Merge branch 'main' into codex/split-featureideacloud-into-subcomponents

This commit is contained in:
2026-01-18 00:22:51 +00:00
committed by GitHub
135 changed files with 6703 additions and 5352 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import { FileCode, CheckCircle, Sparkle } from '@phosphor-icons/react'
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) => (
<li key={feature} 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) => (
<li key={capability} className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-accent"></span>
<span>{capability}</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import agentsData from '@/data/documentation/agents-data.json'
import { AgentFileItem } from './AgentItems'
export function AgentsCoreServices() {
return (
<Card>
<CardHeader>
<CardTitle>Core AI Services</CardTitle>
<CardDescription>Primary modules handling AI operations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{agentsData.coreServices.map((service) => (
<AgentFileItem
key={service.filename}
filename={service.filename}
path={service.path}
description={service.description}
features={service.features}
/>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,28 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Target, Package } from '@phosphor-icons/react'
import agentsData from '@/data/documentation/agents-data.json'
export function AgentsFutureEnhancements() {
return (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package size={20} weight="duotone" />
Future AI Enhancements
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{agentsData.futureEnhancements.map((item) => (
<li key={item.title} className="flex items-start gap-2">
<Target size={16} className="text-accent mt-1 flex-shrink-0" />
<span>
<strong>{item.title}:</strong> {item.description}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import agentsData from '@/data/documentation/agents-data.json'
import { IntegrationPoint } from './AgentItems'
export function AgentsIntegrationPoints() {
return (
<Card>
<CardHeader>
<CardTitle>AI Integration Points</CardTitle>
<CardDescription>Features enhanced by AI capabilities</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
{agentsData.integrationPoints.map((point) => (
<IntegrationPoint key={point.component} component={point.component} capabilities={point.capabilities} />
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,22 @@
import { Separator } from '@/components/ui/separator'
import { FileCode } from '@phosphor-icons/react'
import agentsData from '@/data/documentation/agents-data.json'
export function AgentsOverviewSection() {
return (
<div className="space-y-4">
<h1 className="text-4xl font-bold flex items-center gap-3">
<FileCode size={36} weight="duotone" className="text-accent" />
{agentsData.title}
</h1>
<p className="text-lg text-muted-foreground">{agentsData.subtitle}</p>
<Separator />
<div className="space-y-4">
<h2 className="text-2xl font-semibold">AI Service Architecture</h2>
<p className="text-foreground/90 leading-relaxed">{agentsData.overview}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import agentsData from '@/data/documentation/agents-data.json'
export function AgentsPromptEngineering() {
return (
<Card>
<CardHeader>
<CardTitle>Prompt Engineering</CardTitle>
<CardDescription>How we optimize AI interactions</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{agentsData.promptEngineering.map((item) => (
<div key={item.title} className="space-y-2">
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,19 @@
import { AgentsCoreServices } from './AgentsCoreServices'
import { AgentsFutureEnhancements } from './AgentsFutureEnhancements'
import { AgentsIntegrationPoints } from './AgentsIntegrationPoints'
import { AgentsOverviewSection } from './AgentsOverviewSection'
import { AgentsPromptEngineering } from './AgentsPromptEngineering'
export function AgentsTab() {
return (
<div className="space-y-6">
<AgentsOverviewSection />
<div className="space-y-4">
<AgentsCoreServices />
<AgentsIntegrationPoints />
<AgentsPromptEngineering />
<AgentsFutureEnhancements />
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle, Rocket } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdBestPracticesCard() {
return (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket size={20} weight="duotone" />
Best Practices
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{cicdData.bestPractices.map((practice) => (
<li key={practice} className="flex items-start gap-2">
<CheckCircle size={16} className="text-accent mt-1 flex-shrink-0" weight="fill" />
<span>{practice}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,48 @@
import { Card, CardContent } from '@/components/ui/card'
import { GitBranch } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
const toneStyles = {
green: {
card: 'bg-green-500/5 border-green-500/20',
icon: 'text-green-500'
},
blue: {
card: 'bg-blue-500/5 border-blue-500/20',
icon: 'text-blue-500'
},
purple: {
card: 'bg-purple-500/5 border-purple-500/20',
icon: 'text-purple-500'
},
orange: {
card: 'bg-orange-500/5 border-orange-500/20',
icon: 'text-orange-500'
}
} as const
export function CicdBranchStrategySection() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Branch Strategy</h2>
<div className="grid gap-4">
{cicdData.branches.map((branch) => {
const styles = toneStyles[branch.tone]
return (
<Card key={branch.name} className={styles.card}>
<CardContent className="pt-4 pb-4">
<div className="flex items-start gap-3">
<GitBranch size={20} weight="duotone" className={`${styles.icon} mt-0.5`} />
<div className="space-y-1">
<h4 className="font-semibold">{branch.name}</h4>
<p className="text-sm text-muted-foreground">{branch.description}</p>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { CheckCircle } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdDockerCard() {
return (
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
<CardDescription>Containerization for production deployment</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="font-semibold">Files Included</h3>
<div className="space-y-2 ml-4">
{cicdData.docker.files.map((file) => (
<div key={file.name} className="space-y-1">
<code className="text-sm font-mono text-accent">{file.name}</code>
<p className="text-sm text-muted-foreground">{file.description}</p>
</div>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold">Docker Commands</h3>
<pre className="custom-mui-code-block">{cicdData.docker.commands}</pre>
</div>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold">Features</h3>
<ul className="space-y-2 text-sm">
{cicdData.docker.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<CheckCircle size={16} className="text-accent mt-1 flex-shrink-0" weight="fill" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,39 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdEnvVarsCard() {
return (
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Required configuration for CI/CD platforms</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-semibold">Variable</th>
<th className="text-left py-2 pr-4 font-semibold">Description</th>
<th className="text-left py-2 font-semibold">Required</th>
</tr>
</thead>
<tbody className="text-muted-foreground">
{cicdData.environmentVariables.map((variable) => (
<tr key={variable.variable} className="border-b">
<td className="py-2 pr-4">
<code className="text-accent">{variable.variable}</code>
</td>
<td className="py-2 pr-4">{variable.description}</td>
<td className="py-2">{variable.required}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,56 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { CheckCircle, GitBranch } from '@phosphor-icons/react'
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) => (
<li key={feature} 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>
)
}

View File

@@ -0,0 +1,26 @@
import { Separator } from '@/components/ui/separator'
import { GitBranch } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdOverviewSection() {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<GitBranch size={32} weight="duotone" className="text-white" />
</div>
<div>
<h1 className="text-4xl font-bold">{cicdData.title}</h1>
<p className="text-lg text-muted-foreground">{cicdData.subtitle}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Overview</h2>
<p className="text-foreground/90 leading-relaxed">{cicdData.overview}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import cicdData from '@/data/documentation/cicd-data.json'
import { PipelineStageCard } from './CicdItems'
export function CicdPipelineSection() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Pipeline Stages</h2>
<p className="text-foreground/90 leading-relaxed">{cicdData.pipeline.intro}</p>
<div className="grid gap-3">
{cicdData.pipeline.stages.map((stage) => (
<PipelineStageCard
key={stage.stage}
stage={stage.stage}
description={stage.description}
duration={stage.duration}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import cicdData from '@/data/documentation/cicd-data.json'
import { CICDPlatformItem } from './CicdItems'
export function CicdPlatformsCard() {
return (
<Card>
<CardHeader>
<CardTitle>Available Configurations</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{cicdData.platforms.map((platform) => (
<CICDPlatformItem
key={platform.name}
name={platform.name}
file={platform.file}
description={platform.description}
features={platform.features}
/>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Lightbulb } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdQuickStartCard() {
return (
<Card className="bg-accent/10 border-accent/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb size={20} weight="duotone" className="text-accent" />
Quick Start
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{cicdData.quickStart.map((step) => (
<div key={step.step} className="space-y-2">
<h3 className="font-semibold flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm">
{step.step}
</span>
{step.title}
</h3>
<p className="text-sm text-foreground/80 ml-8">{step.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,28 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { FileCode, Package } from '@phosphor-icons/react'
import cicdData from '@/data/documentation/cicd-data.json'
export function CicdResourcesCard() {
return (
<Card className="border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package size={20} weight="duotone" />
Additional Resources
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{cicdData.resources.map((resource) => (
<li key={resource.label} className="flex items-start gap-2">
<FileCode size={16} className="text-accent mt-1 flex-shrink-0" />
<span>
<code className="text-accent">{resource.label}</code> - {resource.description}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,25 @@
import { CicdBestPracticesCard } from './CicdBestPracticesCard'
import { CicdBranchStrategySection } from './CicdBranchStrategySection'
import { CicdDockerCard } from './CicdDockerCard'
import { CicdEnvVarsCard } from './CicdEnvVarsCard'
import { CicdOverviewSection } from './CicdOverviewSection'
import { CicdPipelineSection } from './CicdPipelineSection'
import { CicdPlatformsCard } from './CicdPlatformsCard'
import { CicdQuickStartCard } from './CicdQuickStartCard'
import { CicdResourcesCard } from './CicdResourcesCard'
export function CicdTab() {
return (
<div className="space-y-6">
<CicdOverviewSection />
<CicdPlatformsCard />
<CicdPipelineSection />
<CicdDockerCard />
<CicdEnvVarsCard />
<CicdBranchStrategySection />
<CicdQuickStartCard />
<CicdBestPracticesCard />
<CicdResourcesCard />
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent } from '@/components/ui/card'
import { Sparkle } 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>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle } from '@phosphor-icons/react'
import pwaData from '@/data/documentation/pwa-data.json'
export function PwaFeaturesCard() {
return (
<Card>
<CardHeader>
<CardTitle>PWA Features</CardTitle>
<CardDescription>Native app capabilities in your browser</CardDescription>
</CardHeader>
<CardContent className="grid md:grid-cols-2 gap-4">
{pwaData.features.map((feature) => (
<div key={feature.title} className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle size={16} weight="fill" className="text-accent" />
<span className="font-semibold text-sm">{feature.title}</span>
</div>
<p className="text-xs text-muted-foreground ml-6">{feature.description}</p>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import pwaData from '@/data/documentation/pwa-data.json'
function InstallationCard({ title, items }: { title: string; items: { title: string; steps: string[] }[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{items.map((item) => (
<div key={item.title}>
<div className="font-semibold mb-1">{item.title}</div>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground ml-2">
{item.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
))}
</CardContent>
</Card>
)
}
export function PwaInstallationSection() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Installation</h2>
<div className="grid md:grid-cols-2 gap-4">
<InstallationCard title="Desktop Installation" items={pwaData.installation.desktop} />
<InstallationCard title="Mobile Installation" items={pwaData.installation.mobile} />
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle, Wrench } from '@phosphor-icons/react'
import pwaData from '@/data/documentation/pwa-data.json'
function OfflineList({ items, accent }: { items: string[]; accent: boolean }) {
return (
<ul className={`space-y-2 text-sm ${accent ? 'text-foreground/80' : 'text-muted-foreground'}`}>
{items.map((item) => (
<li key={item} className="flex items-start gap-2">
<span className={accent ? 'text-accent mt-0.5' : 'mt-0.5'}></span>
<span>{item}</span>
</li>
))}
</ul>
)
}
export function PwaOfflineSection() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Offline Capabilities</h2>
<div className="grid md:grid-cols-2 gap-4">
<Card className="border-accent/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle size={20} weight="fill" className="text-accent" />
Works Offline
</CardTitle>
</CardHeader>
<CardContent>
<OfflineList items={pwaData.offline.worksOffline} accent />
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench size={20} weight="duotone" className="text-muted-foreground" />
Requires Internet
</CardTitle>
</CardHeader>
<CardContent>
<OfflineList items={pwaData.offline.requiresInternet} accent={false} />
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Separator } from '@/components/ui/separator'
import { Rocket } from '@phosphor-icons/react'
import pwaData from '@/data/documentation/pwa-data.json'
export function PwaOverviewSection() {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<Rocket size={32} weight="duotone" className="text-white" />
</div>
<div>
<h1 className="text-4xl font-bold">{pwaData.title}</h1>
<p className="text-lg text-muted-foreground">{pwaData.subtitle}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Overview</h2>
<p className="text-foreground/90 leading-relaxed">{pwaData.overview}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Lightbulb } from '@phosphor-icons/react'
import pwaData from '@/data/documentation/pwa-data.json'
export function PwaProTipsCard() {
return (
<Card className="bg-accent/10 border-accent/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb size={20} weight="duotone" className="text-accent" />
Pro Tips
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{pwaData.proTips.map((tip) => (
<li key={tip.title} className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>
<strong>{tip.title}:</strong> {tip.description}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import pwaData from '@/data/documentation/pwa-data.json'
export function PwaSettingsCard() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">PWA Settings</h2>
<p className="text-foreground/90 leading-relaxed">
Navigate to the <strong>PWA</strong> tab to manage all Progressive Web App features:
</p>
<Card>
<CardHeader>
<CardTitle>Available Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{pwaData.settings.map((setting, index) => (
<div key={setting.title} className="space-y-2">
<div className="font-semibold">{setting.title}</div>
<p className="text-sm text-muted-foreground">{setting.description}</p>
{index < pwaData.settings.length - 1 && <Separator />}
</div>
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { PwaFeaturesCard } from './PwaFeaturesCard'
import { PwaInstallationSection } from './PwaInstallationSection'
import { PwaOfflineSection } from './PwaOfflineSection'
import { PwaOverviewSection } from './PwaOverviewSection'
import { PwaProTipsCard } from './PwaProTipsCard'
import { PwaSettingsCard } from './PwaSettingsCard'
export function PwaTab() {
return (
<div className="space-y-6">
<PwaOverviewSection />
<PwaFeaturesCard />
<PwaInstallationSection />
<PwaSettingsCard />
<PwaOfflineSection />
<PwaProTipsCard />
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { Separator } from '@/components/ui/separator'
import { CheckCircle, Clock, MapPin } from '@phosphor-icons/react'
import roadmapData from '@/data/documentation/roadmap-data.json'
import { RoadmapItem } from './RoadmapItem'
const sections = [
{
key: 'completed',
title: 'Completed Features',
icon: <CheckCircle size={24} weight="fill" className="text-green-500" />,
items: roadmapData.completed
},
{
key: 'planned',
title: 'Planned Features',
icon: <Clock size={24} weight="duotone" className="text-accent" />,
items: roadmapData.planned
}
]
export function RoadmapTab() {
return (
<div className="space-y-6">
<div className="space-y-4">
<h1 className="text-4xl font-bold flex items-center gap-3">
<MapPin size={36} weight="duotone" className="text-accent" />
{roadmapData.title}
</h1>
<p className="text-lg text-muted-foreground">{roadmapData.subtitle}</p>
<Separator />
<div className="space-y-6">
{sections.map((section) => (
<div key={section.key}>
<div className="flex items-center gap-3 mb-4">
{section.icon}
<h2 className="text-2xl font-semibold">{section.title}</h2>
</div>
<div className="space-y-3 ml-9">
{section.items.map((item) => (
<RoadmapItem
key={`${section.key}-${item.title}`}
status={section.key === 'completed' ? 'completed' : 'planned'}
title={item.title}
description={item.description}
version={item.version}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import sassData from '@/data/documentation/sass-data.json'
import { AnimationItem } from './SassItems'
export function SassAnimationsCard() {
return (
<Card>
<CardHeader>
<CardTitle>Animation Classes</CardTitle>
<CardDescription>Pre-built animation utilities</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{sassData.animations.map((animation) => (
<AnimationItem key={animation.name} name={animation.name} description={animation.description} />
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle, Target } from '@phosphor-icons/react'
import sassData from '@/data/documentation/sass-data.json'
export function SassBestPracticesCard() {
return (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target size={20} weight="duotone" />
Best Practices
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{sassData.bestPractices.map((practice) => (
<li key={practice} className="flex items-start gap-2">
<CheckCircle size={16} className="text-accent mt-1 flex-shrink-0" weight="fill" />
<span>{practice}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import sassData from '@/data/documentation/sass-data.json'
import { SassComponentItem } from './SassItems'
export function SassComponentsCard() {
return (
<Card>
<CardHeader>
<CardTitle>Available Components</CardTitle>
<CardDescription>Custom Material UI components built with Sass</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
{sassData.components.map((component) => (
<SassComponentItem
key={component.name}
name={component.name}
classes={component.classes}
description={component.description}
/>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,20 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import sassData from '@/data/documentation/sass-data.json'
export function SassFileStructureCard() {
return (
<Card>
<CardHeader>
<CardTitle>File Structure</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{sassData.fileStructure.map((item) => (
<div key={item.file} className="space-y-2">
<code className="text-sm font-mono text-accent">{item.file}</code>
<p className="text-sm text-muted-foreground ml-4">{item.description}</p>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,22 @@
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) => (
<code key={cls} 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>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Code } from '@phosphor-icons/react'
import sassData from '@/data/documentation/sass-data.json'
import { FeatureItem } from './FeatureItems'
export function SassLayoutCard() {
return (
<Card>
<CardHeader>
<CardTitle>Layout Components</CardTitle>
<CardDescription>Sass-powered layout utilities</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{sassData.layoutComponents.map((item) => (
<FeatureItem
key={item.title}
icon={<Code size={18} />}
title={item.title}
description={item.description}
/>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { Separator } from '@/components/ui/separator'
import { PaintBrush } from '@phosphor-icons/react'
import sassData from '@/data/documentation/sass-data.json'
export function SassOverviewSection() {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<PaintBrush size={32} weight="duotone" className="text-white" />
</div>
<div>
<h1 className="text-4xl font-bold">{sassData.title}</h1>
<p className="text-lg text-muted-foreground">{sassData.subtitle}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Overview</h2>
<p className="text-foreground/90 leading-relaxed">{sassData.overview}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Rocket } from '@phosphor-icons/react'
import sassData from '@/data/documentation/sass-data.json'
export function SassQuickStartCard() {
return (
<Card className="bg-accent/5 border-accent/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket size={20} weight="duotone" />
Quick Start Example
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="font-semibold">{sassData.quickStart.components.title}</h3>
<pre className="custom-mui-code-block">{sassData.quickStart.components.code}</pre>
</div>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold">{sassData.quickStart.mixins.title}</h3>
<pre className="custom-mui-code-block">{sassData.quickStart.mixins.code}</pre>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,23 @@
import { SassAnimationsCard } from './SassAnimationsCard'
import { SassBestPracticesCard } from './SassBestPracticesCard'
import { SassComponentsCard } from './SassComponentsCard'
import { SassFileStructureCard } from './SassFileStructureCard'
import { SassLayoutCard } from './SassLayoutCard'
import { SassOverviewSection } from './SassOverviewSection'
import { SassQuickStartCard } from './SassQuickStartCard'
import { SassUtilitiesCard } from './SassUtilitiesCard'
export function SassTab() {
return (
<div className="space-y-6">
<SassOverviewSection />
<SassFileStructureCard />
<SassComponentsCard />
<SassLayoutCard />
<SassUtilitiesCard />
<SassAnimationsCard />
<SassQuickStartCard />
<SassBestPracticesCard />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Lightbulb } from '@phosphor-icons/react'
import sassData from '@/data/documentation/sass-data.json'
export function SassUtilitiesCard() {
return (
<Card>
<CardHeader>
<CardTitle>Sass Utilities & Mixins</CardTitle>
<CardDescription>Reusable functions for custom styling</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{sassData.utilities.map((utility, index) => (
<div key={utility.title} className="space-y-2">
<h3 className="font-semibold flex items-center gap-2">
<Lightbulb size={18} weight="duotone" className="text-accent" />
{utility.title}
</h3>
<div className="ml-6 space-y-2 text-sm">
<p className="font-mono text-accent">{utility.mixin}</p>
<p className="text-muted-foreground">{utility.description}</p>
<pre className="custom-mui-code-block text-xs mt-2">{utility.snippet}</pre>
</div>
{index < sassData.utilities.length - 1 && <Separator />}
</div>
))}
</div>
</CardContent>
</Card>
)
}

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,41 +8,41 @@ import {
TextT,
Image as ImageIcon,
} from '@phosphor-icons/react'
import { FaviconDesign } from './types'
import presets from '../../data/favicon-designer-presets.json'
import { FaviconDesign, FaviconElement } from './types'
export const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512]
type ElementTypePreset = {
value: FaviconElement['type']
label: string
}
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 },
]
type IconComponent = typeof CircleNotch
type ElementTypeValue = ElementTypePreset['value']
const ELEMENT_TYPE_ICONS: Record<ElementTypeValue, IconComponent> = {
circle: CircleNotch,
square: Square,
triangle: Triangle,
star: Star,
heart: Heart,
polygon: Polygon,
text: TextT,
emoji: ImageIcon,
}
const elementTypePresets = presets.elementTypes as ElementTypePreset[]
const defaultDesignPreset = presets.defaultDesign as FaviconDesign
export const PRESET_SIZES = presets.presetSizes
export const ELEMENT_TYPES = elementTypePresets.map((preset) => ({
...preset,
icon: ELEMENT_TYPE_ICONS[preset.value],
}))
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',
},
],
...defaultDesignPreset,
createdAt: Date.now(),
updatedAt: Date.now(),
}

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

@@ -1,9 +1,693 @@
import { FeatureIdeaCanvas } from './FeatureIdeaCloud/FeatureIdeaCanvas'
import { FeatureIdeaDialogs } from './FeatureIdeaCloud/FeatureIdeaDialogs'
import { useFeatureIdeaCloud } from './FeatureIdeaCloud/useFeatureIdeaCloud'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useKV } from '@/hooks/use-kv'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
BackgroundVariant,
useNodesState,
useEdgesState,
Connection as RFConnection,
MarkerType,
ConnectionMode,
Panel,
reconnectEdge,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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, Package } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { FeatureIdea, IdeaGroup, IdeaEdgeData } from './FeatureIdeaCloud/types'
import { CONNECTION_STYLE } from './FeatureIdeaCloud/constants'
import seedIdeasData from './FeatureIdeaCloud/data/seed-ideas.json'
import categoriesData from './FeatureIdeaCloud/data/categories.json'
import prioritiesData from './FeatureIdeaCloud/data/priorities.json'
import statusesData from './FeatureIdeaCloud/data/statuses.json'
import groupColorsData from './FeatureIdeaCloud/data/group-colors.json'
import { nodeTypes } from './FeatureIdeaCloud/nodes'
import { dispatchConnectionCountUpdate } from './FeatureIdeaCloud/dispatchConnectionCountUpdate'
type SeedIdeaJson = Omit<FeatureIdea, 'createdAt'> & { createdAtOffsetMs: number }
const SEED_IDEAS: FeatureIdea[] = (seedIdeasData as SeedIdeaJson[]).map((idea) => {
const { createdAtOffsetMs, ...rest } = idea
return {
...rest,
createdAt: Date.now() - createdAtOffsetMs,
}
})
const CATEGORIES = categoriesData as string[]
const PRIORITIES = prioritiesData as FeatureIdea['priority'][]
const STATUSES = statusesData as FeatureIdea['status'][]
const GROUP_COLORS = groupColorsData as Array<{ name: string; value: string; bg: string; border: string }>
export function FeatureIdeaCloud() {
const cloud = useFeatureIdeaCloud()
const [ideas, setIdeas] = useKV<FeatureIdea[]>('feature-ideas', SEED_IDEAS)
const [groups, setGroups] = useKV<IdeaGroup[]>('feature-idea-groups', [])
const [savedEdges, setSavedEdges] = useKV<Edge<IdeaEdgeData>[]>('feature-idea-edges', [
{
id: 'edge-1',
source: 'idea-1',
target: 'idea-8',
sourceHandle: 'right-0',
targetHandle: 'left-0',
type: 'default',
animated: false,
data: { label: 'requires' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a78bfa', width: 20, height: 20 },
style: { stroke: '#a78bfa', strokeWidth: 2.5 },
},
{
id: 'edge-2',
source: 'idea-2',
target: 'idea-4',
sourceHandle: 'bottom-0',
targetHandle: 'top-0',
type: 'default',
data: { label: 'works with' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a78bfa', width: 20, height: 20 },
style: { stroke: '#a78bfa', strokeWidth: 2.5 },
},
{
id: 'edge-3',
source: 'idea-8',
target: 'idea-5',
sourceHandle: 'bottom-0',
targetHandle: 'left-0',
type: 'default',
data: { label: 'includes' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a78bfa', width: 20, height: 20 },
style: { stroke: '#a78bfa', strokeWidth: 2.5 },
},
])
const [savedNodePositions, setSavedNodePositions] = useKV<Record<string, { x: number; y: number }>>('feature-idea-node-positions', {})
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedIdea, setSelectedIdea] = useState<FeatureIdea | null>(null)
const [selectedGroup, setSelectedGroup] = useState<IdeaGroup | null>(null)
const [selectedEdge, setSelectedEdge] = useState<Edge<IdeaEdgeData> | null>(null)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
const edgeReconnectSuccessful = useRef(true)
const safeIdeas = ideas || SEED_IDEAS
const safeGroups = groups || []
const safeEdges = savedEdges || []
const safeNodePositions = savedNodePositions || {}
const updateNodeConnectionCounts = useCallback((edges: Edge<IdeaEdgeData>[]) => {
const nodeConnectionMap = new Map<string, Record<string, Set<string>>>()
edges.forEach(edge => {
const sourceHandle = edge.sourceHandle || 'default'
const targetHandle = edge.targetHandle || 'default'
if (!nodeConnectionMap.has(edge.source)) {
nodeConnectionMap.set(edge.source, { left: new Set(), right: new Set(), top: new Set(), bottom: new Set() })
}
if (!nodeConnectionMap.has(edge.target)) {
nodeConnectionMap.set(edge.target, { left: new Set(), right: new Set(), top: new Set(), bottom: new Set() })
}
const sourceSide = sourceHandle.split('-')[0]
const targetSide = targetHandle.split('-')[0]
nodeConnectionMap.get(edge.source)![sourceSide].add(sourceHandle)
nodeConnectionMap.get(edge.target)![targetSide].add(targetHandle)
})
nodeConnectionMap.forEach((connections, nodeId) => {
const counts = {
left: connections.left.size,
right: connections.right.size,
top: connections.top.size,
bottom: connections.bottom.size,
}
dispatchConnectionCountUpdate(nodeId, counts)
})
}, [])
useEffect(() => {
if (!ideas || ideas.length === 0) {
setIdeas(SEED_IDEAS)
}
}, [ideas, setIdeas])
useEffect(() => {
const groupNodes: Node<IdeaGroup>[] = safeGroups.map((group) => ({
id: group.id,
type: 'groupNode',
position: safeNodePositions[group.id] || { x: 0, y: 0 },
data: group,
style: {
zIndex: -1,
},
}))
const ideaNodes: Node<FeatureIdea>[] = safeIdeas.map((idea, index) => ({
id: idea.id,
type: 'ideaNode',
position: safeNodePositions[idea.id] || { x: 100 + (index % 3) * 350, y: 100 + Math.floor(index / 3) * 250 },
data: idea,
parentNode: idea.parentGroup,
extent: idea.parentGroup ? 'parent' : undefined,
style: {
zIndex: 1,
},
}))
setNodes([...groupNodes, ...ideaNodes])
}, [safeIdeas, safeGroups, safeNodePositions, setNodes])
useEffect(() => {
setEdges(safeEdges)
updateNodeConnectionCounts(safeEdges)
}, [safeEdges, setEdges, updateNodeConnectionCounts])
useEffect(() => {
const handleEditIdea = (e: Event) => {
const customEvent = e as CustomEvent<FeatureIdea>
setSelectedIdea(customEvent.detail)
setEditDialogOpen(true)
}
const handleEditGroup = (e: Event) => {
const customEvent = e as CustomEvent<IdeaGroup>
setSelectedGroup(customEvent.detail)
setGroupDialogOpen(true)
}
window.addEventListener('editIdea', handleEditIdea)
window.addEventListener('editGroup', handleEditGroup)
return () => {
window.removeEventListener('editIdea', handleEditIdea)
window.removeEventListener('editGroup', handleEditGroup)
}
}, [])
const onNodesChangeWrapper = useCallback(
(changes: any) => {
onNodesChange(changes)
const moveChange = changes.find((c: any) => c.type === 'position' && c.dragging === false)
if (moveChange) {
setTimeout(() => {
setNodes((currentNodes) => {
const positions: Record<string, { x: number; y: number }> = {}
currentNodes.forEach(node => {
if (node.position) {
positions[node.id] = node.position
}
})
setSavedNodePositions(positions)
return currentNodes
})
setEdges((currentEdges) => {
setSavedEdges(currentEdges)
return currentEdges
})
}, 100)
}
},
[onNodesChange, setNodes, setEdges, setSavedNodePositions, setSavedEdges]
)
const onEdgesChangeWrapper = useCallback(
(changes: any) => {
onEdgesChange(changes)
setTimeout(() => {
setEdges((currentEdges) => {
setSavedEdges(currentEdges)
updateNodeConnectionCounts(currentEdges)
return currentEdges
})
}, 100)
},
[onEdgesChange, setEdges, setSavedEdges, updateNodeConnectionCounts]
)
const validateAndRemoveConflicts = useCallback((
edges: Edge<IdeaEdgeData>[],
sourceNodeId: string,
sourceHandleId: string,
targetNodeId: string,
targetHandleId: string,
excludeEdgeId?: string
): { filteredEdges: Edge<IdeaEdgeData>[], removedCount: number, conflicts: string[] } => {
const edgesToRemove: string[] = []
const conflicts: string[] = []
console.log('[Validator] Checking for conflicts:', {
newConnection: `${sourceNodeId}[${sourceHandleId}] -> ${targetNodeId}[${targetHandleId}]`,
existingEdges: edges.length,
excludeEdgeId
})
edges.forEach(edge => {
if (excludeEdgeId && edge.id === excludeEdgeId) {
console.log('[Validator] Skipping excluded edge:', edge.id)
return
}
const edgeSourceHandle = edge.sourceHandle || 'default'
const edgeTargetHandle = edge.targetHandle || 'default'
const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId
const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId
if (hasSourceConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Source: ${edge.source}[${edgeSourceHandle}] was connected to ${edge.target}[${edgeTargetHandle}]`)
console.log('[Validator] SOURCE CONFLICT DETECTED:', edge.id, edge)
}
if (hasTargetConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Target: ${edge.target}[${edgeTargetHandle}] was connected from ${edge.source}[${edgeSourceHandle}]`)
console.log('[Validator] TARGET CONFLICT DETECTED:', edge.id, edge)
}
})
const filteredEdges = edges.filter(e => !edgesToRemove.includes(e.id))
console.log('[Validator] Conflicts found:', conflicts.length, 'edges to remove:', edgesToRemove)
return {
filteredEdges,
removedCount: edgesToRemove.length,
conflicts
}
}, [])
const onConnect = useCallback(
(params: RFConnection) => {
if (!params.source || !params.target) return
const sourceNodeId = params.source
const sourceHandleId = params.sourceHandle || 'default'
const targetNodeId = params.target
const targetHandleId = params.targetHandle || 'default'
console.log('[Connection] ==== NEW CONNECTION ATTEMPT ====')
console.log('[Connection] Source:', `${sourceNodeId}[${sourceHandleId}]`)
console.log('[Connection] Target:', `${targetNodeId}[${targetHandleId}]`)
setEdges((eds) => {
console.log('[Connection] Current edges BEFORE validation:', eds.length)
eds.forEach(e => {
console.log(` - ${e.id}: ${e.source}[${e.sourceHandle || 'default'}] -> ${e.target}[${e.targetHandle || 'default'}]`)
})
const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts(
eds,
sourceNodeId,
sourceHandleId,
targetNodeId,
targetHandleId
)
console.log('[Connection] Edges AFTER conflict removal:', filteredEdges.length)
const newEdge: Edge<IdeaEdgeData> = {
id: `edge-${Date.now()}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle: sourceHandleId,
targetHandle: targetHandleId,
type: 'default',
data: { label: 'relates to' },
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth
},
animated: false,
}
console.log('[Connection] Creating new edge:', newEdge.id)
const updatedEdges = [...filteredEdges, newEdge]
console.log('[Connection] Total edges AFTER addition:', updatedEdges.length)
console.log('[Connection] Final edge list:')
updatedEdges.forEach(e => {
console.log(` - ${e.id}: ${e.source}[${e.sourceHandle || 'default'}] -> ${e.target}[${e.targetHandle || 'default'}]`)
})
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
if (removedCount > 0) {
setTimeout(() => {
toast.success(`Connection remapped! (${removedCount} old connection${removedCount > 1 ? 's' : ''} removed)`, {
description: conflicts.join('\n')
})
}, 0)
} else {
setTimeout(() => {
toast.success('Ideas connected!')
}, 0)
}
return updatedEdges
})
},
[setEdges, setSavedEdges, validateAndRemoveConflicts, updateNodeConnectionCounts]
)
const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge<IdeaEdgeData>) => {
setSelectedEdge(edge)
setEdgeDialogOpen(true)
}, [])
const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node<FeatureIdea>) => {
setSelectedIdea(node.data)
setViewDialogOpen(true)
}, [])
const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false
}, [])
const onReconnect = useCallback((oldEdge: Edge, newConnection: RFConnection) => {
if (!newConnection.source || !newConnection.target) return
const sourceNodeId = newConnection.source
const sourceHandleId = newConnection.sourceHandle || 'default'
const targetNodeId = newConnection.target
const targetHandleId = newConnection.targetHandle || 'default'
console.log('[Reconnection] Remapping edge:', {
oldEdgeId: oldEdge.id,
oldSource: `${oldEdge.source}[${oldEdge.sourceHandle || 'default'}]`,
oldTarget: `${oldEdge.target}[${oldEdge.targetHandle || 'default'}]`,
newSource: `${sourceNodeId}[${sourceHandleId}]`,
newTarget: `${targetNodeId}[${targetHandleId}]`
})
edgeReconnectSuccessful.current = true
setEdges((els) => {
const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts(
els,
sourceNodeId,
sourceHandleId,
targetNodeId,
targetHandleId,
oldEdge.id
)
const updatedEdges = reconnectEdge(oldEdge, newConnection, filteredEdges)
console.log('[Reconnection] Edge remapped successfully')
console.log('[Reconnection] Total edges after remapping:', updatedEdges.length)
console.log('[Reconnection] Edges by handle:', updatedEdges.map(e => ({
id: e.id,
source: `${e.source}[${e.sourceHandle || 'default'}]`,
target: `${e.target}[${e.targetHandle || 'default'}]`
})))
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
if (removedCount > 0) {
setTimeout(() => {
toast.success(`Connection remapped! (${removedCount} conflicting connection${removedCount > 1 ? 's' : ''} removed)`, {
description: conflicts.join('\n')
})
}, 0)
} else {
setTimeout(() => {
toast.success('Connection remapped!')
}, 0)
}
return updatedEdges
})
}, [setEdges, setSavedEdges, validateAndRemoveConflicts, updateNodeConnectionCounts])
const onReconnectEnd = useCallback((_: MouseEvent | TouchEvent, edge: Edge) => {
if (!edgeReconnectSuccessful.current) {
setEdges((eds) => {
const updatedEdges = eds.filter((e) => e.id !== edge.id)
setSavedEdges(updatedEdges)
return updatedEdges
})
}
edgeReconnectSuccessful.current = true
}, [setEdges, setSavedEdges])
const handleAddIdea = () => {
const newIdea: FeatureIdea = {
id: `idea-${Date.now()}`,
title: '',
description: '',
category: 'Other',
priority: 'medium',
status: 'idea',
createdAt: Date.now(),
}
setSelectedIdea(newIdea)
setEditDialogOpen(true)
}
const handleAddGroup = () => {
const newGroup: IdeaGroup = {
id: `group-${Date.now()}`,
label: '',
color: GROUP_COLORS[0].value,
createdAt: Date.now(),
}
setSelectedGroup(newGroup)
setGroupDialogOpen(true)
}
const handleSaveIdea = () => {
if (!selectedIdea || !selectedIdea.title.trim()) {
toast.error('Please enter a title')
return
}
setIdeas((currentIdeas) => {
const existing = (currentIdeas || []).find(i => i.id === selectedIdea.id)
if (existing) {
return (currentIdeas || []).map(i => i.id === selectedIdea.id ? selectedIdea : i)
} else {
return [...(currentIdeas || []), selectedIdea]
}
})
if (!(ideas || []).find(i => i.id === selectedIdea.id)) {
const newPosition = { x: 400, y: 300 }
const newNode: Node<FeatureIdea> = {
id: selectedIdea.id,
type: 'ideaNode',
position: newPosition,
data: selectedIdea,
}
setNodes((nds) => [...nds, newNode])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
[selectedIdea.id]: newPosition,
}))
}
setEditDialogOpen(false)
setSelectedIdea(null)
toast.success('Idea saved!')
}
const handleDeleteIdea = (id: string) => {
setIdeas((currentIdeas) => (currentIdeas || []).filter(i => i.id !== id))
setNodes((nds) => nds.filter(n => n.id !== id))
setSavedNodePositions((currentPositions) => {
const newPositions = { ...(currentPositions || {}) }
delete newPositions[id]
return newPositions
})
const updatedEdges = edges.filter(e => e.source !== id && e.target !== id)
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
setEditDialogOpen(false)
setViewDialogOpen(false)
setSelectedIdea(null)
toast.success('Idea deleted')
}
const handleSaveGroup = () => {
if (!selectedGroup || !selectedGroup.label.trim()) {
toast.error('Please enter a group name')
return
}
setGroups((currentGroups) => {
const existing = (currentGroups || []).find(g => g.id === selectedGroup.id)
if (existing) {
return (currentGroups || []).map(g => g.id === selectedGroup.id ? selectedGroup : g)
} else {
return [...(currentGroups || []), selectedGroup]
}
})
if (!(groups || []).find(g => g.id === selectedGroup.id)) {
const newPosition = { x: 200, y: 200 }
const newNode: Node<IdeaGroup> = {
id: selectedGroup.id,
type: 'groupNode',
position: newPosition,
data: selectedGroup,
style: {
zIndex: -1,
},
}
setNodes((nds) => [newNode, ...nds])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
[selectedGroup.id]: newPosition,
}))
}
setGroupDialogOpen(false)
setSelectedGroup(null)
toast.success('Group saved!')
}
const handleDeleteGroup = (id: string) => {
setIdeas((currentIdeas) =>
(currentIdeas || []).map(idea =>
idea.parentGroup === id ? { ...idea, parentGroup: undefined } : idea
)
)
setGroups((currentGroups) => (currentGroups || []).filter(g => g.id !== id))
setNodes((nds) => nds.filter(n => n.id !== id))
setSavedNodePositions((currentPositions) => {
const newPositions = { ...(currentPositions || {}) }
delete newPositions[id]
return newPositions
})
setGroupDialogOpen(false)
setSelectedGroup(null)
toast.success('Group deleted')
}
const handleDeleteEdge = (edgeId: string) => {
const updatedEdges = edges.filter(e => e.id !== edgeId)
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
setEdgeDialogOpen(false)
setSelectedEdge(null)
toast.success('Connection removed')
}
const handleSaveEdge = () => {
if (selectedEdge) {
const updatedEdge = {
...selectedEdge,
data: selectedEdge.data,
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth
},
animated: false,
}
const updatedEdges = edges.map(e => e.id === selectedEdge.id ? updatedEdge : e)
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
setEdgeDialogOpen(false)
toast.success('Connection updated!')
}
}
const handleGenerateIdeas = async () => {
toast.info('Generating ideas with AI...')
try {
const categoryList = CATEGORIES.join('|')
const promptText = `Generate 3 innovative feature ideas for a low-code application builder. Each idea should be practical and valuable. Return as JSON with this structure:
{
"ideas": [
{
"title": "Feature Name",
"description": "Brief description",
"category": "${categoryList}",
"priority": "low|medium|high"
}
]
}`
const response = await window.spark.llm(promptText, 'gpt-4o-mini', true)
const result = JSON.parse(response)
if (result.ideas && Array.isArray(result.ideas)) {
const newIdeas: FeatureIdea[] = result.ideas.map((idea: any) => ({
id: `idea-ai-${Date.now()}-${Math.random()}`,
title: idea.title,
description: idea.description,
category: idea.category || 'Other',
priority: idea.priority || 'medium',
status: 'idea' as const,
createdAt: Date.now(),
}))
setIdeas((currentIdeas) => [...(currentIdeas || []), ...newIdeas])
const newPositions: Record<string, { x: number; y: number }> = {}
const newNodes: Node<FeatureIdea>[] = newIdeas.map((idea, index) => {
const position = { x: 400 + (index * 250), y: 300 + (index * 150) }
newPositions[idea.id] = position
return {
id: idea.id,
type: 'ideaNode',
position,
data: idea,
}
})
setNodes((nds) => [...nds, ...newNodes])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
...newPositions,
}))
toast.success(`Generated ${newIdeas.length} new ideas!`)
}
} catch (error) {
console.error('Failed to generate ideas:', error)
toast.error('Failed to generate ideas')
}
}
return (
<div className="h-full flex flex-col bg-gradient-to-br from-background via-muted/20 to-background">

View File

@@ -0,0 +1,44 @@
import { NodeProps } from 'reactflow'
import { Button } from '@/components/ui/button'
import { DotsThree } from '@phosphor-icons/react'
import { IdeaGroup } from './types'
import { GROUP_COLORS } from './constants'
import { dispatchEditGroup } from './dispatchEditGroup'
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()
dispatchEditGroup(data)
}}
>
<DotsThree size={16} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react'
import { NodeProps, 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 } from './types'
import { PRIORITY_COLORS, STATUS_COLORS } from './constants'
import { generateHandles } from './generateHandles'
import { dispatchEditIdea } from './dispatchEditIdea'
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])
return (
<div className="relative">
{generateHandles({ position: Position.Left, type: 'target', side: 'left', count: connectionCounts.left })}
{generateHandles({ position: Position.Right, type: 'source', side: 'right', count: connectionCounts.right })}
{generateHandles({ position: Position.Top, type: 'target', side: 'top', count: connectionCounts.top })}
{generateHandles({ position: Position.Bottom, type: 'source', side: 'bottom', count: connectionCounts.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()
dispatchEditIdea(data)
}}
>
<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>
)
}

View File

@@ -3,27 +3,6 @@ export const CONNECTION_STYLE = {
strokeWidth: 2.5
}
export const CATEGORIES = [
'AI/ML',
'Collaboration',
'Community',
'DevOps',
'Testing',
'Performance',
'Design',
'Database',
'Mobile',
'Accessibility',
'Productivity',
'Security',
'Analytics',
'Other'
]
export const PRIORITIES = ['low', 'medium', 'high'] as const
export const STATUSES = ['idea', 'planned', 'in-progress', 'completed'] as const
export const STATUS_COLORS = {
idea: 'bg-muted text-muted-foreground',
planned: 'bg-accent text-accent-foreground',
@@ -36,14 +15,3 @@ export const PRIORITY_COLORS = {
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',
}
export 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)' },
]

View File

@@ -0,0 +1,16 @@
[
"AI/ML",
"Collaboration",
"Community",
"DevOps",
"Testing",
"Performance",
"Design",
"Database",
"Mobile",
"Accessibility",
"Productivity",
"Security",
"Analytics",
"Other"
]

View File

@@ -0,0 +1,10 @@
[
{ "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)" }
]

View File

@@ -0,0 +1,5 @@
[
"low",
"medium",
"high"
]

View File

@@ -0,0 +1,92 @@
[
{
"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",
"createdAtOffsetMs": 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",
"createdAtOffsetMs": 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",
"createdAtOffsetMs": 8000000
},
{
"id": "idea-4",
"title": "Visual Git Integration",
"description": "Git operations through a visual interface with branch visualization",
"category": "DevOps",
"priority": "high",
"status": "planned",
"createdAtOffsetMs": 7000000
},
{
"id": "idea-5",
"title": "API Mock Server",
"description": "Built-in mock server for testing API integrations",
"category": "Testing",
"priority": "medium",
"status": "idea",
"createdAtOffsetMs": 6000000
},
{
"id": "idea-6",
"title": "Performance Profiler",
"description": "Analyze and optimize application performance with visual metrics",
"category": "Performance",
"priority": "medium",
"status": "idea",
"createdAtOffsetMs": 5000000
},
{
"id": "idea-7",
"title": "Theme Presets",
"description": "Pre-designed theme templates for quick project setup",
"category": "Design",
"priority": "low",
"status": "completed",
"createdAtOffsetMs": 4000000
},
{
"id": "idea-8",
"title": "Database Schema Migrations",
"description": "Visual tool for creating and managing database migrations",
"category": "Database",
"priority": "high",
"status": "in-progress",
"createdAtOffsetMs": 3000000
},
{
"id": "idea-9",
"title": "Mobile App Preview",
"description": "Live preview on actual mobile devices or simulators",
"category": "Mobile",
"priority": "medium",
"status": "planned",
"createdAtOffsetMs": 2000000
},
{
"id": "idea-10",
"title": "Accessibility Checker",
"description": "Automated accessibility testing and suggestions",
"category": "Accessibility",
"priority": "high",
"status": "idea",
"createdAtOffsetMs": 1000000
}
]

View File

@@ -0,0 +1,6 @@
[
"idea",
"planned",
"in-progress",
"completed"
]

View File

@@ -0,0 +1,6 @@
export function dispatchConnectionCountUpdate(nodeId: string, counts: Record<string, number>) {
const event = new CustomEvent('updateConnectionCounts', {
detail: { nodeId, counts }
})
window.dispatchEvent(event)
}

View File

@@ -0,0 +1,6 @@
import { IdeaGroup } from './types'
export function dispatchEditGroup(group: IdeaGroup) {
const event = new CustomEvent('editGroup', { detail: group })
window.dispatchEvent(event)
}

View File

@@ -0,0 +1,6 @@
import { FeatureIdea } from './types'
export function dispatchEditIdea(idea: FeatureIdea) {
const event = new CustomEvent('editIdea', { detail: idea })
window.dispatchEvent(event)
}

View File

@@ -11,7 +11,7 @@ interface GenerateHandlesProps {
export function generateHandles({ position, type, side, count }: GenerateHandlesProps): ReactElement[] {
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
@@ -20,7 +20,7 @@ export function generateHandles({ position, type, side, count }: GenerateHandles
const positionStyle = isVertical
? { left: `${leftPercent}%` }
: { top: `${topPercent}%` }
const element = (
<Handle
key={handleId}
@@ -36,23 +36,6 @@ export function generateHandles({ position, type, side, count }: GenerateHandles
)
handles.push(element)
}
return handles
}
export function dispatchConnectionCountUpdate(nodeId: string, counts: Record<string, number>) {
const event = new CustomEvent('updateConnectionCounts', {
detail: { nodeId, counts }
})
window.dispatchEvent(event)
}
export function dispatchEditIdea(idea: any) {
const event = new CustomEvent('editIdea', { detail: idea })
window.dispatchEvent(event)
}
export function dispatchEditGroup(group: any) {
const event = new CustomEvent('editGroup', { detail: group })
window.dispatchEvent(event)
}

View File

@@ -5,7 +5,10 @@ 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'
import { PRIORITY_COLORS, STATUS_COLORS } from './constants'
import groupColorsData from './data/group-colors.json'
const GROUP_COLORS = groupColorsData as Array<{ name: string; value: string; bg: string; border: string }>
export function GroupNode({ data, selected }: NodeProps<IdeaGroup>) {
const colorScheme = GROUP_COLORS.find(c => c.value === data.color) || GROUP_COLORS[0]

View File

@@ -1,16 +0,0 @@
export function dispatchConnectionCountUpdate(nodeId: string, counts: Record<string, number>) {
const event = new CustomEvent('updateConnectionCounts', {
detail: { nodeId, counts }
})
window.dispatchEvent(event)
}
export function dispatchEditIdea(idea: any) {
const event = new CustomEvent('editIdea', { detail: idea })
window.dispatchEvent(event)
}
export function dispatchEditGroup(group: any) {
const event = new CustomEvent('editGroup', { detail: group })
window.dispatchEvent(event)
}

View File

@@ -1,17 +1,15 @@
import { useState } from 'react'
import { NextJsConfig, NpmSettings, NpmPackage } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { NextJsConfig, NpmSettings } from '@/types/project'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Plus, Trash, Package, Cube, Code } from '@phosphor-icons/react'
import { Badge } from '@/components/ui/badge'
import { SeedDataManager } from '@/components/molecules'
import { Cube } from '@phosphor-icons/react'
import projectSettingsCopy from '@/data/project-settings.json'
import { NextJsConfigTab } from '@/components/project-settings/NextJsConfigTab'
import { PackagesTab } from '@/components/project-settings/PackagesTab'
import { ScriptsTab } from '@/components/project-settings/ScriptsTab'
import { DataTab } from '@/components/project-settings/DataTab'
import { PackageDialog } from '@/components/project-settings/PackageDialog'
import { ScriptDialog } from '@/components/project-settings/ScriptDialog'
import { useProjectSettingsActions } from '@/components/project-settings/useProjectSettingsActions'
interface ProjectSettingsDesignerProps {
nextjsConfig: NextJsConfig
@@ -26,92 +24,27 @@ export function ProjectSettingsDesigner({
onNextjsConfigChange,
onNpmSettingsChange,
}: ProjectSettingsDesignerProps) {
const [packageDialogOpen, setPackageDialogOpen] = useState(false)
const [editingPackage, setEditingPackage] = useState<NpmPackage | null>(null)
const [scriptDialogOpen, setScriptDialogOpen] = useState(false)
const [scriptKey, setScriptKey] = useState('')
const [scriptValue, setScriptValue] = useState('')
const [editingScriptKey, setEditingScriptKey] = useState<string | null>(null)
const handleAddPackage = () => {
setEditingPackage({
id: `package-${Date.now()}`,
name: '',
version: 'latest',
isDev: false,
})
setPackageDialogOpen(true)
}
const handleEditPackage = (pkg: NpmPackage) => {
setEditingPackage({ ...pkg })
setPackageDialogOpen(true)
}
const handleSavePackage = () => {
if (!editingPackage || !editingPackage.name) return
onNpmSettingsChange((current) => {
const existingIndex = current.packages.findIndex((p) => p.id === editingPackage.id)
if (existingIndex >= 0) {
const updated = [...current.packages]
updated[existingIndex] = editingPackage
return { ...current, packages: updated }
} else {
return { ...current, packages: [...current.packages, editingPackage] }
}
})
setPackageDialogOpen(false)
setEditingPackage(null)
}
const handleDeletePackage = (packageId: string) => {
onNpmSettingsChange((current) => ({
...current,
packages: current.packages.filter((p) => p.id !== packageId),
}))
}
const handleAddScript = () => {
setScriptKey('')
setScriptValue('')
setEditingScriptKey(null)
setScriptDialogOpen(true)
}
const handleEditScript = (key: string, value: string) => {
setScriptKey(key)
setScriptValue(value)
setEditingScriptKey(key)
setScriptDialogOpen(true)
}
const handleSaveScript = () => {
if (!scriptKey || !scriptValue) return
onNpmSettingsChange((current) => {
const scripts = { ...current.scripts }
if (editingScriptKey && editingScriptKey !== scriptKey) {
delete scripts[editingScriptKey]
}
scripts[scriptKey] = scriptValue
return { ...current, scripts }
})
setScriptDialogOpen(false)
setScriptKey('')
setScriptValue('')
setEditingScriptKey(null)
}
const handleDeleteScript = (key: string) => {
onNpmSettingsChange((current) => {
const scripts = { ...current.scripts }
delete scripts[key]
return { ...current, scripts }
})
}
const {
packageDialogOpen,
setPackageDialogOpen,
editingPackage,
setEditingPackage,
scriptDialogOpen,
setScriptDialogOpen,
scriptKey,
setScriptKey,
scriptValue,
setScriptValue,
editingScriptKey,
handleAddPackage,
handleEditPackage,
handleSavePackage,
handleDeletePackage,
handleAddScript,
handleEditScript,
handleSaveScript,
handleDeleteScript,
} = useProjectSettingsActions({ onNpmSettingsChange })
return (
<div className="h-full flex flex-col">
@@ -121,9 +54,9 @@ export function ProjectSettingsDesigner({
<Cube size={24} weight="duotone" className="text-white" />
</div>
<div>
<h2 className="text-lg font-bold">Project Settings</h2>
<h2 className="text-lg font-bold">{projectSettingsCopy.header.title}</h2>
<p className="text-sm text-muted-foreground">
Configure Next.js and npm settings
{projectSettingsCopy.header.description}
</p>
</div>
</div>
@@ -132,488 +65,66 @@ export function ProjectSettingsDesigner({
<Tabs defaultValue="nextjs" className="flex-1 flex flex-col">
<div className="border-b border-border px-6">
<TabsList>
<TabsTrigger value="nextjs">Next.js Config</TabsTrigger>
<TabsTrigger value="packages">NPM Packages</TabsTrigger>
<TabsTrigger value="scripts">Scripts</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="nextjs">{projectSettingsCopy.tabs.nextjs}</TabsTrigger>
<TabsTrigger value="packages">{projectSettingsCopy.tabs.packages}</TabsTrigger>
<TabsTrigger value="scripts">{projectSettingsCopy.tabs.scripts}</TabsTrigger>
<TabsTrigger value="data">{projectSettingsCopy.tabs.data}</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="flex-1">
<div className="p-6">
<TabsContent value="nextjs" className="mt-0">
<div className="max-w-2xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Application Settings</CardTitle>
<CardDescription>Basic Next.js application configuration</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="app-name">Application Name</Label>
<Input
id="app-name"
value={nextjsConfig.appName}
onChange={(e) =>
onNextjsConfigChange((current) => ({
...current,
appName: e.target.value,
}))
}
placeholder="my-nextjs-app"
/>
</div>
<div>
<Label htmlFor="import-alias">Import Alias</Label>
<Input
id="import-alias"
value={nextjsConfig.importAlias}
onChange={(e) =>
onNextjsConfigChange((current) => ({
...current,
importAlias: e.target.value,
}))
}
placeholder="@/*"
/>
<p className="text-xs text-muted-foreground mt-1">
Used for module imports (e.g., import {'{'} Button {'}'} from "@/components")
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Features</CardTitle>
<CardDescription>Enable or disable Next.js features</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="typescript">TypeScript</Label>
<p className="text-xs text-muted-foreground">
Use TypeScript for type safety
</p>
</div>
<Switch
id="typescript"
checked={nextjsConfig.typescript}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
typescript: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="eslint">ESLint</Label>
<p className="text-xs text-muted-foreground">Code linting and formatting</p>
</div>
<Switch
id="eslint"
checked={nextjsConfig.eslint}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
eslint: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="tailwind">Tailwind CSS</Label>
<p className="text-xs text-muted-foreground">Utility-first CSS framework</p>
</div>
<Switch
id="tailwind"
checked={nextjsConfig.tailwind}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
tailwind: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="src-dir">Use src/ Directory</Label>
<p className="text-xs text-muted-foreground">
Organize code inside src/ folder
</p>
</div>
<Switch
id="src-dir"
checked={nextjsConfig.srcDirectory}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
srcDirectory: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="app-router">App Router</Label>
<p className="text-xs text-muted-foreground">
Use the new App Router (vs Pages Router)
</p>
</div>
<Switch
id="app-router"
checked={nextjsConfig.appRouter}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
appRouter: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="turbopack">Turbopack (Beta)</Label>
<p className="text-xs text-muted-foreground">
Faster incremental bundler
</p>
</div>
<Switch
id="turbopack"
checked={nextjsConfig.turbopack || false}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
turbopack: checked,
}))
}
/>
</div>
</CardContent>
</Card>
</div>
<NextJsConfigTab
nextjsConfig={nextjsConfig}
onNextjsConfigChange={onNextjsConfigChange}
/>
</TabsContent>
<TabsContent value="packages" className="mt-0">
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold">NPM Packages</h3>
<p className="text-sm text-muted-foreground">
Manage project dependencies
</p>
</div>
<Button onClick={handleAddPackage}>
<Plus size={16} className="mr-2" />
Add Package
</Button>
</div>
<div className="mb-6">
<Label htmlFor="package-manager">Package Manager</Label>
<Select
value={npmSettings.packageManager}
onValueChange={(value: any) =>
onNpmSettingsChange((current) => ({
...current,
packageManager: value,
}))
}
>
<SelectTrigger id="package-manager" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="yarn">yarn</SelectItem>
<SelectItem value="pnpm">pnpm</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-3">Dependencies</h4>
<div className="space-y-2">
{npmSettings.packages
.filter((pkg) => !pkg.isDev)
.map((pkg) => (
<Card key={pkg.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<Package size={18} className="text-primary" />
<code className="font-semibold">{pkg.name}</code>
<Badge variant="secondary">{pkg.version}</Badge>
</div>
{pkg.description && (
<p className="text-xs text-muted-foreground mt-1">
{pkg.description}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEditPackage(pkg)}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => handleDeletePackage(pkg.id)}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{npmSettings.packages.filter((pkg) => !pkg.isDev).length === 0 && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">No dependencies added yet</p>
</Card>
)}
</div>
</div>
<div>
<h4 className="font-semibold mb-3">Dev Dependencies</h4>
<div className="space-y-2">
{npmSettings.packages
.filter((pkg) => pkg.isDev)
.map((pkg) => (
<Card key={pkg.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<code className="font-semibold">{pkg.name}</code>
<Badge variant="secondary">{pkg.version}</Badge>
<Badge variant="outline" className="text-xs">
dev
</Badge>
</div>
{pkg.description && (
<p className="text-xs text-muted-foreground mt-1">
{pkg.description}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEditPackage(pkg)}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => handleDeletePackage(pkg.id)}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{npmSettings.packages.filter((pkg) => pkg.isDev).length === 0 && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">No dev dependencies added yet</p>
</Card>
)}
</div>
</div>
</div>
</div>
<PackagesTab
npmSettings={npmSettings}
onNpmSettingsChange={onNpmSettingsChange}
onAddPackage={handleAddPackage}
onEditPackage={handleEditPackage}
onDeletePackage={handleDeletePackage}
/>
</TabsContent>
<TabsContent value="scripts" className="mt-0">
<div className="max-w-3xl">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold">NPM Scripts</h3>
<p className="text-sm text-muted-foreground">
Define custom commands for your project
</p>
</div>
<Button onClick={handleAddScript}>
<Plus size={16} className="mr-2" />
Add Script
</Button>
</div>
<div className="space-y-2">
{Object.entries(npmSettings.scripts).map(([key, value]) => (
<Card key={key}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Code size={18} className="text-primary flex-shrink-0" />
<code className="font-semibold text-sm">{key}</code>
</div>
<code className="text-xs text-muted-foreground block truncate">
{value}
</code>
</div>
<div className="flex gap-2 ml-4">
<Button
size="sm"
variant="outline"
onClick={() => handleEditScript(key, value)}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => handleDeleteScript(key)}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{Object.keys(npmSettings.scripts).length === 0 && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">No scripts defined yet</p>
</Card>
)}
</div>
</div>
<ScriptsTab
npmSettings={npmSettings}
onAddScript={handleAddScript}
onEditScript={handleEditScript}
onDeleteScript={handleDeleteScript}
/>
</TabsContent>
<TabsContent value="data" className="mt-0">
<div className="max-w-2xl">
<SeedDataManager />
</div>
<DataTab />
</TabsContent>
</div>
</ScrollArea>
</Tabs>
<Dialog open={packageDialogOpen} onOpenChange={setPackageDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingPackage?.name ? 'Edit Package' : 'Add Package'}
</DialogTitle>
<DialogDescription>Configure npm package details</DialogDescription>
</DialogHeader>
{editingPackage && (
<div className="space-y-4">
<div>
<Label htmlFor="package-name">Package Name</Label>
<Input
id="package-name"
value={editingPackage.name}
onChange={(e) =>
setEditingPackage({ ...editingPackage, name: e.target.value })
}
placeholder="e.g., react-query, axios"
/>
</div>
<div>
<Label htmlFor="package-version">Version</Label>
<Input
id="package-version"
value={editingPackage.version}
onChange={(e) =>
setEditingPackage({ ...editingPackage, version: e.target.value })
}
placeholder="latest, ^1.0.0, ~2.3.4"
/>
</div>
<div>
<Label htmlFor="package-description">Description (Optional)</Label>
<Input
id="package-description"
value={editingPackage.description || ''}
onChange={(e) =>
setEditingPackage({ ...editingPackage, description: e.target.value })
}
placeholder="What is this package for?"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="package-dev">Development Dependency</Label>
<Switch
id="package-dev"
checked={editingPackage.isDev}
onCheckedChange={(checked) =>
setEditingPackage({ ...editingPackage, isDev: checked })
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPackageDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSavePackage}>Save Package</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PackageDialog
open={packageDialogOpen}
onOpenChange={setPackageDialogOpen}
editingPackage={editingPackage}
setEditingPackage={setEditingPackage}
onSave={handleSavePackage}
/>
<Dialog open={scriptDialogOpen} onOpenChange={setScriptDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingScriptKey ? 'Edit Script' : 'Add Script'}</DialogTitle>
<DialogDescription>Define a custom npm script command</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="script-name">Script Name</Label>
<Input
id="script-name"
value={scriptKey}
onChange={(e) => setScriptKey(e.target.value)}
placeholder="e.g., dev, build, test"
/>
</div>
<div>
<Label htmlFor="script-command">Command</Label>
<Input
id="script-command"
value={scriptValue}
onChange={(e) => setScriptValue(e.target.value)}
placeholder="e.g., next dev, tsc --noEmit"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScriptDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveScript}>Save Script</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ScriptDialog
open={scriptDialogOpen}
onOpenChange={setScriptDialogOpen}
scriptKey={scriptKey}
scriptValue={scriptValue}
setScriptKey={setScriptKey}
setScriptValue={setScriptValue}
editingScriptKey={editingScriptKey}
onSave={handleSaveScript}
/>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { SeedDataManager } from '@/components/molecules'
export function DataTab() {
return (
<div className="max-w-2xl">
<SeedDataManager />
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { NextJsConfig } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json'
interface NextJsApplicationCardProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsApplicationCard({
nextjsConfig,
onNextjsConfigChange,
}: NextJsApplicationCardProps) {
const { application } = projectSettingsCopy.nextjs
return (
<Card>
<CardHeader>
<CardTitle>{application.title}</CardTitle>
<CardDescription>{application.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="app-name">{application.fields.appName.label}</Label>
<Input
id="app-name"
value={nextjsConfig.appName}
onChange={(e) =>
onNextjsConfigChange((current) => ({
...current,
appName: e.target.value,
}))
}
placeholder={application.fields.appName.placeholder}
/>
</div>
<div>
<Label htmlFor="import-alias">{application.fields.importAlias.label}</Label>
<Input
id="import-alias"
value={nextjsConfig.importAlias}
onChange={(e) =>
onNextjsConfigChange((current) => ({
...current,
importAlias: e.target.value,
}))
}
placeholder={application.fields.importAlias.placeholder}
/>
<p className="text-xs text-muted-foreground mt-1">
{application.fields.importAlias.helper}
</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { NextJsConfig } from '@/types/project'
import { NextJsApplicationCard } from '@/components/project-settings/NextJsApplicationCard'
import { NextJsFeaturesCard } from '@/components/project-settings/NextJsFeaturesCard'
interface NextJsConfigTabProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsConfigTab({
nextjsConfig,
onNextjsConfigChange,
}: NextJsConfigTabProps) {
return (
<div className="max-w-2xl space-y-6">
<NextJsApplicationCard
nextjsConfig={nextjsConfig}
onNextjsConfigChange={onNextjsConfigChange}
/>
<NextJsFeaturesCard
nextjsConfig={nextjsConfig}
onNextjsConfigChange={onNextjsConfigChange}
/>
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { NextJsConfig } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json'
interface NextJsFeaturesCardProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsFeaturesCard({
nextjsConfig,
onNextjsConfigChange,
}: NextJsFeaturesCardProps) {
const { features } = projectSettingsCopy.nextjs
return (
<Card>
<CardHeader>
<CardTitle>{features.title}</CardTitle>
<CardDescription>{features.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="typescript">{features.items.typescript.label}</Label>
<p className="text-xs text-muted-foreground">
{features.items.typescript.description}
</p>
</div>
<Switch
id="typescript"
checked={nextjsConfig.typescript}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
typescript: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="eslint">{features.items.eslint.label}</Label>
<p className="text-xs text-muted-foreground">{features.items.eslint.description}</p>
</div>
<Switch
id="eslint"
checked={nextjsConfig.eslint}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
eslint: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="tailwind">{features.items.tailwind.label}</Label>
<p className="text-xs text-muted-foreground">
{features.items.tailwind.description}
</p>
</div>
<Switch
id="tailwind"
checked={nextjsConfig.tailwind}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
tailwind: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="src-dir">{features.items.srcDirectory.label}</Label>
<p className="text-xs text-muted-foreground">
{features.items.srcDirectory.description}
</p>
</div>
<Switch
id="src-dir"
checked={nextjsConfig.srcDirectory}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
srcDirectory: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="app-router">{features.items.appRouter.label}</Label>
<p className="text-xs text-muted-foreground">
{features.items.appRouter.description}
</p>
</div>
<Switch
id="app-router"
checked={nextjsConfig.appRouter}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
appRouter: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="turbopack">{features.items.turbopack.label}</Label>
<p className="text-xs text-muted-foreground">
{features.items.turbopack.description}
</p>
</div>
<Switch
id="turbopack"
checked={nextjsConfig.turbopack || false}
onCheckedChange={(checked) =>
onNextjsConfigChange((current) => ({
...current,
turbopack: checked,
}))
}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,90 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { NpmPackage } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json'
interface PackageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editingPackage: NpmPackage | null
setEditingPackage: (pkg: NpmPackage | null) => void
onSave: () => void
}
export function PackageDialog({
open,
onOpenChange,
editingPackage,
setEditingPackage,
onSave,
}: PackageDialogProps) {
const copy = projectSettingsCopy.packages.dialog
const isEditing = Boolean(editingPackage?.name)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? copy.title.edit : copy.title.add}</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
{editingPackage && (
<div className="space-y-4">
<div>
<Label htmlFor="package-name">{copy.fields.name.label}</Label>
<Input
id="package-name"
value={editingPackage.name}
onChange={(e) =>
setEditingPackage({ ...editingPackage, name: e.target.value })
}
placeholder={copy.fields.name.placeholder}
/>
</div>
<div>
<Label htmlFor="package-version">{copy.fields.version.label}</Label>
<Input
id="package-version"
value={editingPackage.version}
onChange={(e) =>
setEditingPackage({ ...editingPackage, version: e.target.value })
}
placeholder={copy.fields.version.placeholder}
/>
</div>
<div>
<Label htmlFor="package-description">{copy.fields.description.label}</Label>
<Input
id="package-description"
value={editingPackage.description || ''}
onChange={(e) =>
setEditingPackage({ ...editingPackage, description: e.target.value })
}
placeholder={copy.fields.description.placeholder}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="package-dev">{copy.fields.devDependency.label}</Label>
<Switch
id="package-dev"
checked={editingPackage.isDev}
onCheckedChange={(checked) =>
setEditingPackage({ ...editingPackage, isDev: checked })
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave}>Save Package</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,74 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { NpmPackage } from '@/types/project'
import { Package, Trash } from '@phosphor-icons/react'
interface PackageListSectionProps {
title: string
emptyCopy: string
iconClassName: string
showDevBadge?: boolean
packages: NpmPackage[]
onEditPackage: (pkg: NpmPackage) => void
onDeletePackage: (packageId: string) => void
}
export function PackageListSection({
title,
emptyCopy,
iconClassName,
showDevBadge = false,
packages,
onEditPackage,
onDeletePackage,
}: PackageListSectionProps) {
return (
<div>
<h4 className="font-semibold mb-3">{title}</h4>
<div className="space-y-2">
{packages.map((pkg) => (
<Card key={pkg.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<Package size={18} className={iconClassName} />
<code className="font-semibold">{pkg.name}</code>
<Badge variant="secondary">{pkg.version}</Badge>
{showDevBadge && (
<Badge variant="outline" className="text-xs">
dev
</Badge>
)}
</div>
{pkg.description && (
<p className="text-xs text-muted-foreground mt-1">{pkg.description}</p>
)}
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => onEditPackage(pkg)}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => onDeletePackage(pkg.id)}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{packages.length === 0 && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{emptyCopy}</p>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { NpmPackage, NpmSettings } from '@/types/project'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import projectSettingsCopy from '@/data/project-settings.json'
import { Plus } from '@phosphor-icons/react'
import { PackageListSection } from '@/components/project-settings/PackageListSection'
interface PackagesTabProps {
npmSettings: NpmSettings
onNpmSettingsChange: (settings: NpmSettings | ((current: NpmSettings) => NpmSettings)) => void
onAddPackage: () => void
onEditPackage: (pkg: NpmPackage) => void
onDeletePackage: (packageId: string) => void
}
export function PackagesTab({
npmSettings,
onNpmSettingsChange,
onAddPackage,
onEditPackage,
onDeletePackage,
}: PackagesTabProps) {
const copy = projectSettingsCopy.packages
const dependencies = npmSettings.packages.filter((pkg) => !pkg.isDev)
const devDependencies = npmSettings.packages.filter((pkg) => pkg.isDev)
return (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold">{copy.title}</h3>
<p className="text-sm text-muted-foreground">{copy.description}</p>
</div>
<Button onClick={onAddPackage}>
<Plus size={16} className="mr-2" />
{copy.dialog.title.add}
</Button>
</div>
<div className="mb-6">
<Label htmlFor="package-manager">{copy.packageManager.label}</Label>
<Select
value={npmSettings.packageManager}
onValueChange={(value: any) =>
onNpmSettingsChange((current) => ({
...current,
packageManager: value,
}))
}
>
<SelectTrigger id="package-manager" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="yarn">yarn</SelectItem>
<SelectItem value="pnpm">pnpm</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<PackageListSection
title={copy.dependencies.title}
emptyCopy={copy.dependencies.empty}
iconClassName="text-primary"
packages={dependencies}
onEditPackage={onEditPackage}
onDeletePackage={onDeletePackage}
/>
<PackageListSection
title={copy.devDependencies.title}
emptyCopy={copy.devDependencies.empty}
iconClassName="text-muted-foreground"
showDevBadge
packages={devDependencies}
onEditPackage={onEditPackage}
onDeletePackage={onDeletePackage}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import projectSettingsCopy from '@/data/project-settings.json'
interface ScriptDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
scriptKey: string
scriptValue: string
setScriptKey: (value: string) => void
setScriptValue: (value: string) => void
editingScriptKey: string | null
onSave: () => void
}
export function ScriptDialog({
open,
onOpenChange,
scriptKey,
scriptValue,
setScriptKey,
setScriptValue,
editingScriptKey,
onSave,
}: ScriptDialogProps) {
const copy = projectSettingsCopy.scripts.dialog
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingScriptKey ? copy.title.edit : copy.title.add}</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="script-name">{copy.fields.name.label}</Label>
<Input
id="script-name"
value={scriptKey}
onChange={(e) => setScriptKey(e.target.value)}
placeholder={copy.fields.name.placeholder}
/>
</div>
<div>
<Label htmlFor="script-command">{copy.fields.command.label}</Label>
<Input
id="script-command"
value={scriptValue}
onChange={(e) => setScriptValue(e.target.value)}
placeholder={copy.fields.command.placeholder}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave}>Save Script</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,73 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { NpmSettings } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json'
import { Code, Plus, Trash } from '@phosphor-icons/react'
interface ScriptsTabProps {
npmSettings: NpmSettings
onAddScript: () => void
onEditScript: (key: string, value: string) => void
onDeleteScript: (key: string) => void
}
export function ScriptsTab({
npmSettings,
onAddScript,
onEditScript,
onDeleteScript,
}: ScriptsTabProps) {
const copy = projectSettingsCopy.scripts
const scripts = Object.entries(npmSettings.scripts)
return (
<div className="max-w-3xl">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold">{copy.title}</h3>
<p className="text-sm text-muted-foreground">{copy.description}</p>
</div>
<Button onClick={onAddScript}>
<Plus size={16} className="mr-2" />
{copy.dialog.title.add}
</Button>
</div>
<div className="space-y-2">
{scripts.map(([key, value]) => (
<Card key={key}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Code size={18} className="text-primary flex-shrink-0" />
<code className="font-semibold text-sm">{key}</code>
</div>
<code className="text-xs text-muted-foreground block truncate">{value}</code>
</div>
<div className="flex gap-2 ml-4">
<Button size="sm" variant="outline" onClick={() => onEditScript(key, value)}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => onDeleteScript(key)}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{scripts.length === 0 && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{copy.empty}</p>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { NpmPackage, NpmSettings } from '@/types/project'
interface UseProjectSettingsActionsProps {
onNpmSettingsChange: (settings: NpmSettings | ((current: NpmSettings) => NpmSettings)) => void
}
export function useProjectSettingsActions({
onNpmSettingsChange,
}: UseProjectSettingsActionsProps) {
const [packageDialogOpen, setPackageDialogOpen] = useState(false)
const [editingPackage, setEditingPackage] = useState<NpmPackage | null>(null)
const [scriptDialogOpen, setScriptDialogOpen] = useState(false)
const [scriptKey, setScriptKey] = useState('')
const [scriptValue, setScriptValue] = useState('')
const [editingScriptKey, setEditingScriptKey] = useState<string | null>(null)
const handleAddPackage = () => {
setEditingPackage({
id: `package-${Date.now()}`,
name: '',
version: 'latest',
isDev: false,
})
setPackageDialogOpen(true)
}
const handleEditPackage = (pkg: NpmPackage) => {
setEditingPackage({ ...pkg })
setPackageDialogOpen(true)
}
const handleSavePackage = () => {
if (!editingPackage || !editingPackage.name) return
onNpmSettingsChange((current) => {
const existingIndex = current.packages.findIndex((p) => p.id === editingPackage.id)
if (existingIndex >= 0) {
const updated = [...current.packages]
updated[existingIndex] = editingPackage
return { ...current, packages: updated }
}
return { ...current, packages: [...current.packages, editingPackage] }
})
setPackageDialogOpen(false)
setEditingPackage(null)
}
const handleDeletePackage = (packageId: string) => {
onNpmSettingsChange((current) => ({
...current,
packages: current.packages.filter((p) => p.id !== packageId),
}))
}
const handleAddScript = () => {
setScriptKey('')
setScriptValue('')
setEditingScriptKey(null)
setScriptDialogOpen(true)
}
const handleEditScript = (key: string, value: string) => {
setScriptKey(key)
setScriptValue(value)
setEditingScriptKey(key)
setScriptDialogOpen(true)
}
const handleSaveScript = () => {
if (!scriptKey || !scriptValue) return
onNpmSettingsChange((current) => {
const scripts = { ...current.scripts }
if (editingScriptKey && editingScriptKey !== scriptKey) {
delete scripts[editingScriptKey]
}
scripts[scriptKey] = scriptValue
return { ...current, scripts }
})
setScriptDialogOpen(false)
setScriptKey('')
setScriptValue('')
setEditingScriptKey(null)
}
const handleDeleteScript = (key: string) => {
onNpmSettingsChange((current) => {
const scripts = { ...current.scripts }
delete scripts[key]
return { ...current, scripts }
})
}
return {
packageDialogOpen,
setPackageDialogOpen,
editingPackage,
setEditingPackage,
scriptDialogOpen,
setScriptDialogOpen,
scriptKey,
setScriptKey,
scriptValue,
setScriptValue,
editingScriptKey,
setEditingScriptKey,
handleAddPackage,
handleEditPackage,
handleSavePackage,
handleDeletePackage,
handleAddScript,
handleEditScript,
handleSaveScript,
handleDeleteScript,
}
}

View File

@@ -1,722 +0,0 @@
"use client"
import { CSSProperties, ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import PanelLeftIcon from "lucide-react/dist/esm/icons/panel-left"
import { useIsMobile } from "@/hooks/use-mobile"
import { useThemeConfig } from "@/hooks/use-theme-config"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const { themeConfig } = useThemeConfig()
const [openMobile, setOpenMobile] = useState(false)
const sidebarWidth = themeConfig.sidebar?.width || '16rem'
const sidebarWidthIcon = themeConfig.sidebar?.widthIcon || '3rem'
const [_open, _setOpen] = useState(defaultOpen)
const open = openProp ?? _open
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
const toggleSidebar = useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
const state = open ? "expanded" : "collapsed"
const contextValue = useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": sidebarWidth,
"--sidebar-width-icon": sidebarWidthIcon,
...style,
} as CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { themeConfig } = useThemeConfig()
const sidebarWidthMobile = themeConfig.sidebar?.widthMobile || '18rem'
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": sidebarWidthMobile,
} as CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,24 @@
export { Sidebar } from "@/components/ui/sidebar/sidebar"
export { SidebarContent } from "@/components/ui/sidebar/sidebar-content"
export { SidebarFooter } from "@/components/ui/sidebar/sidebar-footer"
export { SidebarGroup } from "@/components/ui/sidebar/sidebar-group"
export { SidebarGroupAction } from "@/components/ui/sidebar/sidebar-group-action"
export { SidebarGroupContent } from "@/components/ui/sidebar/sidebar-group-content"
export { SidebarGroupLabel } from "@/components/ui/sidebar/sidebar-group-label"
export { SidebarHeader } from "@/components/ui/sidebar/sidebar-header"
export { SidebarInput } from "@/components/ui/sidebar/sidebar-input"
export { SidebarInset } from "@/components/ui/sidebar/sidebar-inset"
export { SidebarMenu } from "@/components/ui/sidebar/sidebar-menu"
export { SidebarMenuAction } from "@/components/ui/sidebar/sidebar-menu-action"
export { SidebarMenuBadge } from "@/components/ui/sidebar/sidebar-menu-badge"
export { SidebarMenuButton } from "@/components/ui/sidebar/sidebar-menu-button"
export { SidebarMenuItem } from "@/components/ui/sidebar/sidebar-menu-item"
export { SidebarMenuSkeleton } from "@/components/ui/sidebar/sidebar-menu-skeleton"
export { SidebarMenuSub } from "@/components/ui/sidebar/sidebar-menu-sub"
export { SidebarMenuSubButton } from "@/components/ui/sidebar/sidebar-menu-sub-button"
export { SidebarMenuSubItem } from "@/components/ui/sidebar/sidebar-menu-sub-item"
export { SidebarProvider } from "@/components/ui/sidebar/sidebar-provider"
export { SidebarRail } from "@/components/ui/sidebar/sidebar-rail"
export { SidebarSeparator } from "@/components/ui/sidebar/sidebar-separator"
export { SidebarTrigger } from "@/components/ui/sidebar/sidebar-trigger"
export { useSidebar } from "@/components/ui/sidebar/use-sidebar"

View File

@@ -0,0 +1,19 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
export { SidebarContent }

View File

@@ -0,0 +1,16 @@
import { createContext } from "react"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = createContext<SidebarContextProps | null>(null)
export type { SidebarContextProps }
export { SidebarContext }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export { SidebarFooter }

View File

@@ -0,0 +1,28 @@
import { ComponentProps } from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
function SidebarGroupAction({
className,
asChild = false,
...props
}: ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export { SidebarGroupAction }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
export { SidebarGroupContent }

View File

@@ -0,0 +1,27 @@
import { ComponentProps } from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
function SidebarGroupLabel({
className,
asChild = false,
...props
}: ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
export { SidebarGroupLabel }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
export { SidebarGroup }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export { SidebarHeader }

View File

@@ -0,0 +1,17 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
export { SidebarInput }

View File

@@ -0,0 +1,19 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarInset({ className, ...props }: ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
export { SidebarInset }

View File

@@ -0,0 +1,37 @@
import { ComponentProps } from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
export { SidebarMenuAction }

View File

@@ -0,0 +1,24 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarMenuBadge({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export { SidebarMenuBadge }

View File

@@ -0,0 +1,83 @@
"use client"
import { ComponentProps } from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useSidebar } from "@/components/ui/sidebar/use-sidebar"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
export { SidebarMenuButton }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
export { SidebarMenuItem }

View File

@@ -0,0 +1,43 @@
import { ComponentProps, CSSProperties, useMemo } from "react"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: ComponentProps<"div"> & {
showIcon?: boolean
}) {
const width = useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as CSSProperties
}
/>
</div>
)
}
export { SidebarMenuSkeleton }

View File

@@ -0,0 +1,38 @@
import { ComponentProps } from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export { SidebarMenuSubButton }

View File

@@ -0,0 +1,16 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarMenuSubItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
export { SidebarMenuSubItem }

View File

@@ -0,0 +1,20 @@
import { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export { SidebarMenuSub }

Some files were not shown because too many files have changed in this diff Show More