mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge pull request #18 from johndoe6345789/copilot/refactor-large-components
Refactor large components into focused modules
This commit is contained in:
@@ -8,10 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
BookOpen,
|
||||
MapPin,
|
||||
FileCode,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Sparkle,
|
||||
Code,
|
||||
Database,
|
||||
Tree,
|
||||
@@ -28,6 +25,17 @@ import {
|
||||
MagnifyingGlass,
|
||||
GitBranch
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
FeatureItem,
|
||||
AIFeatureCard,
|
||||
RoadmapItem,
|
||||
AgentFileItem,
|
||||
IntegrationPoint,
|
||||
CICDPlatformItem,
|
||||
PipelineStageCard,
|
||||
SassComponentItem,
|
||||
AnimationItem
|
||||
} from './DocumentationView/DocComponents'
|
||||
|
||||
export function DocumentationView() {
|
||||
const [activeTab, setActiveTab] = useState('readme')
|
||||
@@ -1894,182 +1902,3 @@ docker pull ghcr.io/<username>/<repo>:latest`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CICDPlatformItem({ name, file, description, features }: {
|
||||
name: string
|
||||
file: string
|
||||
description: string
|
||||
features: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 border-l-2 border-accent pl-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={18} className="text-accent" />
|
||||
<h3 className="text-base font-semibold">{name}</h3>
|
||||
</div>
|
||||
<code className="text-xs text-muted-foreground font-mono">{file}</code>
|
||||
<p className="text-sm text-foreground/90">{description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Key Features:</p>
|
||||
<ul className="space-y-1">
|
||||
{features.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-foreground/80 flex items-start gap-2">
|
||||
<CheckCircle size={14} weight="fill" className="text-accent mt-1 flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStageCard({ stage, description, duration }: {
|
||||
stage: string
|
||||
description: string
|
||||
duration: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<h4 className="font-semibold text-sm">{stage}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
{duration}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SassComponentItem({ name, classes, description }: { name: string; classes: string[]; description: string }) {
|
||||
return (
|
||||
<div className="space-y-2 p-4 border rounded-lg bg-card">
|
||||
<h4 className="font-semibold">{name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<div className="space-y-1">
|
||||
{classes.map((cls, idx) => (
|
||||
<code key={idx} className="text-xs font-mono text-accent block">{cls}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimationItem({ name, description }: { name: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1 p-3 border rounded-lg bg-card">
|
||||
<code className="text-xs font-mono text-accent">{name}</code>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-accent mt-0.5">{icon}</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AIFeatureCard({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex gap-3">
|
||||
<Sparkle size={20} weight="duotone" className="text-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RoadmapItem({ status, title, description, version }: {
|
||||
status: 'completed' | 'planned'
|
||||
title: string
|
||||
description: string
|
||||
version: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={status === 'completed' ? 'bg-green-500/5 border-green-500/20' : 'bg-muted/50'}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
<Badge variant={status === 'completed' ? 'default' : 'secondary'} className="text-xs">
|
||||
{version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentFileItem({ filename, path, description, features }: {
|
||||
filename: string
|
||||
path: string
|
||||
description: string
|
||||
features: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 border-l-2 border-accent pl-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={18} className="text-accent" />
|
||||
<code className="text-sm font-semibold text-accent">{filename}</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">{path}</p>
|
||||
<p className="text-sm text-foreground/90">{description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Key Features:</p>
|
||||
<ul className="space-y-1">
|
||||
{features.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-foreground/80 flex items-start gap-2">
|
||||
<CheckCircle size={14} weight="fill" className="text-accent mt-1 flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationPoint({ component, capabilities }: { component: string; capabilities: string[] }) {
|
||||
return (
|
||||
<div className="space-y-2 border rounded-lg p-4 bg-card">
|
||||
<h4 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Sparkle size={16} weight="duotone" className="text-accent" />
|
||||
{component}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{capabilities.map((capability, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
<span>{capability}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
182
src/components/DocumentationView/DocComponents.tsx
Normal file
182
src/components/DocumentationView/DocComponents.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { FileCode, CheckCircle, Sparkle, GitBranch } from '@phosphor-icons/react'
|
||||
|
||||
export function FeatureItem({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-accent mt-0.5">{icon}</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AIFeatureCard({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex gap-3">
|
||||
<Sparkle size={20} weight="duotone" className="text-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoadmapItem({ status, title, description, version }: {
|
||||
status: 'completed' | 'planned'
|
||||
title: string
|
||||
description: string
|
||||
version: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={status === 'completed' ? 'bg-green-500/5 border-green-500/20' : 'bg-muted/50'}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
<Badge variant={status === 'completed' ? 'default' : 'secondary'} className="text-xs">
|
||||
{version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentFileItem({ filename, path, description, features }: {
|
||||
filename: string
|
||||
path: string
|
||||
description: string
|
||||
features: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 border-l-2 border-accent pl-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={18} className="text-accent" />
|
||||
<code className="text-sm font-semibold text-accent">{filename}</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">{path}</p>
|
||||
<p className="text-sm text-foreground/90">{description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Key Features:</p>
|
||||
<ul className="space-y-1">
|
||||
{features.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-foreground/80 flex items-start gap-2">
|
||||
<CheckCircle size={14} weight="fill" className="text-accent mt-1 flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationPoint({ component, capabilities }: { component: string; capabilities: string[] }) {
|
||||
return (
|
||||
<div className="space-y-2 border rounded-lg p-4 bg-card">
|
||||
<h4 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Sparkle size={16} weight="duotone" className="text-accent" />
|
||||
{component}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{capabilities.map((capability, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
<span>{capability}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CICDPlatformItem({ name, file, description, features }: {
|
||||
name: string
|
||||
file: string
|
||||
description: string
|
||||
features: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 border-l-2 border-accent pl-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={18} className="text-accent" />
|
||||
<h3 className="text-base font-semibold">{name}</h3>
|
||||
</div>
|
||||
<code className="text-xs text-muted-foreground font-mono">{file}</code>
|
||||
<p className="text-sm text-foreground/90">{description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Key Features:</p>
|
||||
<ul className="space-y-1">
|
||||
{features.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-foreground/80 flex items-start gap-2">
|
||||
<CheckCircle size={14} weight="fill" className="text-accent mt-1 flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PipelineStageCard({ stage, description, duration }: {
|
||||
stage: string
|
||||
description: string
|
||||
duration: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<h4 className="font-semibold text-sm">{stage}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
{duration}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function SassComponentItem({ name, classes, description }: { name: string; classes: string[]; description: string }) {
|
||||
return (
|
||||
<div className="space-y-2 p-4 border rounded-lg bg-card">
|
||||
<h4 className="font-semibold">{name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<div className="space-y-1">
|
||||
{classes.map((cls, idx) => (
|
||||
<code key={idx} className="text-xs font-mono text-accent block">{cls}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimationItem({ name, description }: { name: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1 p-3 border rounded-lg bg-card">
|
||||
<code className="text-xs font-mono text-accent">{name}</code>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -14,96 +13,17 @@ import {
|
||||
Plus,
|
||||
Trash,
|
||||
Download,
|
||||
CircleNotch,
|
||||
Square,
|
||||
Triangle,
|
||||
Star,
|
||||
Heart,
|
||||
Polygon,
|
||||
TextT,
|
||||
Image as ImageIcon,
|
||||
ArrowCounterClockwise,
|
||||
Copy,
|
||||
FloppyDisk,
|
||||
PencilSimple,
|
||||
Eraser,
|
||||
Gradient,
|
||||
Sparkle,
|
||||
Drop,
|
||||
MagicWand
|
||||
} from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type BrushEffect = 'solid' | 'gradient' | 'spray' | 'glow'
|
||||
type CanvasFilter = 'none' | 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'sepia' | 'invert' | 'saturate' | 'hue-rotate' | 'pixelate'
|
||||
|
||||
interface FaviconElement {
|
||||
id: string
|
||||
type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' | 'freehand'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
color: string
|
||||
rotation: number
|
||||
text?: string
|
||||
fontSize?: number
|
||||
fontWeight?: string
|
||||
emoji?: string
|
||||
paths?: Array<{ x: number; y: number }>
|
||||
strokeWidth?: number
|
||||
brushEffect?: BrushEffect
|
||||
gradientColor?: string
|
||||
glowIntensity?: number
|
||||
}
|
||||
|
||||
interface FaviconDesign {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
backgroundColor: string
|
||||
elements: FaviconElement[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
filter?: CanvasFilter
|
||||
filterIntensity?: 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(),
|
||||
}
|
||||
import { BrushEffect, CanvasFilter, FaviconElement, FaviconDesign } from './FaviconDesigner/types'
|
||||
import { PRESET_SIZES, ELEMENT_TYPES, DEFAULT_DESIGN } from './FaviconDesigner/constants'
|
||||
import { drawCanvas } from './FaviconDesigner/canvasUtils'
|
||||
|
||||
export function FaviconDesigner() {
|
||||
const [designs, setDesigns] = useKV<FaviconDesign[]>('favicon-designs', [DEFAULT_DESIGN])
|
||||
@@ -125,255 +45,11 @@ export function FaviconDesigner() {
|
||||
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()
|
||||
|
||||
if (element.type === 'freehand' && element.paths && element.paths.length > 0) {
|
||||
const effect = element.brushEffect || 'solid'
|
||||
const strokeWidth = element.strokeWidth || 3
|
||||
|
||||
if (effect === 'glow') {
|
||||
ctx.shadowColor = element.color
|
||||
ctx.shadowBlur = element.glowIntensity || 10
|
||||
}
|
||||
|
||||
if (effect === 'gradient' && element.gradientColor) {
|
||||
const bounds = getPathBounds(element.paths)
|
||||
const gradient = ctx.createLinearGradient(
|
||||
bounds.minX,
|
||||
bounds.minY,
|
||||
bounds.maxX,
|
||||
bounds.maxY
|
||||
)
|
||||
gradient.addColorStop(0, element.color)
|
||||
gradient.addColorStop(1, element.gradientColor)
|
||||
ctx.strokeStyle = gradient
|
||||
} else {
|
||||
ctx.strokeStyle = element.color
|
||||
}
|
||||
|
||||
ctx.lineWidth = strokeWidth
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
if (effect === 'spray') {
|
||||
element.paths.forEach((point, i) => {
|
||||
if (i % 2 === 0) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const offsetX = (Math.random() - 0.5) * strokeWidth * 2
|
||||
const offsetY = (Math.random() - 0.5) * strokeWidth * 2
|
||||
ctx.fillStyle = element.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x + offsetX, point.y + offsetY, strokeWidth / 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(element.paths[0].x, element.paths[0].y)
|
||||
for (let i = 1; i < element.paths.length; i++) {
|
||||
ctx.lineTo(element.paths[i].x, element.paths[i].y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
} else {
|
||||
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()
|
||||
})
|
||||
|
||||
if (activeDesign.filter && activeDesign.filter !== 'none') {
|
||||
applyCanvasFilter(ctx, activeDesign.filter, activeDesign.filterIntensity || 50)
|
||||
if (canvas) {
|
||||
drawCanvas(canvas, activeDesign)
|
||||
}
|
||||
}
|
||||
|
||||
const getPathBounds = (paths: Array<{ x: number; y: number }>) => {
|
||||
const xs = paths.map(p => p.x)
|
||||
const ys = paths.map(p => p.y)
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
maxX: Math.max(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxY: Math.max(...ys),
|
||||
}
|
||||
}
|
||||
|
||||
const applyCanvasFilter = (ctx: CanvasRenderingContext2D, filter: CanvasFilter, intensity: number) => {
|
||||
const canvas = ctx.canvas
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const data = imageData.data
|
||||
|
||||
switch (filter) {
|
||||
case 'blur':
|
||||
ctx.filter = `blur(${intensity / 10}px)`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'brightness':
|
||||
ctx.filter = `brightness(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'contrast':
|
||||
ctx.filter = `contrast(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'grayscale':
|
||||
ctx.filter = `grayscale(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'sepia':
|
||||
ctx.filter = `sepia(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'invert':
|
||||
ctx.filter = `invert(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'saturate':
|
||||
ctx.filter = `saturate(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'hue-rotate':
|
||||
ctx.filter = `hue-rotate(${intensity * 3.6}deg)`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'pixelate': {
|
||||
const pixelSize = Math.max(1, Math.floor(intensity / 10))
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = canvas.width / pixelSize
|
||||
tempCanvas.height = canvas.height / pixelSize
|
||||
const tempCtx = tempCanvas.getContext('2d')
|
||||
if (tempCtx) {
|
||||
tempCtx.imageSmoothingEnabled = false
|
||||
tempCtx.drawImage(canvas, 0, 0, tempCanvas.width, tempCanvas.height)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height)
|
||||
ctx.imageSmoothingEnabled = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}, [activeDesign])
|
||||
|
||||
const handleAddElement = (type: FaviconElement['type']) => {
|
||||
const newElement: FaviconElement = {
|
||||
@@ -720,7 +396,10 @@ export function FaviconDesigner() {
|
||||
}
|
||||
|
||||
setCurrentPath([])
|
||||
drawCanvas()
|
||||
const canvas = canvasRef.current
|
||||
if (canvas) {
|
||||
drawCanvas(canvas, activeDesign)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanvasMouseLeave = () => {
|
||||
|
||||
247
src/components/FaviconDesigner/canvasUtils.ts
Normal file
247
src/components/FaviconDesigner/canvasUtils.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { FaviconElement, FaviconDesign, CanvasFilter } from './types'
|
||||
|
||||
export function getPathBounds(paths: Array<{ x: number; y: number }>) {
|
||||
const xs = paths.map(p => p.x)
|
||||
const ys = paths.map(p => p.y)
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
maxX: Math.max(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxY: Math.max(...ys),
|
||||
}
|
||||
}
|
||||
|
||||
export function 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()
|
||||
}
|
||||
|
||||
export function 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()
|
||||
}
|
||||
|
||||
export function 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()
|
||||
}
|
||||
|
||||
export function applyCanvasFilter(ctx: CanvasRenderingContext2D, filter: CanvasFilter, intensity: number) {
|
||||
const canvas = ctx.canvas
|
||||
|
||||
switch (filter) {
|
||||
case 'blur':
|
||||
ctx.filter = `blur(${intensity / 10}px)`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'brightness':
|
||||
ctx.filter = `brightness(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'contrast':
|
||||
ctx.filter = `contrast(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'grayscale':
|
||||
ctx.filter = `grayscale(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'sepia':
|
||||
ctx.filter = `sepia(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'invert':
|
||||
ctx.filter = `invert(${intensity / 100})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'saturate':
|
||||
ctx.filter = `saturate(${intensity / 50})`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'hue-rotate':
|
||||
ctx.filter = `hue-rotate(${intensity * 3.6}deg)`
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
ctx.filter = 'none'
|
||||
break
|
||||
case 'pixelate': {
|
||||
const pixelSize = Math.max(1, Math.floor(intensity / 10))
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = canvas.width / pixelSize
|
||||
tempCanvas.height = canvas.height / pixelSize
|
||||
const tempCtx = tempCanvas.getContext('2d')
|
||||
if (tempCtx) {
|
||||
tempCtx.imageSmoothingEnabled = false
|
||||
tempCtx.drawImage(canvas, 0, 0, tempCanvas.width, tempCanvas.height)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height)
|
||||
ctx.imageSmoothingEnabled = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawElement(ctx: CanvasRenderingContext2D, element: FaviconElement) {
|
||||
ctx.save()
|
||||
|
||||
if (element.type === 'freehand' && element.paths && element.paths.length > 0) {
|
||||
const effect = element.brushEffect || 'solid'
|
||||
const strokeWidth = element.strokeWidth || 3
|
||||
|
||||
if (effect === 'glow') {
|
||||
ctx.shadowColor = element.color
|
||||
ctx.shadowBlur = element.glowIntensity || 10
|
||||
}
|
||||
|
||||
if (effect === 'gradient' && element.gradientColor) {
|
||||
const bounds = getPathBounds(element.paths)
|
||||
const gradient = ctx.createLinearGradient(
|
||||
bounds.minX,
|
||||
bounds.minY,
|
||||
bounds.maxX,
|
||||
bounds.maxY
|
||||
)
|
||||
gradient.addColorStop(0, element.color)
|
||||
gradient.addColorStop(1, element.gradientColor)
|
||||
ctx.strokeStyle = gradient
|
||||
} else {
|
||||
ctx.strokeStyle = element.color
|
||||
}
|
||||
|
||||
ctx.lineWidth = strokeWidth
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
if (effect === 'spray') {
|
||||
element.paths.forEach((point, i) => {
|
||||
if (i % 2 === 0) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const offsetX = (Math.random() - 0.5) * strokeWidth * 2
|
||||
const offsetY = (Math.random() - 0.5) * strokeWidth * 2
|
||||
ctx.fillStyle = element.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x + offsetX, point.y + offsetY, strokeWidth / 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(element.paths[0].x, element.paths[0].y)
|
||||
for (let i = 1; i < element.paths.length; i++) {
|
||||
ctx.lineTo(element.paths[i].x, element.paths[i].y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
|
||||
export function drawCanvas(canvas: HTMLCanvasElement, design: FaviconDesign) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const size = design.size
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
ctx.fillStyle = design.backgroundColor
|
||||
ctx.fillRect(0, 0, size, size)
|
||||
|
||||
design.elements.forEach((element) => {
|
||||
drawElement(ctx, element)
|
||||
})
|
||||
|
||||
if (design.filter && design.filter !== 'none') {
|
||||
applyCanvasFilter(ctx, design.filter, design.filterIntensity || 50)
|
||||
}
|
||||
}
|
||||
48
src/components/FaviconDesigner/constants.ts
Normal file
48
src/components/FaviconDesigner/constants.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CircleNotch,
|
||||
Square,
|
||||
Triangle,
|
||||
Star,
|
||||
Heart,
|
||||
Polygon,
|
||||
TextT,
|
||||
Image as ImageIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
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 },
|
||||
]
|
||||
|
||||
export 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(),
|
||||
}
|
||||
34
src/components/FaviconDesigner/types.ts
Normal file
34
src/components/FaviconDesigner/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type BrushEffect = 'solid' | 'gradient' | 'spray' | 'glow'
|
||||
export type CanvasFilter = 'none' | 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'sepia' | 'invert' | 'saturate' | 'hue-rotate' | 'pixelate'
|
||||
|
||||
export interface FaviconElement {
|
||||
id: string
|
||||
type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' | 'freehand'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
color: string
|
||||
rotation: number
|
||||
text?: string
|
||||
fontSize?: number
|
||||
fontWeight?: string
|
||||
emoji?: string
|
||||
paths?: Array<{ x: number; y: number }>
|
||||
strokeWidth?: number
|
||||
brushEffect?: BrushEffect
|
||||
gradientColor?: string
|
||||
glowIntensity?: number
|
||||
}
|
||||
|
||||
export interface FaviconDesign {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
backgroundColor: string
|
||||
elements: FaviconElement[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
filter?: CanvasFilter
|
||||
filterIntensity?: number
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
/// <reference path="../global.d.ts" />
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, ReactElement } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
@@ -14,307 +12,20 @@ import ReactFlow, {
|
||||
MarkerType,
|
||||
ConnectionMode,
|
||||
Panel,
|
||||
NodeProps,
|
||||
Handle,
|
||||
Position,
|
||||
reconnectEdge,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Plus, Trash, Sparkle, DotsThree, Package } from '@phosphor-icons/react'
|
||||
import { Plus, Trash, Sparkle, Package } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface FeatureIdea {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
priority: 'low' | 'medium' | 'high'
|
||||
status: 'idea' | 'planned' | 'in-progress' | 'completed'
|
||||
createdAt: number
|
||||
parentGroup?: string
|
||||
}
|
||||
|
||||
interface IdeaGroup {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface IdeaEdgeData {
|
||||
label?: string
|
||||
}
|
||||
|
||||
const SEED_IDEAS: FeatureIdea[] = [
|
||||
{
|
||||
id: 'idea-1',
|
||||
title: 'AI Code Assistant',
|
||||
description: 'Integrate an AI assistant that can suggest code improvements and answer questions',
|
||||
category: 'AI/ML',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 10000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-2',
|
||||
title: 'Real-time Collaboration',
|
||||
description: 'Allow multiple developers to work on the same project simultaneously',
|
||||
category: 'Collaboration',
|
||||
priority: 'high',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 9000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-3',
|
||||
title: 'Component Marketplace',
|
||||
description: 'A marketplace where users can share and download pre-built components',
|
||||
category: 'Community',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 8000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-4',
|
||||
title: 'Visual Git Integration',
|
||||
description: 'Git operations through a visual interface with branch visualization',
|
||||
category: 'DevOps',
|
||||
priority: 'high',
|
||||
status: 'planned',
|
||||
createdAt: Date.now() - 7000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-5',
|
||||
title: 'API Mock Server',
|
||||
description: 'Built-in mock server for testing API integrations',
|
||||
category: 'Testing',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 6000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-6',
|
||||
title: 'Performance Profiler',
|
||||
description: 'Analyze and optimize application performance with visual metrics',
|
||||
category: 'Performance',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 5000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-7',
|
||||
title: 'Theme Presets',
|
||||
description: 'Pre-designed theme templates for quick project setup',
|
||||
category: 'Design',
|
||||
priority: 'low',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 4000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-8',
|
||||
title: 'Database Schema Migrations',
|
||||
description: 'Visual tool for creating and managing database migrations',
|
||||
category: 'Database',
|
||||
priority: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: Date.now() - 3000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-9',
|
||||
title: 'Mobile App Preview',
|
||||
description: 'Live preview on actual mobile devices or simulators',
|
||||
category: 'Mobile',
|
||||
priority: 'medium',
|
||||
status: 'planned',
|
||||
createdAt: Date.now() - 2000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-10',
|
||||
title: 'Accessibility Checker',
|
||||
description: 'Automated accessibility testing and suggestions',
|
||||
category: 'Accessibility',
|
||||
priority: 'high',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 1000000,
|
||||
},
|
||||
]
|
||||
|
||||
const CATEGORIES = ['AI/ML', 'Collaboration', 'Community', 'DevOps', 'Testing', 'Performance', 'Design', 'Database', 'Mobile', 'Accessibility', 'Productivity', 'Security', 'Analytics', 'Other']
|
||||
const PRIORITIES = ['low', 'medium', 'high'] as const
|
||||
const STATUSES = ['idea', 'planned', 'in-progress', 'completed'] as const
|
||||
|
||||
const CONNECTION_STYLE = {
|
||||
stroke: '#a78bfa',
|
||||
strokeWidth: 2.5
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
idea: 'bg-muted text-muted-foreground',
|
||||
planned: 'bg-accent text-accent-foreground',
|
||||
'in-progress': 'bg-primary text-primary-foreground',
|
||||
completed: 'bg-green-600 text-white',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
low: 'border-blue-400/60 bg-blue-50/80 dark:bg-blue-950/40',
|
||||
medium: 'border-amber-400/60 bg-amber-50/80 dark:bg-amber-950/40',
|
||||
high: 'border-red-400/60 bg-red-50/80 dark:bg-red-950/40',
|
||||
}
|
||||
|
||||
const GROUP_COLORS = [
|
||||
{ name: 'Blue', value: '#3b82f6', bg: 'rgba(59, 130, 246, 0.08)', border: 'rgba(59, 130, 246, 0.3)' },
|
||||
{ name: 'Purple', value: '#a855f7', bg: 'rgba(168, 85, 247, 0.08)', border: 'rgba(168, 85, 247, 0.3)' },
|
||||
{ name: 'Green', value: '#10b981', bg: 'rgba(16, 185, 129, 0.08)', border: 'rgba(16, 185, 129, 0.3)' },
|
||||
{ name: 'Red', value: '#ef4444', bg: 'rgba(239, 68, 68, 0.08)', border: 'rgba(239, 68, 68, 0.3)' },
|
||||
{ name: 'Orange', value: '#f97316', bg: 'rgba(249, 115, 22, 0.08)', border: 'rgba(249, 115, 22, 0.3)' },
|
||||
{ name: 'Pink', value: '#ec4899', bg: 'rgba(236, 72, 153, 0.08)', border: 'rgba(236, 72, 153, 0.3)' },
|
||||
{ name: 'Cyan', value: '#06b6d4', bg: 'rgba(6, 182, 212, 0.08)', border: 'rgba(6, 182, 212, 0.3)' },
|
||||
{ name: 'Amber', value: '#f59e0b', bg: 'rgba(245, 158, 11, 0.08)', border: 'rgba(245, 158, 11, 0.3)' },
|
||||
]
|
||||
|
||||
function GroupNode({ data, selected }: NodeProps<IdeaGroup>) {
|
||||
const colorScheme = GROUP_COLORS.find(c => c.value === data.color) || GROUP_COLORS[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl backdrop-blur-sm transition-all"
|
||||
style={{
|
||||
width: 450,
|
||||
height: 350,
|
||||
backgroundColor: colorScheme.bg,
|
||||
border: `3px dashed ${colorScheme.border}`,
|
||||
boxShadow: selected ? `0 0 0 2px ${colorScheme.value}` : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-3 left-4 px-3 py-1 rounded-full text-xs font-semibold shadow-md"
|
||||
style={{
|
||||
backgroundColor: colorScheme.value,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute -top-2 -right-2 h-7 w-7 rounded-full shadow-md bg-background hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const event = new CustomEvent('editGroup', { detail: data })
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdeaNode({ data, selected, id }: NodeProps<FeatureIdea> & { id: string }) {
|
||||
const [connectionCounts, setConnectionCounts] = useState<Record<string, number>>({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const updateConnectionCounts = (event: CustomEvent) => {
|
||||
const { nodeId, counts } = event.detail
|
||||
if (nodeId === id) {
|
||||
setConnectionCounts(counts)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('updateConnectionCounts' as any, updateConnectionCounts as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('updateConnectionCounts' as any, updateConnectionCounts as EventListener)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const generateHandles = (position: Position, type: 'source' | 'target', side: string) => {
|
||||
const count = connectionCounts[side] || 0
|
||||
const totalHandles = Math.max(2, count + 1)
|
||||
const handles: ReactElement[] = []
|
||||
|
||||
for (let i = 0; i < totalHandles; i++) {
|
||||
const handleId = `${side}-${i}`
|
||||
const isVertical = position === Position.Top || position === Position.Bottom
|
||||
const positionStyle = isVertical
|
||||
? { left: `${((i + 1) / (totalHandles + 1)) * 100}%` }
|
||||
: { top: `${((i + 1) / (totalHandles + 1)) * 100}%` }
|
||||
|
||||
handles.push(
|
||||
<Handle
|
||||
key={handleId}
|
||||
type={type}
|
||||
position={position}
|
||||
id={handleId}
|
||||
className="w-3 h-3 !bg-primary border-2 border-background transition-all hover:scale-125"
|
||||
style={{
|
||||
...positionStyle,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return handles
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{generateHandles(Position.Left, 'target', 'left')}
|
||||
{generateHandles(Position.Right, 'source', 'right')}
|
||||
{generateHandles(Position.Top, 'target', 'top')}
|
||||
{generateHandles(Position.Bottom, 'source', 'bottom')}
|
||||
|
||||
<Card className={`p-4 shadow-xl hover:shadow-2xl transition-all border-2 ${PRIORITY_COLORS[data.priority]} w-[240px] ${selected ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-sm line-clamp-2 flex-1">{data.title}</h3>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const event = new CustomEvent('editIdea', { detail: data })
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{data.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{data.category}
|
||||
</Badge>
|
||||
<Badge className={`text-xs ${STATUS_COLORS[data.status]}`}>
|
||||
{data.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
ideaNode: IdeaNode,
|
||||
groupNode: GroupNode,
|
||||
}
|
||||
import { FeatureIdea, IdeaGroup, IdeaEdgeData } from './FeatureIdeaCloud/types'
|
||||
import { SEED_IDEAS, CATEGORIES, PRIORITIES, STATUSES, CONNECTION_STYLE, GROUP_COLORS } from './FeatureIdeaCloud/constants'
|
||||
import { nodeTypes } from './FeatureIdeaCloud/nodes'
|
||||
|
||||
export function FeatureIdeaCloud() {
|
||||
const [ideas, setIdeas] = useKV<FeatureIdea[]>('feature-ideas', SEED_IDEAS)
|
||||
|
||||
@@ -1,3 +1,103 @@
|
||||
import { FeatureIdea } from './types'
|
||||
|
||||
export const SEED_IDEAS: FeatureIdea[] = [
|
||||
{
|
||||
id: 'idea-1',
|
||||
title: 'AI Code Assistant',
|
||||
description: 'Integrate an AI assistant that can suggest code improvements and answer questions',
|
||||
category: 'AI/ML',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 10000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-2',
|
||||
title: 'Real-time Collaboration',
|
||||
description: 'Allow multiple developers to work on the same project simultaneously',
|
||||
category: 'Collaboration',
|
||||
priority: 'high',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 9000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-3',
|
||||
title: 'Component Marketplace',
|
||||
description: 'A marketplace where users can share and download pre-built components',
|
||||
category: 'Community',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 8000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-4',
|
||||
title: 'Visual Git Integration',
|
||||
description: 'Git operations through a visual interface with branch visualization',
|
||||
category: 'DevOps',
|
||||
priority: 'high',
|
||||
status: 'planned',
|
||||
createdAt: Date.now() - 7000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-5',
|
||||
title: 'API Mock Server',
|
||||
description: 'Built-in mock server for testing API integrations',
|
||||
category: 'Testing',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 6000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-6',
|
||||
title: 'Performance Profiler',
|
||||
description: 'Analyze and optimize application performance with visual metrics',
|
||||
category: 'Performance',
|
||||
priority: 'medium',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 5000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-7',
|
||||
title: 'Theme Presets',
|
||||
description: 'Pre-designed theme templates for quick project setup',
|
||||
category: 'Design',
|
||||
priority: 'low',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 4000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-8',
|
||||
title: 'Database Schema Migrations',
|
||||
description: 'Visual tool for creating and managing database migrations',
|
||||
category: 'Database',
|
||||
priority: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: Date.now() - 3000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-9',
|
||||
title: 'Mobile App Preview',
|
||||
description: 'Live preview on actual mobile devices or simulators',
|
||||
category: 'Mobile',
|
||||
priority: 'medium',
|
||||
status: 'planned',
|
||||
createdAt: Date.now() - 2000000,
|
||||
},
|
||||
{
|
||||
id: 'idea-10',
|
||||
title: 'Accessibility Checker',
|
||||
description: 'Automated accessibility testing and suggestions',
|
||||
category: 'Accessibility',
|
||||
priority: 'high',
|
||||
status: 'idea',
|
||||
createdAt: Date.now() - 1000000,
|
||||
},
|
||||
]
|
||||
|
||||
export const CONNECTION_STYLE = {
|
||||
stroke: '#a78bfa',
|
||||
strokeWidth: 2.5
|
||||
}
|
||||
|
||||
export const CATEGORIES = [
|
||||
'AI/ML',
|
||||
'Collaboration',
|
||||
|
||||
145
src/components/FeatureIdeaCloud/nodes.tsx
Normal file
145
src/components/FeatureIdeaCloud/nodes.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, ReactElement } from 'react'
|
||||
import { NodeProps, Handle, Position } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DotsThree } from '@phosphor-icons/react'
|
||||
import { FeatureIdea, IdeaGroup } from './types'
|
||||
import { PRIORITY_COLORS, STATUS_COLORS, GROUP_COLORS } from './constants'
|
||||
|
||||
export function GroupNode({ data, selected }: NodeProps<IdeaGroup>) {
|
||||
const colorScheme = GROUP_COLORS.find(c => c.value === data.color) || GROUP_COLORS[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl backdrop-blur-sm transition-all"
|
||||
style={{
|
||||
width: 450,
|
||||
height: 350,
|
||||
backgroundColor: colorScheme.bg,
|
||||
border: `3px dashed ${colorScheme.border}`,
|
||||
boxShadow: selected ? `0 0 0 2px ${colorScheme.value}` : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-3 left-4 px-3 py-1 rounded-full text-xs font-semibold shadow-md"
|
||||
style={{
|
||||
backgroundColor: colorScheme.value,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute -top-2 -right-2 h-7 w-7 rounded-full shadow-md bg-background hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const event = new CustomEvent('editGroup', { detail: data })
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function IdeaNode({ data, selected, id }: NodeProps<FeatureIdea> & { id: string }) {
|
||||
const [connectionCounts, setConnectionCounts] = useState<Record<string, number>>({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const updateConnectionCounts = (event: CustomEvent) => {
|
||||
const { nodeId, counts } = event.detail
|
||||
if (nodeId === id) {
|
||||
setConnectionCounts(counts)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('updateConnectionCounts' as any, updateConnectionCounts as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('updateConnectionCounts' as any, updateConnectionCounts as EventListener)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const generateHandles = (position: Position, type: 'source' | 'target', side: string) => {
|
||||
const count = connectionCounts[side] || 0
|
||||
const totalHandles = Math.max(2, count + 1)
|
||||
const handles: ReactElement[] = []
|
||||
|
||||
for (let i = 0; i < totalHandles; i++) {
|
||||
const handleId = `${side}-${i}`
|
||||
const isVertical = position === Position.Top || position === Position.Bottom
|
||||
const positionStyle = isVertical
|
||||
? { left: `${((i + 1) / (totalHandles + 1)) * 100}%` }
|
||||
: { top: `${((i + 1) / (totalHandles + 1)) * 100}%` }
|
||||
|
||||
handles.push(
|
||||
<Handle
|
||||
key={handleId}
|
||||
type={type}
|
||||
position={position}
|
||||
id={handleId}
|
||||
className="w-3 h-3 !bg-primary border-2 border-background transition-all hover:scale-125"
|
||||
style={{
|
||||
...positionStyle,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return handles
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{generateHandles(Position.Left, 'target', 'left')}
|
||||
{generateHandles(Position.Right, 'source', 'right')}
|
||||
{generateHandles(Position.Top, 'target', 'top')}
|
||||
{generateHandles(Position.Bottom, 'source', 'bottom')}
|
||||
|
||||
<Card className={`p-4 shadow-xl hover:shadow-2xl transition-all border-2 ${PRIORITY_COLORS[data.priority]} w-[240px] ${selected ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-sm line-clamp-2 flex-1">{data.title}</h3>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const event = new CustomEvent('editIdea', { detail: data })
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{data.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{data.category}
|
||||
</Badge>
|
||||
<Badge className={`text-xs ${STATUS_COLORS[data.status]}`}>
|
||||
{data.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const nodeTypes = {
|
||||
ideaNode: IdeaNode,
|
||||
groupNode: GroupNode,
|
||||
}
|
||||
21
src/components/FeatureIdeaCloud/types.ts
Normal file
21
src/components/FeatureIdeaCloud/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface FeatureIdea {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
priority: 'low' | 'medium' | 'high'
|
||||
status: 'idea' | 'planned' | 'in-progress' | 'completed'
|
||||
createdAt: number
|
||||
parentGroup?: string
|
||||
}
|
||||
|
||||
export interface IdeaGroup {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface IdeaEdgeData {
|
||||
label?: string
|
||||
}
|
||||
Reference in New Issue
Block a user