diff --git a/src/App.tsx b/src/App.tsx index 296eca4..f9e22be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card } from '@/components/ui/card' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' -import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard, FlowArrow, Faders, DeviceMobile } from '@phosphor-icons/react' +import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard, FlowArrow, Faders, DeviceMobile, Image } from '@phosphor-icons/react' import { ProjectFile, PrismaModel, ComponentNode, ComponentTree, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig, NpmSettings, Workflow, Lambda, FeatureToggles, Project } from '@/types/project' import { CodeEditor } from '@/components/CodeEditor' import { ModelDesigner } from '@/components/ModelDesigner' @@ -32,6 +32,7 @@ import { PWAInstallPrompt } from '@/components/PWAInstallPrompt' import { PWAUpdatePrompt } from '@/components/PWAUpdatePrompt' import { PWAStatusBar } from '@/components/PWAStatusBar' import { PWASettings } from '@/components/PWASettings' +import { FaviconDesigner } from '@/components/FaviconDesigner' import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' import { generateNextJSProject, generatePrismaSchema, generateMUITheme, generatePlaywrightTests, generateStorybookStories, generateUnitTests, generateFlaskApp } from '@/lib/generators' import { AIService } from '@/lib/ai-service' @@ -99,6 +100,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = { errorRepair: true, documentation: true, sassStyles: true, + faviconDesigner: true, } const DEFAULT_THEME: ThemeConfig = { @@ -278,6 +280,12 @@ function App() { description: 'Go to Styling', action: () => setActiveTab('styling'), }] : []), + ...(safeFeatureToggles.faviconDesigner ? [{ + key: '9', + ctrl: true, + description: 'Go to Favicon Designer', + action: () => setActiveTab('favicon'), + }] : []), { key: 'e', ctrl: true, @@ -673,6 +681,12 @@ Navigate to the backend directory and follow the setup instructions. Sass Styles )} + {safeFeatureToggles.faviconDesigner && ( + + + Favicon Designer + + )} @@ -828,6 +842,12 @@ Navigate to the backend directory and follow the setup instructions. )} + + {safeFeatureToggles.faviconDesigner && ( + + + + )} diff --git a/src/components/FaviconDesigner.tsx b/src/components/FaviconDesigner.tsx new file mode 100644 index 0000000..1ece320 --- /dev/null +++ b/src/components/FaviconDesigner.tsx @@ -0,0 +1,777 @@ +import { useState, useRef, useEffect } from 'react' +import { useKV } from '@github/spark/hooks' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Slider } from '@/components/ui/slider' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Separator } from '@/components/ui/separator' +import { Badge } from '@/components/ui/badge' +import { + Plus, + Trash, + Download, + CircleNotch, + Square, + Triangle, + Star, + Heart, + Polygon, + TextT, + Image as ImageIcon, + ArrowCounterClockwise, + Copy, + FloppyDisk +} from '@phosphor-icons/react' +import { toast } from 'sonner' + +interface FaviconElement { + id: string + type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' + x: number + y: number + width: number + height: number + color: string + rotation: number + text?: string + fontSize?: number + fontWeight?: string + emoji?: string +} + +interface FaviconDesign { + id: string + name: string + size: number + backgroundColor: string + elements: FaviconElement[] + createdAt: number + updatedAt: number +} + +const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512] +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 }, +] + +const DEFAULT_DESIGN: FaviconDesign = { + id: 'default', + name: 'My Favicon', + size: 128, + backgroundColor: '#7c3aed', + elements: [ + { + id: '1', + type: 'text', + x: 64, + y: 64, + width: 100, + height: 100, + color: '#ffffff', + rotation: 0, + text: 'CF', + fontSize: 48, + fontWeight: 'bold', + }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), +} + +export function FaviconDesigner() { + const [designs, setDesigns] = useKV('favicon-designs', [DEFAULT_DESIGN]) + const [activeDesignId, setActiveDesignId] = useState(DEFAULT_DESIGN.id) + const [selectedElementId, setSelectedElementId] = useState(null) + const canvasRef = useRef(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(() => { + drawCanvas() + }, [activeDesign]) + + const drawCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + const size = activeDesign.size + canvas.width = size + canvas.height = size + + ctx.fillStyle = activeDesign.backgroundColor + ctx.fillRect(0, 0, size, size) + + activeDesign.elements.forEach((element) => { + ctx.save() + ctx.translate(element.x, element.y) + ctx.rotate((element.rotation * Math.PI) / 180) + ctx.fillStyle = element.color + + switch (element.type) { + case 'circle': + ctx.beginPath() + ctx.arc(0, 0, element.width / 2, 0, Math.PI * 2) + ctx.fill() + break + case 'square': + ctx.fillRect(-element.width / 2, -element.height / 2, element.width, element.height) + break + case 'triangle': + ctx.beginPath() + ctx.moveTo(0, -element.height / 2) + ctx.lineTo(element.width / 2, element.height / 2) + ctx.lineTo(-element.width / 2, element.height / 2) + ctx.closePath() + ctx.fill() + break + case 'star': + drawStar(ctx, 0, 0, 5, element.width / 2, element.width / 4) + break + case 'heart': + drawHeart(ctx, 0, 0, element.width) + break + case 'polygon': + drawPolygon(ctx, 0, 0, 6, element.width / 2) + break + case 'text': + ctx.fillStyle = element.color + ctx.font = `${element.fontWeight || 'bold'} ${element.fontSize || 32}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(element.text || '', 0, 0) + break + case 'emoji': + ctx.font = `${element.fontSize || 32}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(element.emoji || '😀', 0, 0) + break + } + + ctx.restore() + }) + } + + const drawStar = (ctx: CanvasRenderingContext2D, cx: number, cy: number, spikes: number, outerRadius: number, innerRadius: number) => { + let rot = (Math.PI / 2) * 3 + let x = cx + let y = cy + const step = Math.PI / spikes + + ctx.beginPath() + ctx.moveTo(cx, cy - outerRadius) + for (let i = 0; i < spikes; i++) { + x = cx + Math.cos(rot) * outerRadius + y = cy + Math.sin(rot) * outerRadius + ctx.lineTo(x, y) + rot += step + + x = cx + Math.cos(rot) * innerRadius + y = cy + Math.sin(rot) * innerRadius + ctx.lineTo(x, y) + rot += step + } + ctx.lineTo(cx, cy - outerRadius) + ctx.closePath() + ctx.fill() + } + + const drawHeart = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { + const topCurveHeight = size * 0.3 + ctx.beginPath() + ctx.moveTo(x, y + topCurveHeight) + ctx.bezierCurveTo(x, y, x - size / 2, y - topCurveHeight, x - size / 2, y + topCurveHeight) + ctx.bezierCurveTo(x - size / 2, y + (size + topCurveHeight) / 2, x, y + (size + topCurveHeight) / 1.2, x, y + size) + ctx.bezierCurveTo(x, y + (size + topCurveHeight) / 1.2, x + size / 2, y + (size + topCurveHeight) / 2, x + size / 2, y + topCurveHeight) + ctx.bezierCurveTo(x + size / 2, y - topCurveHeight, x, y, x, y + topCurveHeight) + ctx.closePath() + ctx.fill() + } + + const drawPolygon = (ctx: CanvasRenderingContext2D, x: number, y: number, sides: number, radius: number) => { + ctx.beginPath() + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const px = x + radius * Math.cos(angle) + const py = y + radius * Math.sin(angle) + if (i === 0) ctx.moveTo(px, py) + else ctx.lineTo(px, py) + } + ctx.closePath() + ctx.fill() + } + + 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: 'A', fontSize: 32, fontWeight: 'bold' }), + ...(type === 'emoji' && { emoji: '😀', 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) => { + 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) => { + setDesigns((current) => + (current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d)) + ) + } + + const handleNewDesign = () => { + const newDesign: FaviconDesign = { + id: `design-${Date.now()}`, + name: `Favicon ${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)`, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + setDesigns((current) => [...(current || []), newDesign]) + setActiveDesignId(newDesign.id) + toast.success('Design duplicated') + } + + const handleDeleteDesign = () => { + if (safeDesigns.length === 1) { + toast.error('Cannot delete the last design') + return + } + + const filteredDesigns = safeDesigns.filter((d) => d.id !== activeDesignId) + setDesigns(filteredDesigns) + setActiveDesignId(filteredDesigns[0].id) + setSelectedElementId(null) + toast.success('Design deleted') + } + + 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(`Exported as ${exportSize}x${exportSize} PNG`) + }) + } 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('Exported as ICO') + }) + } 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('Exported as SVG') + } + } + + const generateSVG = (): string => { + const size = activeDesign.size + let svg = `` + svg += `` + + activeDesign.elements.forEach((element) => { + const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})` + + switch (element.type) { + case 'circle': + svg += `` + break + case 'square': + svg += `` + break + case 'text': + svg += `${element.text}` + break + } + }) + + svg += '' + return svg + } + + const handleExportAll = () => { + PRESET_SIZES.forEach((size) => { + setTimeout(() => handleExport('png', size), size * 10) + }) + toast.success('Exporting all sizes...') + } + + return ( +
+
+
+
+

Favicon Designer

+

+ Create custom favicons for your web applications +

+
+
+ + + +
+
+
+ +
+
+
+ +
+
+ + + {activeDesign.size}x{activeDesign.size} + +
+ +
+ {PRESET_SIZES.map((size) => ( +
handleExport('png', size)} + title={`Export ${size}x${size}`} + > + { + 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` }} + /> + {size}px +
+ ))} +
+
+
+ +
+ + + +
+
+ + +
+
+ + handleUpdateDesign({ name: e.target.value })} + placeholder="My Favicon" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ handleUpdateDesign({ backgroundColor: e.target.value })} + className="w-20 h-10" + /> + handleUpdateDesign({ backgroundColor: e.target.value })} + placeholder="#7c3aed" + /> +
+
+ + + +
+ +
+ {ELEMENT_TYPES.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ + + +
+ + +
+ {activeDesign.elements.map((element) => ( +
setSelectedElementId(element.id)} + > +
+ {ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && ( + + {(() => { + const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon + return + })()} + + )} + {element.type} + {element.text && "{element.text}"} + {element.emoji && {element.emoji}} +
+ +
+ ))} + {activeDesign.elements.length === 0 && ( +

+ No elements yet. Add some! +

+ )} +
+
+
+ + {selectedElement && ( + <> + +
+ + + {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && ( + <> + {selectedElement.type === 'text' && ( +
+ + handleUpdateElement({ text: e.target.value })} + placeholder="Enter text" + /> +
+ )} + + {selectedElement.type === 'emoji' && ( +
+ + handleUpdateElement({ emoji: e.target.value })} + placeholder="😀" + /> +
+ )} + +
+ + handleUpdateElement({ fontSize: value })} + min={12} + max={200} + step={1} + /> +
+ + {selectedElement.type === 'text' && ( +
+ + +
+ )} + + )} + + {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && ( + <> +
+ + handleUpdateElement({ width: value })} + min={10} + max={activeDesign.size} + step={1} + /> +
+ +
+ + handleUpdateElement({ height: value })} + min={10} + max={activeDesign.size} + step={1} + /> +
+ + )} + +
+ + handleUpdateElement({ x: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+ +
+ + handleUpdateElement({ y: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+ +
+ + handleUpdateElement({ rotation: value })} + min={0} + max={360} + step={1} + /> +
+ +
+ +
+ handleUpdateElement({ color: e.target.value })} + className="w-20 h-10" + /> + handleUpdateElement({ color: e.target.value })} + placeholder="#ffffff" + /> +
+
+
+ + )} +
+
+
+
+
+ ) +} diff --git a/src/types/project.ts b/src/types/project.ts index f2e8a82..44737e2 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -274,6 +274,7 @@ export interface FeatureToggles { errorRepair: boolean documentation: boolean sassStyles: boolean + faviconDesigner: boolean } export interface Project {