Refactor favicon designer components

This commit is contained in:
2026-01-18 00:21:41 +00:00
parent c901b8d8ec
commit 4adcf87903
17 changed files with 1651 additions and 1116 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Drop, Gradient, PencilSimple, Sparkle } from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
import { formatCopy } from './formatCopy'
import { BrushEffect } from './types'
type BrushSettingsPanelProps = {
drawMode: 'draw' | 'erase'
brushEffect: BrushEffect
brushColor: string
brushSize: number
gradientColor: string
glowIntensity: number
onBrushEffectChange: (value: BrushEffect) => void
onBrushColorChange: (value: string) => void
onBrushSizeChange: (value: number) => void
onGradientColorChange: (value: string) => void
onGlowIntensityChange: (value: number) => void
}
export const BrushSettingsPanel = ({
drawMode,
brushEffect,
brushColor,
brushSize,
gradientColor,
glowIntensity,
onBrushEffectChange,
onBrushColorChange,
onBrushSizeChange,
onGradientColorChange,
onGlowIntensityChange,
}: BrushSettingsPanelProps) => (
<div className="space-y-4">
<Label className="text-base font-semibold">
{drawMode === 'draw' ? copy.brush.settingsTitle : copy.brush.eraserSettingsTitle}
</Label>
{drawMode === 'draw' && (
<>
<div>
<Label>{copy.brush.effectLabel}</Label>
<Select value={brushEffect} onValueChange={(value) => onBrushEffectChange(value as BrushEffect)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<div className="flex items-center gap-2">
<PencilSimple size={16} />
{copy.effects.solid}
</div>
</SelectItem>
<SelectItem value="gradient">
<div className="flex items-center gap-2">
<Gradient size={16} />
{copy.effects.gradient}
</div>
</SelectItem>
<SelectItem value="spray">
<div className="flex items-center gap-2">
<Drop size={16} />
{copy.effects.spray}
</div>
</SelectItem>
<SelectItem value="glow">
<div className="flex items-center gap-2">
<Sparkle size={16} />
{copy.effects.glow}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{copy.brush.colorLabel}</Label>
<div className="flex gap-2">
<Input
type="color"
value={brushColor}
onChange={(event) => onBrushColorChange(event.target.value)}
className="w-20 h-10"
/>
<Input
value={brushColor}
onChange={(event) => onBrushColorChange(event.target.value)}
placeholder={copy.placeholders.color}
/>
</div>
</div>
{brushEffect === 'gradient' && (
<div>
<Label>{copy.brush.gradientColorLabel}</Label>
<div className="flex gap-2">
<Input
type="color"
value={gradientColor}
onChange={(event) => onGradientColorChange(event.target.value)}
className="w-20 h-10"
/>
<Input
value={gradientColor}
onChange={(event) => onGradientColorChange(event.target.value)}
placeholder={copy.placeholders.gradient}
/>
</div>
</div>
)}
{brushEffect === 'glow' && (
<div>
<Label>{formatCopy(copy.brush.glowIntensity, { value: glowIntensity })}</Label>
<Slider
value={[glowIntensity]}
onValueChange={([value]) => onGlowIntensityChange(value)}
min={1}
max={30}
step={1}
/>
</div>
)}
</>
)}
<div>
<Label>
{formatCopy(copy.brush.sizeLabel, {
mode: drawMode === 'draw' ? copy.modes.draw : copy.modes.erase,
size: brushSize,
})}
</Label>
<Slider value={[brushSize]} onValueChange={([value]) => onBrushSizeChange(value)} min={1} max={20} step={1} />
</div>
</div>
)

View File

@@ -0,0 +1,28 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import copy from '@/data/favicon-designer.json'
import { FaviconElement } from './types'
type ColorInspectorProps = {
element: FaviconElement
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const ColorInspector = ({ element, onUpdateElement }: ColorInspectorProps) => (
<div>
<Label>{copy.inspector.color}</Label>
<div className="flex gap-2">
<Input
type="color"
value={element.color}
onChange={(event) => onUpdateElement({ color: event.target.value })}
className="w-20 h-10"
/>
<Input
value={element.color}
onChange={(event) => onUpdateElement({ color: event.target.value })}
placeholder={copy.placeholders.color}
/>
</div>
</div>
)

View File

@@ -0,0 +1,116 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import copy from '@/data/favicon-designer.json'
import { PRESET_SIZES } from './constants'
import { formatCopy } from './formatCopy'
import { CanvasFilter, FaviconDesign } from './types'
type DesignSettingsPanelProps = {
activeDesign: FaviconDesign
activeDesignId: string
designs: FaviconDesign[]
onUpdateDesign: (updates: Partial<FaviconDesign>) => void
onSelectDesign: (value: string) => void
}
export const DesignSettingsPanel = ({
activeDesign,
activeDesignId,
designs,
onUpdateDesign,
onSelectDesign,
}: DesignSettingsPanelProps) => (
<div className="space-y-6">
<div>
<Label>{copy.design.nameLabel}</Label>
<Input
value={activeDesign.name}
onChange={(e) => onUpdateDesign({ name: e.target.value })}
placeholder={copy.design.namePlaceholder}
/>
</div>
<div>
<Label>{copy.design.selectLabel}</Label>
<Select value={activeDesignId} onValueChange={onSelectDesign}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{designs.map((design) => (
<SelectItem key={design.id} value={design.id}>
{design.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{copy.design.sizeLabel}</Label>
<Select value={String(activeDesign.size)} onValueChange={(value) => onUpdateDesign({ size: Number(value) })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESET_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}x{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{copy.design.backgroundLabel}</Label>
<div className="flex gap-2">
<Input
type="color"
value={activeDesign.backgroundColor}
onChange={(e) => onUpdateDesign({ backgroundColor: e.target.value })}
className="w-20 h-10"
/>
<Input
value={activeDesign.backgroundColor}
onChange={(e) => onUpdateDesign({ backgroundColor: e.target.value })}
placeholder={copy.design.backgroundPlaceholder}
/>
</div>
</div>
<div>
<Label>{copy.design.filterLabel}</Label>
<Select
value={activeDesign.filter || 'none'}
onValueChange={(value) => onUpdateDesign({ filter: value as CanvasFilter })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(copy.filters) as Array<keyof typeof copy.filters>).map((key) => (
<SelectItem key={key} value={key}>
{copy.filters[key]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeDesign.filter && activeDesign.filter !== 'none' && (
<div>
<Label>{formatCopy(copy.design.filterIntensity, { value: activeDesign.filterIntensity || 50 })}</Label>
<Slider
value={[activeDesign.filterIntensity || 50]}
onValueChange={([value]) => onUpdateDesign({ filterIntensity: value })}
min={0}
max={100}
step={1}
/>
</div>
)}
</div>
)

View File

@@ -0,0 +1,42 @@
import { Label } from '@/components/ui/label'
import copy from '@/data/favicon-designer.json'
import { ColorInspector } from './ColorInspector'
import { FreehandInspector } from './FreehandInspector'
import { ShapeInspector } from './ShapeInspector'
import { TextEmojiInspector } from './TextEmojiInspector'
import { TransformInspector } from './TransformInspector'
import { FaviconDesign, FaviconElement } from './types'
type ElementInspectorPanelProps = {
activeDesign: FaviconDesign
selectedElement: FaviconElement
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const ElementInspectorPanel = ({
activeDesign,
selectedElement,
onUpdateElement,
}: ElementInspectorPanelProps) => (
<div className="space-y-4">
<Label className="text-base font-semibold">{copy.inspector.title}</Label>
{selectedElement.type === 'freehand' && (
<FreehandInspector element={selectedElement} onUpdateElement={onUpdateElement} />
)}
{(selectedElement.type === 'text' || selectedElement.type === 'emoji') && (
<TextEmojiInspector element={selectedElement} onUpdateElement={onUpdateElement} />
)}
{selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && (
<ShapeInspector element={selectedElement} activeDesign={activeDesign} onUpdateElement={onUpdateElement} />
)}
{selectedElement.type !== 'freehand' && (
<TransformInspector element={selectedElement} activeDesign={activeDesign} onUpdateElement={onUpdateElement} />
)}
{selectedElement.type !== 'freehand' && <ColorInspector element={selectedElement} onUpdateElement={onUpdateElement} />}
</div>
)

View File

@@ -0,0 +1,104 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { PencilSimple, Trash } from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
import { ELEMENT_TYPES } from './constants'
import { formatCopy } from './formatCopy'
import { FaviconDesign, FaviconElement } from './types'
type ElementsPanelProps = {
activeDesign: FaviconDesign
drawMode: 'select' | 'draw' | 'erase'
selectedElementId: string | null
onAddElement: (type: FaviconElement['type']) => void
onSelectElement: (id: string) => void
onDeleteElement: (id: string) => void
}
export const ElementsPanel = ({
activeDesign,
drawMode,
selectedElementId,
onAddElement,
onSelectElement,
onDeleteElement,
}: ElementsPanelProps) => (
<div className="space-y-6">
<div>
<Label className="mb-3 block">{copy.elements.addTitle}</Label>
<div className="grid grid-cols-4 gap-2">
{ELEMENT_TYPES.map(({ value, icon: Icon }) => (
<Button
key={value}
variant="outline"
size="sm"
onClick={() => onAddElement(value as FaviconElement['type'])}
className="flex flex-col gap-1 h-auto py-2"
disabled={drawMode !== 'select'}
>
<Icon size={20} />
<span className="text-xs">
{copy.elementTypes[value as keyof typeof copy.elementTypes]}
</span>
</Button>
))}
</div>
{drawMode !== 'select' && <p className="text-xs text-muted-foreground mt-2">{copy.elements.selectHint}</p>}
</div>
<div>
<Label className="mb-3 block">{formatCopy(copy.elements.listTitle, { count: activeDesign.elements.length })}</Label>
<ScrollArea className="h-40">
<div className="space-y-2">
{activeDesign.elements.map((element) => (
<div
key={element.id}
className={`flex items-center justify-between p-2 rounded border cursor-pointer ${
selectedElementId === element.id ? 'border-primary bg-primary/10' : 'border-border hover:bg-accent/50'
}`}
onClick={() => {
if (drawMode === 'select') {
onSelectElement(element.id)
}
}}
>
<div className="flex items-center gap-2">
{element.type === 'freehand' ? (
<PencilSimple size={16} />
) : (
ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && (
<span>
{(() => {
const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon
return <Icon size={16} />
})()}
</span>
)
)}
<span className="text-sm capitalize">
{copy.elementTypes[element.type as keyof typeof copy.elementTypes] || element.type}
</span>
{element.text && <span className="text-xs text-muted-foreground">"{element.text}"</span>}
{element.emoji && <span className="text-xs">{element.emoji}</span>}
</div>
<Button
size="sm"
variant="ghost"
onClick={(event) => {
event.stopPropagation()
onDeleteElement(element.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{activeDesign.elements.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">{copy.elements.empty}</p>
)}
</div>
</ScrollArea>
</div>
</div>
)

View File

@@ -0,0 +1,127 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Download } from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
import { PRESET_SIZES } from './constants'
import { formatCopy } from './formatCopy'
type FaviconDesignerCanvasProps = {
activeSize: number
brushEffect: string
brushSize: number
canvasRef: React.RefObject<HTMLCanvasElement>
drawingCanvasRef: React.RefObject<HTMLCanvasElement>
drawMode: 'select' | 'draw' | 'erase'
onExport: (format: 'png' | 'ico' | 'svg', size?: number) => void
onExportAll: () => void
onMouseDown: (event: React.MouseEvent<HTMLCanvasElement>) => void
onMouseMove: (event: React.MouseEvent<HTMLCanvasElement>) => void
onMouseUp: () => void
onMouseLeave: () => void
}
export const FaviconDesignerCanvas = ({
activeSize,
brushEffect,
brushSize,
canvasRef,
drawingCanvasRef,
drawMode,
onExport,
onExportAll,
onMouseDown,
onMouseMove,
onMouseUp,
onMouseLeave,
}: FaviconDesignerCanvasProps) => (
<div className="border-r border-border p-6 flex flex-col items-center justify-center bg-muted/20">
<Card className="p-8 mb-6">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="relative">
<canvas
ref={canvasRef}
className="border-2 border-border rounded-lg shadow-xl absolute top-0 left-0"
style={{
width: '400px',
height: '400px',
imageRendering: 'pixelated',
pointerEvents: 'none',
}}
/>
<canvas
ref={drawingCanvasRef}
className="border-2 border-border rounded-lg shadow-xl relative z-10"
style={{
width: '400px',
height: '400px',
imageRendering: 'pixelated',
cursor: drawMode === 'draw' ? 'crosshair' : drawMode === 'erase' ? 'not-allowed' : 'default',
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
/>
</div>
<Badge className="absolute -top-3 -right-3">
{activeSize}x{activeSize}
</Badge>
{drawMode !== 'select' && (
<Badge className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-accent">
{drawMode === 'draw'
? formatCopy(copy.canvas.brushBadge, {
effect: copy.effects[brushEffect as keyof typeof copy.effects] || brushEffect,
size: brushSize,
})
: formatCopy(copy.canvas.eraserBadge, { size: brushSize * 2 })}
</Badge>
)}
</div>
<div className="flex gap-2 flex-wrap justify-center">
{PRESET_SIZES.map((size) => (
<div
key={size}
className="flex flex-col items-center gap-1 p-2 rounded border border-border hover:bg-accent/50 cursor-pointer"
onClick={() => onExport('png', size)}
title={formatCopy(copy.canvas.exportPresetTitle, { size })}
>
<canvas
width={size}
height={size}
ref={(canvas) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx || !canvasRef.current) return
ctx.drawImage(canvasRef.current, 0, 0, size, size)
}}
className="border border-border rounded"
style={{ width: `${size / 2}px`, height: `${size / 2}px` }}
/>
<span className="text-xs text-muted-foreground">
{formatCopy(copy.canvas.presetLabel, { size })}
</span>
</div>
))}
</div>
</div>
</Card>
<div className="flex gap-2">
<Button onClick={() => onExport('png')}>
<Download size={16} className="mr-2" />
{copy.export.png}
</Button>
<Button onClick={() => onExport('svg')} variant="outline">
<Download size={16} className="mr-2" />
{copy.export.svg}
</Button>
<Button onClick={onExportAll} variant="outline">
<Download size={16} className="mr-2" />
{copy.export.all}
</Button>
</div>
</div>
)

View File

@@ -0,0 +1,110 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { BrushSettingsPanel } from './BrushSettingsPanel'
import { DesignSettingsPanel } from './DesignSettingsPanel'
import { ElementInspectorPanel } from './ElementInspectorPanel'
import { ElementsPanel } from './ElementsPanel'
import { BrushEffect, FaviconDesign, FaviconElement } from './types'
type FaviconDesignerSidebarProps = {
activeDesign: FaviconDesign
activeDesignId: string
brushColor: string
brushEffect: BrushEffect
brushSize: number
drawMode: 'select' | 'draw' | 'erase'
glowIntensity: number
gradientColor: string
selectedElement: FaviconElement | undefined
selectedElementId: string | null
designs: FaviconDesign[]
onAddElement: (type: FaviconElement['type']) => void
onDeleteElement: (id: string) => void
onSelectElement: (id: string) => void
onSelectDesign: (value: string) => void
onUpdateDesign: (updates: Partial<FaviconDesign>) => void
onUpdateElement: (updates: Partial<FaviconElement>) => void
onBrushEffectChange: (value: BrushEffect) => void
onBrushColorChange: (value: string) => void
onBrushSizeChange: (value: number) => void
onGradientColorChange: (value: string) => void
onGlowIntensityChange: (value: number) => void
}
export const FaviconDesignerSidebar = ({
activeDesign,
activeDesignId,
brushColor,
brushEffect,
brushSize,
drawMode,
glowIntensity,
gradientColor,
selectedElement,
selectedElementId,
designs,
onAddElement,
onDeleteElement,
onSelectElement,
onSelectDesign,
onUpdateDesign,
onUpdateElement,
onBrushEffectChange,
onBrushColorChange,
onBrushSizeChange,
onGradientColorChange,
onGlowIntensityChange,
}: FaviconDesignerSidebarProps) => (
<ScrollArea className="h-full">
<div className="p-6 space-y-6">
<DesignSettingsPanel
activeDesign={activeDesign}
activeDesignId={activeDesignId}
designs={designs}
onUpdateDesign={onUpdateDesign}
onSelectDesign={onSelectDesign}
/>
<Separator />
<ElementsPanel
activeDesign={activeDesign}
drawMode={drawMode}
selectedElementId={selectedElementId}
onAddElement={onAddElement}
onSelectElement={onSelectElement}
onDeleteElement={onDeleteElement}
/>
{drawMode !== 'select' && (
<>
<Separator />
<BrushSettingsPanel
drawMode={drawMode}
brushEffect={brushEffect}
brushColor={brushColor}
brushSize={brushSize}
gradientColor={gradientColor}
glowIntensity={glowIntensity}
onBrushEffectChange={onBrushEffectChange}
onBrushColorChange={onBrushColorChange}
onBrushSizeChange={onBrushSizeChange}
onGradientColorChange={onGradientColorChange}
onGlowIntensityChange={onGlowIntensityChange}
/>
</>
)}
{selectedElement && drawMode === 'select' && (
<>
<Separator />
<ElementInspectorPanel
activeDesign={activeDesign}
selectedElement={selectedElement}
onUpdateElement={onUpdateElement}
/>
</>
)}
</div>
</ScrollArea>
)

View File

@@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button'
import { Copy, Eraser, PencilSimple, Plus, Trash } from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
type FaviconDesignerToolbarProps = {
drawMode: 'select' | 'draw' | 'erase'
canDelete: boolean
onNewDesign: () => void
onDuplicateDesign: () => void
onDeleteDesign: () => void
onSelectMode: () => void
onDrawMode: () => void
onEraseMode: () => void
}
export const FaviconDesignerToolbar = ({
drawMode,
canDelete,
onNewDesign,
onDuplicateDesign,
onDeleteDesign,
onSelectMode,
onDrawMode,
onEraseMode,
}: FaviconDesignerToolbarProps) => (
<div className="border-b border-border bg-card px-4 sm:px-6 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onNewDesign}>
<Plus size={16} className="mr-2" />
{copy.toolbar.newDesign}
</Button>
<Button variant="outline" size="sm" onClick={onDuplicateDesign}>
<Copy size={16} className="mr-2" />
{copy.toolbar.duplicate}
</Button>
<Button variant="outline" size="sm" onClick={onDeleteDesign} disabled={!canDelete}>
<Trash size={16} className="mr-2" />
{copy.toolbar.delete}
</Button>
</div>
<div className="flex gap-2">
<Button variant={drawMode === 'select' ? 'default' : 'outline'} size="sm" onClick={onSelectMode}>
{copy.modes.select}
</Button>
<Button variant={drawMode === 'draw' ? 'default' : 'outline'} size="sm" onClick={onDrawMode}>
<PencilSimple size={16} className="mr-2" />
{copy.modes.draw}
</Button>
<Button variant={drawMode === 'erase' ? 'default' : 'outline'} size="sm" onClick={onEraseMode}>
<Eraser size={16} className="mr-2" />
{copy.modes.erase}
</Button>
</div>
</div>
</div>
)

View File

@@ -0,0 +1,115 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Drop, Gradient, PencilSimple, Sparkle } from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
import { formatCopy } from './formatCopy'
import { BrushEffect, FaviconElement } from './types'
type FreehandInspectorProps = {
element: FaviconElement
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const FreehandInspector = ({ element, onUpdateElement }: FreehandInspectorProps) => (
<>
<div>
<Label>{copy.brush.effectLabel}</Label>
<Select
value={element.brushEffect || 'solid'}
onValueChange={(value) => onUpdateElement({ brushEffect: value as BrushEffect })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<div className="flex items-center gap-2">
<PencilSimple size={16} />
{copy.effects.solid}
</div>
</SelectItem>
<SelectItem value="gradient">
<div className="flex items-center gap-2">
<Gradient size={16} />
{copy.effects.gradient}
</div>
</SelectItem>
<SelectItem value="spray">
<div className="flex items-center gap-2">
<Drop size={16} />
{copy.effects.spray}
</div>
</SelectItem>
<SelectItem value="glow">
<div className="flex items-center gap-2">
<Sparkle size={16} />
{copy.effects.glow}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{copy.inspector.strokeColor}</Label>
<div className="flex gap-2">
<Input
type="color"
value={element.color}
onChange={(event) => onUpdateElement({ color: event.target.value })}
className="w-20 h-10"
/>
<Input
value={element.color}
onChange={(event) => onUpdateElement({ color: event.target.value })}
placeholder={copy.placeholders.color}
/>
</div>
</div>
{element.brushEffect === 'gradient' && (
<div>
<Label>{copy.brush.gradientColorLabel}</Label>
<div className="flex gap-2">
<Input
type="color"
value={element.gradientColor || copy.placeholders.gradient}
onChange={(event) => onUpdateElement({ gradientColor: event.target.value })}
className="w-20 h-10"
/>
<Input
value={element.gradientColor || copy.placeholders.gradient}
onChange={(event) => onUpdateElement({ gradientColor: event.target.value })}
placeholder={copy.placeholders.gradient}
/>
</div>
</div>
)}
{element.brushEffect === 'glow' && (
<div>
<Label>{formatCopy(copy.brush.glowIntensity, { value: element.glowIntensity || 10 })}</Label>
<Slider
value={[element.glowIntensity || 10]}
onValueChange={([value]) => onUpdateElement({ glowIntensity: value })}
min={1}
max={30}
step={1}
/>
</div>
)}
<div>
<Label>{formatCopy(copy.inspector.strokeWidth, { value: element.strokeWidth || 3 })}</Label>
<Slider
value={[element.strokeWidth || 3]}
onValueChange={([value]) => onUpdateElement({ strokeWidth: value })}
min={1}
max={20}
step={1}
/>
</div>
</>
)

View File

@@ -0,0 +1,36 @@
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import copy from '@/data/favicon-designer.json'
import { formatCopy } from './formatCopy'
import { FaviconDesign, FaviconElement } from './types'
type ShapeInspectorProps = {
element: FaviconElement
activeDesign: FaviconDesign
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const ShapeInspector = ({ element, activeDesign, onUpdateElement }: ShapeInspectorProps) => (
<>
<div>
<Label>{formatCopy(copy.inspector.width, { value: element.width })}</Label>
<Slider
value={[element.width]}
onValueChange={([value]) => onUpdateElement({ width: value })}
min={10}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>{formatCopy(copy.inspector.height, { value: element.height })}</Label>
<Slider
value={[element.height]}
onValueChange={([value]) => onUpdateElement({ height: value })}
min={10}
max={activeDesign.size}
step={1}
/>
</div>
</>
)

View File

@@ -0,0 +1,65 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import copy from '@/data/favicon-designer.json'
import { formatCopy } from './formatCopy'
import { FaviconElement } from './types'
type TextEmojiInspectorProps = {
element: FaviconElement
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const TextEmojiInspector = ({ element, onUpdateElement }: TextEmojiInspectorProps) => (
<>
{element.type === 'text' && (
<div>
<Label>{copy.inspector.textLabel}</Label>
<Input
value={element.text || ''}
onChange={(event) => onUpdateElement({ text: event.target.value })}
placeholder={copy.inspector.textPlaceholder}
/>
</div>
)}
{element.type === 'emoji' && (
<div>
<Label>{copy.inspector.emojiLabel}</Label>
<Input
value={element.emoji || ''}
onChange={(event) => onUpdateElement({ emoji: event.target.value })}
placeholder={copy.inspector.emojiPlaceholder}
/>
</div>
)}
<div>
<Label>{formatCopy(copy.inspector.fontSize, { value: element.fontSize })}</Label>
<Slider
value={[element.fontSize || 32]}
onValueChange={([value]) => onUpdateElement({ fontSize: value })}
min={12}
max={200}
step={1}
/>
</div>
{element.type === 'text' && (
<div>
<Label>{copy.inspector.fontWeight}</Label>
<Select value={element.fontWeight || 'bold'} onValueChange={(value) => onUpdateElement({ fontWeight: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">{copy.fontWeights.normal}</SelectItem>
<SelectItem value="bold">{copy.fontWeights.bold}</SelectItem>
<SelectItem value="lighter">{copy.fontWeights.lighter}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</>
)

View File

@@ -0,0 +1,46 @@
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import copy from '@/data/favicon-designer.json'
import { formatCopy } from './formatCopy'
import { FaviconDesign, FaviconElement } from './types'
type TransformInspectorProps = {
element: FaviconElement
activeDesign: FaviconDesign
onUpdateElement: (updates: Partial<FaviconElement>) => void
}
export const TransformInspector = ({ element, activeDesign, onUpdateElement }: TransformInspectorProps) => (
<>
<div>
<Label>{formatCopy(copy.inspector.xPosition, { value: element.x })}</Label>
<Slider
value={[element.x]}
onValueChange={([value]) => onUpdateElement({ x: value })}
min={0}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>{formatCopy(copy.inspector.yPosition, { value: element.y })}</Label>
<Slider
value={[element.y]}
onValueChange={([value]) => onUpdateElement({ y: value })}
min={0}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>{formatCopy(copy.inspector.rotation, { value: element.rotation })}</Label>
<Slider
value={[element.rotation]}
onValueChange={([value]) => onUpdateElement({ rotation: value })}
min={0}
max={360}
step={1}
/>
</div>
</>
)

View File

@@ -8,24 +8,25 @@ import {
TextT,
Image as ImageIcon,
} from '@phosphor-icons/react'
import copy from '@/data/favicon-designer.json'
import { FaviconDesign } from './types'
export const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512]
export const ELEMENT_TYPES = [
{ value: 'circle', label: 'Circle', icon: CircleNotch },
{ value: 'square', label: 'Square', icon: Square },
{ value: 'triangle', label: 'Triangle', icon: Triangle },
{ value: 'star', label: 'Star', icon: Star },
{ value: 'heart', label: 'Heart', icon: Heart },
{ value: 'polygon', label: 'Polygon', icon: Polygon },
{ value: 'text', label: 'Text', icon: TextT },
{ value: 'emoji', label: 'Emoji', icon: ImageIcon },
{ value: 'circle', icon: CircleNotch },
{ value: 'square', icon: Square },
{ value: 'triangle', icon: Triangle },
{ value: 'star', icon: Star },
{ value: 'heart', icon: Heart },
{ value: 'polygon', icon: Polygon },
{ value: 'text', icon: TextT },
{ value: 'emoji', icon: ImageIcon },
]
export const DEFAULT_DESIGN: FaviconDesign = {
id: 'default',
name: 'My Favicon',
name: copy.defaults.designName,
size: 128,
backgroundColor: '#7c3aed',
elements: [
@@ -38,7 +39,7 @@ export const DEFAULT_DESIGN: FaviconDesign = {
height: 100,
color: '#ffffff',
rotation: 0,
text: 'CF',
text: copy.defaults.designText,
fontSize: 48,
fontWeight: 'bold',
},

View File

@@ -0,0 +1,5 @@
export const formatCopy = (template: string, values: Record<string, string | number> = {}) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => {
const value = values[key]
return value === undefined ? match : String(value)
})

View File

@@ -0,0 +1,432 @@
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import copy from '@/data/favicon-designer.json'
import { useKV } from '@/hooks/use-kv'
import { DEFAULT_DESIGN, PRESET_SIZES } from './constants'
import { drawCanvas } from './canvasUtils'
import { formatCopy } from './formatCopy'
import { BrushEffect, FaviconDesign, FaviconElement } from './types'
export const useFaviconDesigner = () => {
const [designs, setDesigns] = useKV<FaviconDesign[]>('favicon-designs', [DEFAULT_DESIGN])
const [activeDesignId, setActiveDesignId] = useState<string>(DEFAULT_DESIGN.id)
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [drawMode, setDrawMode] = useState<'select' | 'draw' | 'erase'>('select')
const [brushSize, setBrushSize] = useState(3)
const [brushColor, setBrushColor] = useState('#ffffff')
const [brushEffect, setBrushEffect] = useState<BrushEffect>('solid')
const [gradientColor, setGradientColor] = useState('#ff00ff')
const [glowIntensity, setGlowIntensity] = useState(10)
const [currentPath, setCurrentPath] = useState<Array<{ x: number; y: number }>>([])
const canvasRef = useRef<HTMLCanvasElement>(null)
const drawingCanvasRef = useRef<HTMLCanvasElement>(null)
const safeDesigns = designs || [DEFAULT_DESIGN]
const activeDesign = safeDesigns.find((d) => d.id === activeDesignId) || DEFAULT_DESIGN
const selectedElement = activeDesign.elements.find((e) => e.id === selectedElementId)
useEffect(() => {
const canvas = canvasRef.current
if (canvas) {
drawCanvas(canvas, activeDesign)
}
}, [activeDesign])
useEffect(() => {
const canvas = drawingCanvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = activeDesign.size
canvas.height = activeDesign.size
ctx.clearRect(0, 0, activeDesign.size, activeDesign.size)
}, [activeDesign, drawMode])
const handleAddElement = (type: FaviconElement['type']) => {
const newElement: FaviconElement = {
id: `element-${Date.now()}`,
type,
x: activeDesign.size / 2,
y: activeDesign.size / 2,
width: type === 'text' || type === 'emoji' ? 100 : 40,
height: type === 'text' || type === 'emoji' ? 100 : 40,
color: '#ffffff',
rotation: 0,
...(type === 'text' && { text: copy.defaults.newText, fontSize: 32, fontWeight: 'bold' }),
...(type === 'emoji' && { emoji: copy.defaults.newEmoji, fontSize: 40 }),
}
setDesigns((current) =>
(current || []).map((d) =>
d.id === activeDesignId
? { ...d, elements: [...d.elements, newElement], updatedAt: Date.now() }
: d
)
)
setSelectedElementId(newElement.id)
}
const handleUpdateElement = (updates: Partial<FaviconElement>) => {
if (!selectedElementId) return
setDesigns((current) =>
(current || []).map((d) =>
d.id === activeDesignId
? {
...d,
elements: d.elements.map((e) => (e.id === selectedElementId ? { ...e, ...updates } : e)),
updatedAt: Date.now(),
}
: d
)
)
}
const handleDeleteElement = (elementId: string) => {
setDesigns((current) =>
(current || []).map((d) =>
d.id === activeDesignId
? { ...d, elements: d.elements.filter((e) => e.id !== elementId), updatedAt: Date.now() }
: d
)
)
setSelectedElementId(null)
}
const handleUpdateDesign = (updates: Partial<FaviconDesign>) => {
setDesigns((current) =>
(current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d))
)
}
const handleNewDesign = () => {
const newDesign: FaviconDesign = {
id: `design-${Date.now()}`,
name: formatCopy(copy.design.newDesignName, { count: safeDesigns.length + 1 }),
size: 128,
backgroundColor: '#7c3aed',
elements: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
setDesigns((current) => [...(current || []), newDesign])
setActiveDesignId(newDesign.id)
setSelectedElementId(null)
}
const handleDuplicateDesign = () => {
const newDesign: FaviconDesign = {
...activeDesign,
id: `design-${Date.now()}`,
name: `${activeDesign.name}${copy.design.duplicateSuffix}`,
createdAt: Date.now(),
updatedAt: Date.now(),
}
setDesigns((current) => [...(current || []), newDesign])
setActiveDesignId(newDesign.id)
toast.success(copy.toasts.designDuplicated)
}
const handleDeleteDesign = () => {
if (safeDesigns.length === 1) {
toast.error(copy.toasts.cannotDeleteLast)
return
}
const filteredDesigns = safeDesigns.filter((d) => d.id !== activeDesignId)
setDesigns(filteredDesigns)
setActiveDesignId(filteredDesigns[0].id)
setSelectedElementId(null)
toast.success(copy.toasts.designDeleted)
}
const generateSVG = (): string => {
const size = activeDesign.size
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">`
svg += `<rect width="${size}" height="${size}" fill="${activeDesign.backgroundColor}"/>`
activeDesign.elements.forEach((element) => {
const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})`
switch (element.type) {
case 'circle':
svg += `<circle cx="0" cy="0" r="${element.width / 2}" fill="${element.color}" transform="${transform}"/>`
break
case 'square':
svg += `<rect x="${-element.width / 2}" y="${-element.height / 2}" width="${element.width}" height="${element.height}" fill="${element.color}" transform="${transform}"/>`
break
case 'text':
svg += `<text x="0" y="0" fill="${element.color}" font-size="${element.fontSize}" font-weight="${element.fontWeight}" text-anchor="middle" dominant-baseline="middle" transform="${transform}">${element.text}</text>`
break
}
})
svg += '</svg>'
return svg
}
const handleExport = (format: 'png' | 'ico' | 'svg', size?: number) => {
const canvas = canvasRef.current
if (!canvas) return
if (format === 'png') {
const exportSize = size || activeDesign.size
const tempCanvas = document.createElement('canvas')
tempCanvas.width = exportSize
tempCanvas.height = exportSize
const ctx = tempCanvas.getContext('2d')
if (!ctx) return
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, exportSize, exportSize)
tempCanvas.toBlob((blob) => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeDesign.name}-${exportSize}x${exportSize}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(formatCopy(copy.toasts.exportedPng, { size: exportSize }))
})
} else if (format === 'ico') {
canvas.toBlob((blob) => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeDesign.name}.ico`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(copy.toasts.exportedIco)
})
} else if (format === 'svg') {
const svg = generateSVG()
const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeDesign.name}.svg`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(copy.toasts.exportedSvg)
}
}
const handleExportAll = () => {
PRESET_SIZES.forEach((size) => {
setTimeout(() => handleExport('png', size), size * 10)
})
toast.success(copy.toasts.exportAll)
}
const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = drawingCanvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const scaleX = activeDesign.size / rect.width
const scaleY = activeDesign.size / rect.height
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
}
}
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (drawMode === 'select') return
setIsDrawing(true)
const coords = getCanvasCoordinates(e)
setCurrentPath([coords])
}
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || drawMode === 'select') return
const coords = getCanvasCoordinates(e)
setCurrentPath((prev) => [...prev, coords])
const canvas = drawingCanvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
if (drawMode === 'draw') {
if (brushEffect === 'glow') {
ctx.shadowColor = brushColor
ctx.shadowBlur = glowIntensity
}
if (brushEffect === 'gradient' && currentPath.length > 0) {
const gradient = ctx.createLinearGradient(currentPath[0].x, currentPath[0].y, coords.x, coords.y)
gradient.addColorStop(0, brushColor)
gradient.addColorStop(1, gradientColor)
ctx.strokeStyle = gradient
} else {
ctx.strokeStyle = brushColor
}
ctx.lineWidth = brushSize
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
if (currentPath.length > 0) {
const prevPoint = currentPath[currentPath.length - 1]
if (brushEffect === 'spray') {
for (let i = 0; i < 5; i++) {
const offsetX = (Math.random() - 0.5) * brushSize * 2
const offsetY = (Math.random() - 0.5) * brushSize * 2
ctx.fillStyle = brushColor
ctx.beginPath()
ctx.arc(coords.x + offsetX, coords.y + offsetY, brushSize / 3, 0, Math.PI * 2)
ctx.fill()
}
} else {
ctx.beginPath()
ctx.moveTo(prevPoint.x, prevPoint.y)
ctx.lineTo(coords.x, coords.y)
ctx.stroke()
}
}
ctx.shadowBlur = 0
} else if (drawMode === 'erase') {
ctx.globalCompositeOperation = 'destination-out'
ctx.lineWidth = brushSize * 2
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
if (currentPath.length > 0) {
const prevPoint = currentPath[currentPath.length - 1]
ctx.beginPath()
ctx.moveTo(prevPoint.x, prevPoint.y)
ctx.lineTo(coords.x, coords.y)
ctx.stroke()
}
ctx.globalCompositeOperation = 'source-over'
}
}
const handleCanvasMouseUp = () => {
if (!isDrawing || drawMode === 'select') return
setIsDrawing(false)
if (drawMode === 'draw' && currentPath.length > 1) {
const newElement: FaviconElement = {
id: `element-${Date.now()}`,
type: 'freehand',
x: 0,
y: 0,
width: 0,
height: 0,
color: brushColor,
rotation: 0,
paths: currentPath,
strokeWidth: brushSize,
brushEffect,
gradientColor: brushEffect === 'gradient' ? gradientColor : undefined,
glowIntensity: brushEffect === 'glow' ? glowIntensity : undefined,
}
setDesigns((current) =>
(current || []).map((d) =>
d.id === activeDesignId
? { ...d, elements: [...d.elements, newElement], updatedAt: Date.now() }
: d
)
)
} else if (drawMode === 'erase') {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const filteredElements = activeDesign.elements.filter((element) => {
if (element.type !== 'freehand' || !element.paths) return true
return !element.paths.some((point) =>
currentPath.some((erasePoint) => {
const distance = Math.sqrt(Math.pow(point.x - erasePoint.x, 2) + Math.pow(point.y - erasePoint.y, 2))
return distance < brushSize * 2
})
)
})
if (filteredElements.length !== activeDesign.elements.length) {
setDesigns((current) =>
(current || []).map((d) =>
d.id === activeDesignId
? { ...d, elements: filteredElements, updatedAt: Date.now() }
: d
)
)
}
}
setCurrentPath([])
const canvas = canvasRef.current
if (canvas) {
drawCanvas(canvas, activeDesign)
}
}
const handleCanvasMouseLeave = () => {
if (isDrawing) {
handleCanvasMouseUp()
}
}
return {
activeDesign,
activeDesignId,
brushColor,
brushEffect,
brushSize,
canvasRef,
drawMode,
drawingCanvasRef,
glowIntensity,
gradientColor,
safeDesigns,
selectedElement,
selectedElementId,
setActiveDesignId,
setBrushColor,
setBrushEffect,
setBrushSize,
setDrawMode,
setGlowIntensity,
setGradientColor,
setSelectedElementId,
handleAddElement,
handleCanvasMouseDown,
handleCanvasMouseLeave,
handleCanvasMouseMove,
handleCanvasMouseUp,
handleDeleteDesign,
handleDeleteElement,
handleDuplicateDesign,
handleExport,
handleExportAll,
handleNewDesign,
handleUpdateDesign,
handleUpdateElement,
}
}

View File

@@ -0,0 +1,120 @@
{
"toolbar": {
"newDesign": "New Design",
"duplicate": "Duplicate",
"delete": "Delete"
},
"modes": {
"select": "Select",
"draw": "Draw",
"erase": "Erase"
},
"toasts": {
"designDuplicated": "Design duplicated",
"cannotDeleteLast": "Cannot delete the last design",
"designDeleted": "Design deleted",
"exportedPng": "Exported as {size}x{size} PNG",
"exportedIco": "Exported as ICO",
"exportedSvg": "Exported as SVG",
"exportAll": "Exporting all sizes..."
},
"canvas": {
"exportPresetTitle": "Export {size}x{size}",
"brushBadge": "{effect}: {size}px",
"eraserBadge": "Eraser: {size}px",
"presetLabel": "{size}px"
},
"export": {
"png": "Export PNG",
"svg": "Export SVG",
"all": "Export All Sizes"
},
"design": {
"nameLabel": "Design Name",
"namePlaceholder": "My Favicon",
"newDesignName": "Favicon {count}",
"duplicateSuffix": " (Copy)",
"selectLabel": "Select Design",
"sizeLabel": "Canvas Size",
"backgroundLabel": "Background Color",
"backgroundPlaceholder": "#7c3aed",
"filterLabel": "Image Filter",
"filterIntensity": "Filter Intensity: {value}%"
},
"filters": {
"none": "None",
"blur": "Blur",
"brightness": "Brightness",
"contrast": "Contrast",
"grayscale": "Grayscale",
"sepia": "Sepia",
"invert": "Invert",
"saturate": "Saturate",
"hue-rotate": "Hue Rotate",
"pixelate": "Pixelate"
},
"elements": {
"addTitle": "Add Elements",
"selectHint": "Switch to Select mode to add elements",
"listTitle": "Elements ({count})",
"empty": "No elements yet. Add some or start drawing!"
},
"brush": {
"settingsTitle": "Brush Settings",
"eraserSettingsTitle": "Eraser Settings",
"effectLabel": "Brush Effect",
"colorLabel": "Brush Color",
"gradientColorLabel": "Gradient End Color",
"glowIntensity": "Glow Intensity: {value}px",
"sizeLabel": "{mode} Size: {size}px"
},
"inspector": {
"title": "Edit Element",
"strokeColor": "Stroke Color",
"strokeWidth": "Stroke Width: {value}px",
"textLabel": "Text",
"textPlaceholder": "Enter text",
"emojiLabel": "Emoji",
"emojiPlaceholder": "😀",
"fontSize": "Font Size: {value}px",
"fontWeight": "Font Weight",
"width": "Width: {value}px",
"height": "Height: {value}px",
"xPosition": "X Position: {value}px",
"yPosition": "Y Position: {value}px",
"rotation": "Rotation: {value}°",
"color": "Color"
},
"effects": {
"solid": "Solid",
"gradient": "Gradient",
"spray": "Spray Paint",
"glow": "Glow"
},
"fontWeights": {
"normal": "Normal",
"bold": "Bold",
"lighter": "Light"
},
"elementTypes": {
"circle": "Circle",
"square": "Square",
"triangle": "Triangle",
"star": "Star",
"heart": "Heart",
"polygon": "Polygon",
"text": "Text",
"emoji": "Emoji",
"freehand": "Freehand"
},
"defaults": {
"designName": "My Favicon",
"designText": "CF",
"newText": "A",
"newEmoji": "😀"
},
"placeholders": {
"color": "#ffffff",
"gradient": "#ff00ff"
}
}