-
-
-
-
-
-
-
CodeForge
-
- Low-Code Next.js App Builder with AI
-
-
-
-
-
-
-
-
Overview
-
- CodeForge is a comprehensive visual low-code platform for generating production-ready Next.js applications.
- It combines the power of visual designers with direct code editing, AI-powered generation, and a complete
- full-stack development toolkit.
-
-
-
-
-
-
-
- Key Features
-
-
-
- }
- title="Monaco Code Editor"
- description="Full-featured code editor with syntax highlighting, autocomplete, and AI-powered improvements"
- />
- }
- title="Prisma Schema Designer"
- description="Visual database model designer with automatic schema generation and AI assistance"
- />
- }
- title="Component Tree Builder"
- description="Hierarchical component structure builder with drag-and-drop and AI generation"
- />
- }
- title="Theme Designer"
- description="Material UI theme customizer with multiple variants, custom colors, and AI theme generation"
- />
- }
- title="Flask Backend Designer"
- description="Visual Python Flask API designer with blueprints, endpoints, and CORS configuration"
- />
- }
- title="Playwright Test Designer"
- description="E2E test builder with step configuration and AI test generation"
- />
- }
- title="Unit Test Designer"
- description="Comprehensive test suite builder for components, functions, hooks, and integration tests"
- />
- }
- title="Auto Error Repair"
- description="Automated error detection and AI-powered code repair system"
- />
- }
- title="Project Settings"
- description="Configure Next.js options, npm packages, scripts, and build settings"
- />
- }
- title="AI Integration"
- description="OpenAI-powered generation across all features for rapid development"
- />
-
-
-
-
-
Getting Started
-
-
-
-
- 1
- Create Your First Model
-
-
- Navigate to the Models tab and create your database schema using the visual designer
- or describe your data model to the AI.
-
-
-
-
- 2
- Design Your Components
-
-
- Use the Components tab to build your UI hierarchy visually or let the AI generate
- component structures based on your requirements.
-
-
-
-
- 3
- Customize Your Theme
-
-
- Head to the Styling tab to create custom color palettes, manage theme variants (light/dark),
- and configure typography.
-
-
-
-
- 4
- Build Your Backend
-
-
- Configure your Flask API in the Flask API tab by creating blueprints and endpoints
- with full CORS and authentication support.
-
-
-
-
- 5
- Export Your Project
-
-
- Click Export Project to generate all files including Next.js pages, Prisma schemas,
- Flask backend, tests, and configuration files ready for deployment.
-
-
-
-
-
-
-
-
AI-Powered Features
-
- CodeForge integrates OpenAI across every designer to accelerate development:
-
-
-
-
-
-
Technology Stack
-
-
-
- Frontend
-
-
-
-
-
- Next.js 14 with App Router
-
-
-
- React 18 with TypeScript
-
-
-
- Material UI 5
-
-
-
- Monaco Editor
-
-
-
- Tailwind CSS
-
-
-
-
-
-
- Backend & Tools
-
-
-
-
-
- Flask REST API
-
-
-
- Prisma ORM
-
-
-
- Playwright E2E Testing
-
-
-
- Vitest & React Testing Library
-
-
-
- Storybook for Components
-
-
-
-
-
-
-
-
-
-
-
- Pro Tips
-
-
-
- 💡 Use the AI Generate feature to quickly scaffold entire applications from descriptions
- 💡 The Error Repair tab automatically scans and fixes common issues - check it before exporting
- 💡 Create multiple theme variants (light, dark, custom) in the Styling tab for complete theme support
- 💡 Test your components with Storybook stories before writing full E2E tests
- 💡 Flask blueprints help organize your API endpoints by feature or resource
-
-
-
+
-
-
-
-
- Product Roadmap
-
-
- Features delivered and planned for CodeForge development
-
-
-
-
-
-
-
-
-
Completed Features
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Planned Features
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
- Agents Files
-
-
- AI agent configuration and service architecture
-
-
-
-
-
-
AI Service Architecture
-
- CodeForge uses a modular AI service architecture that integrates OpenAI's GPT models across all features.
- Each designer has specialized prompts and validation logic to ensure high-quality generated code.
-
-
-
-
- Core AI Services
- Primary modules handling AI operations
-
-
-
-
-
-
-
-
-
-
- AI Integration Points
- Features enhanced by AI capabilities
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Prompt Engineering
- How we optimize AI interactions
-
-
-
-
Context Preservation
-
- All AI prompts include relevant project context such as existing models, components,
- and theme configurations to ensure generated code integrates seamlessly.
-
-
-
-
Format Specification
-
- Prompts specify exact output formats (JSON, TypeScript, Python) with strict schemas
- to ensure parseable and valid responses.
-
-
-
-
Best Practices Enforcement
-
- Generated code follows Next.js, React, and Flask best practices through detailed
- prompt instructions and post-processing validation.
-
-
-
-
Error Handling
-
- Fallback mechanisms and retry logic ensure graceful degradation when AI services
- are unavailable or responses are malformed.
-
-
-
-
-
-
-
-
-
- Future AI Enhancements
-
-
-
-
-
-
- Multi-Model Support: Integration with Claude, Gemini, and other LLMs for specialized tasks
-
-
-
- Fine-Tuned Models: Custom models trained on specific frameworks and design patterns
-
-
-
- Code Review Agent: Automated code review with security and performance analysis
-
-
-
- Conversational Interface: Chat-based project building with natural language commands
-
-
-
- Learning System: AI that learns from user corrections and preferences over time
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
Progressive Web App
-
- Offline-first experience with native-like capabilities
-
-
-
-
-
-
-
-
Overview
-
- CodeForge is a fully-featured Progressive Web App that can be installed on any device and works offline.
- With intelligent caching, automatic updates, and native app-like features, you can build applications anywhere, anytime.
-
-
-
-
-
- PWA Features
- Native app capabilities in your browser
-
-
-
-
-
- Installable
-
-
- Install on desktop or mobile for quick access from your home screen or applications menu
-
-
-
-
-
- Offline Support
-
-
- Work without internet connection; changes sync automatically when you reconnect
-
-
-
-
-
- Automatic Updates
-
-
- Get notified when new versions are available with one-click updates
-
-
-
-
-
- Push Notifications
-
-
- Opt-in to receive updates about builds, errors, and new features
-
-
-
-
-
- App Shortcuts
-
-
- Quick access to Dashboard, Code Editor, and Models from your OS
-
-
-
-
-
- Share Target
-
-
- Share code files directly to CodeForge from other apps
-
-
-
-
-
-
-
Installation
-
-
-
-
- Desktop Installation
-
-
-
-
Chrome/Edge/Brave:
-
- Look for install icon (⊕) in address bar
- Click "Install" or use prompt in app
- App added to applications menu
-
-
-
-
Safari (macOS):
-
- Click File → Add to Dock
- App appears in Dock
-
-
-
-
-
-
-
- Mobile Installation
-
-
-
-
iOS (Safari):
-
- Tap Share button
- Select "Add to Home Screen"
- Tap "Add"
-
-
-
-
Android (Chrome):
-
- Tap menu (three dots)
- Select "Install app"
- Confirm installation
-
-
-
-
-
-
-
-
-
PWA Settings
-
- Navigate to the PWA tab to manage all Progressive Web App features:
-
-
-
-
- Available Controls
-
-
-
-
Installation Status
-
- Check if app is installed and trigger installation if available
-
-
-
-
-
Network Status
-
- Real-time online/offline indicator with connectivity information
-
-
-
-
-
Push Notifications
-
- Toggle notifications and manage permissions
-
-
-
-
-
Cache Management
-
- View cache size, service worker status, and clear cached data
-
-
-
-
-
Update Management
-
- Install pending updates when new versions are available
-
-
-
-
-
-
-
-
Offline Capabilities
-
-
-
-
-
-
- Works Offline
-
-
-
-
-
- •
- View and edit existing projects
-
-
- •
- Browse files and code
-
-
- •
- Use Monaco editor
-
-
- •
- Navigate all tabs
-
-
- •
- View documentation
-
-
- •
- Make changes locally
-
-
-
-
-
-
-
-
-
- Requires Internet
-
-
-
-
-
- •
- AI-powered generation
-
-
- •
- External font loading
-
-
- •
- Database sync
-
-
- •
- External resources
-
-
-
-
-
-
-
-
-
-
-
- Pro Tips
-
-
-
-
-
- •
- Install for best performance: Installed apps load faster and work more reliably offline
-
-
- •
- Save before going offline: Ensure projects are saved to local storage before losing connection
-
-
- •
- Clear cache if issues arise: Use PWA settings to clear cache and reload with fresh data
-
-
- •
- Enable notifications: Stay informed about updates and build completions
-
-
- •
- Update regularly: New versions bring performance improvements and features
-
-
-
-
-
+
-
-
-
-
-
-
Sass Styles Guide
-
- Custom Material UI components with Sass
-
-
-
-
-
-
-
-
Overview
-
- CodeForge includes a comprehensive Sass-based styling system for non-standard Material UI components.
- This system provides pre-built components, utilities, mixins, and animations that extend beyond the
- standard Material UI component library.
-
-
-
-
-
- File Structure
-
-
-
-
src/styles/_variables.scss
-
- Color palettes, spacing scales, typography, transitions, and other design tokens
-
-
-
-
src/styles/_utilities.scss
-
- Mixins and functions for responsive design, colors, typography, and layout helpers
-
-
-
-
src/styles/_animations.scss
-
- Keyframe animations and animation utility classes for transitions and effects
-
-
-
-
src/styles/material-ui-custom.scss
-
- Custom Material UI component styles with variants and states
-
-
-
-
src/styles/main.scss
-
- Main entry point that imports all Sass modules and provides layout components
-
-
-
-
-
-
-
- Available Components
- Custom Material UI components built with Sass
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Layout Components
- Sass-powered layout utilities
-
-
- }
- title="custom-mui-container"
- description="Max-width container with responsive padding"
- />
- }
- title="custom-mui-grid"
- description="CSS Grid layouts with responsive columns (--cols-1 to --cols-12, --responsive)"
- />
- }
- title="custom-mui-flex"
- description="Flexbox utilities (--row, --col, --wrap, --center, --between, --around)"
- />
- }
- title="custom-mui-stack"
- description="Vertical/horizontal stacks with configurable gaps"
- />
- }
- title="custom-mui-surface"
- description="Interactive surfaces with elevation and hover effects"
- />
-
-
-
-
-
- Sass Utilities & Mixins
- Reusable functions for custom styling
-
-
-
-
-
-
- Responsive Design
-
-
-
@include respond-to($breakpoint)
-
Generate media queries for xs, sm, md, lg, xl, 2xl breakpoints
-
-{`@include respond-to('lg') {
- padding: 2rem;
-}`}
-
-
-
-
-
-
-
-
-
- Elevation & Shadows
-
-
-
@include elevation($level)
-
Apply box shadows with levels 1-4
-
-{`@include elevation(2);`}
-
-
-
-
-
-
-
-
-
- Glassmorphism
-
-
-
@include glassmorphism($blur, $opacity)
-
Create frosted glass effects with backdrop blur
-
-{`@include glassmorphism(16px, 0.1);`}
-
-
-
-
-
-
-
-
-
- Color Functions
-
-
-
get-color($palette, $shade)
-
Access colors from predefined palettes (primary, secondary, accent, success, error, warning)
-
-{`color: get-color('primary', 500);`}
-
-
-
-
-
-
-
-
-
- Text Truncation
-
-
-
@include truncate($lines)
-
Truncate text with ellipsis after specified lines
-
-{`@include truncate(2);`}
-
-
-
-
-
-
-
-
-
- Custom Scrollbars
-
-
-
@include show-scrollbar($track, $thumb)
-
Style webkit scrollbars with custom colors
-
-{`@include show-scrollbar(rgba(0,0,0,0.1), rgba(0,0,0,0.3));`}
-
-
-
-
-
-
-
-
-
- Animation Classes
- Pre-built animation utilities
-
-
-
-
-
-
-
-
-
-
- Quick Start Example
-
-
-
-
-
Using Custom Components
-
-{`import './styles/main.scss'
-
-function MyComponent() {
- return (
-
-
-
-
Card Title
-
Card content
-
- Click Me
-
-
-
-
- )
-}`}
-
-
-
-
-
-
-
Creating Custom Styles with Mixins
-
-{`@use './styles/utilities' as *;
-@use './styles/variables' as *;
-
-.my-custom-component {
- @include elevation(2);
- @include responsive-padding(spacing('6'));
- background: get-color('primary', 500);
-
- @include respond-to('md') {
- @include elevation(3);
- }
-
- &:hover {
- @include glassmorphism(12px, 0.15);
- }
-}`}
-
-
-
-
-
-
-
-
-
- Best Practices
-
-
-
-
-
-
- Import main.scss in your index.css to access all Sass components and utilities
-
-
-
- Use @use instead of @import for better module encapsulation
-
-
-
- Leverage mixins for consistent spacing, elevation, and responsive design
-
-
-
- Extend existing component classes rather than creating from scratch
-
-
-
- Use animation classes sparingly and respect prefers-reduced-motion
-
-
-
- Customize variables in _variables.scss to match your design system
-
-
-
-
-
+
-
-
-
-
-
-
-
-
CI/CD Guide
-
- Complete deployment automation for multiple platforms
-
-
-
-
-
-
-
-
Overview
-
- CodeForge includes production-ready CI/CD configurations for GitHub Actions, GitLab CI, Jenkins,
- and CircleCI. Each pipeline includes linting, testing, security scanning, Docker image building,
- and automated deployment workflows.
-
-
-
-
-
- Available Configurations
-
-
-
-
-
-
-
-
-
-
-
Pipeline Stages
-
- All CI/CD configurations follow a similar multi-stage pipeline structure:
-
-
-
-
-
-
- Docker Configuration
- Containerization for production deployment
-
-
-
-
Files Included
-
-
-
Dockerfile
-
Multi-stage build with Node.js builder and Nginx runtime
-
-
-
nginx.conf
-
Production Nginx configuration with health checks and caching
-
-
-
docker-compose.yml
-
Local development and deployment orchestration
-
-
-
.dockerignore
-
Optimized build context by excluding unnecessary files
-
-
-
-
-
-
-
-
Docker Commands
-
-{`# Build image locally
-docker build -t codeforge:local .
-
-# Run container
-docker run -p 3000:80 codeforge:local
-
-# Use docker-compose
-docker-compose up -d
-
-# Pull from registry
-docker pull ghcr.io//:latest`}
-
-
-
-
-
-
-
Features
-
-
-
- Multi-stage build reduces final image size to ~50MB
-
-
-
- Nginx serves static files with gzip compression
-
-
-
- Health check endpoint at /health for orchestration
-
-
-
- Automatic cache headers for static assets
-
-
-
- SPA routing support with fallback to index.html
-
-
-
-
-
-
-
-
- Environment Variables
- Required configuration for CI/CD platforms
-
-
-
-
-
-
-
- Variable
- Description
- Required
-
-
-
-
- NODE_VERSION
- Node.js version (default: 20)
- No
-
-
- REGISTRY
- Docker registry URL (default: ghcr.io)
- No
-
-
- STAGING_WEBHOOK_URL
- Webhook for staging deployments
- Optional
-
-
- PRODUCTION_WEBHOOK_URL
- Webhook for production deployments
- Optional
-
-
- CODECOV_TOKEN
- Codecov integration token
- Optional
-
-
- SLACK_WEBHOOK
- Slack webhook for notifications
- Optional
-
-
-
-
-
-
-
-
-
-
Branch Strategy
-
-
-
-
-
-
-
main
-
- Production branch - deploys to production environment (manual approval required)
-
-
-
-
-
-
-
-
-
-
-
develop
-
- Development branch - automatically deploys to staging environment
-
-
-
-
-
-
-
-
-
-
-
feature/*
-
- Feature branches - runs tests only, no deployment
-
-
-
-
-
-
-
-
-
-
-
v* tags
-
- Version tags - triggers release workflow with artifacts and changelog
-
-
-
-
-
-
-
-
-
-
-
-
- Quick Start
-
-
-
-
-
-
- 1
- Choose Your Platform
-
-
- Select GitHub Actions, GitLab CI, Jenkins, or CircleCI based on your infrastructure
-
-
-
-
- 2
- Configure Secrets
-
-
- Add required environment variables and secrets in your platform's settings
-
-
-
-
- 3
- Push to Repository
-
-
- Push code to main or develop branch to trigger the CI/CD pipeline
-
-
-
-
- 4
- Monitor Pipeline
-
-
- View pipeline status, test results, and deployment logs in your platform's dashboard
-
-
-
-
-
-
-
-
-
-
- Best Practices
-
-
-
-
-
-
- Never commit secrets - use environment variables and platform secret management
-
-
-
- Enable branch protection on main and develop branches
-
-
-
- Require code reviews and passing tests before merging
-
-
-
- Use manual approval gates for production deployments
-
-
-
- Monitor security scan results and fix vulnerabilities promptly
-
-
-
- Keep dependencies updated with Dependabot or Renovate
-
-
-
- Use semantic versioning for releases (v1.0.0, v1.1.0, etc.)
-
-
-
- Configure Slack or email notifications for deployment status
-
-
-
-
-
-
-
-
-
- Additional Resources
-
-
-
-
-
-
- CI_CD_GUIDE.md - Detailed setup guide for all platforms
-
-
-
- .github/workflows/ - GitHub Actions workflows
-
-
-
- .gitlab-ci.yml - GitLab CI configuration
-
-
-
- Jenkinsfile - Jenkins pipeline definition
-
-
-
- .circleci/config.yml - CircleCI configuration
-
-
-
-
-
+
diff --git a/src/components/DocumentationView/AgentItems.tsx b/src/components/DocumentationView/AgentItems.tsx
new file mode 100644
index 0000000..68c59c8
--- /dev/null
+++ b/src/components/DocumentationView/AgentItems.tsx
@@ -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 (
+
+
+
+
+ {filename}
+
+
{path}
+
{description}
+
+
+
Key Features:
+
+ {features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+ )
+}
+
+export function IntegrationPoint({ component, capabilities }: { component: string; capabilities: string[] }) {
+ return (
+
+
+
+ {component}
+
+
+ {capabilities.map((capability) => (
+
+ •
+ {capability}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsCoreServices.tsx b/src/components/DocumentationView/AgentsCoreServices.tsx
new file mode 100644
index 0000000..363c148
--- /dev/null
+++ b/src/components/DocumentationView/AgentsCoreServices.tsx
@@ -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 (
+
+
+ Core AI Services
+ Primary modules handling AI operations
+
+
+ {agentsData.coreServices.map((service) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsFutureEnhancements.tsx b/src/components/DocumentationView/AgentsFutureEnhancements.tsx
new file mode 100644
index 0000000..f691cf4
--- /dev/null
+++ b/src/components/DocumentationView/AgentsFutureEnhancements.tsx
@@ -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 (
+
+
+
+
+ Future AI Enhancements
+
+
+
+
+ {agentsData.futureEnhancements.map((item) => (
+
+
+
+ {item.title}: {item.description}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsIntegrationPoints.tsx b/src/components/DocumentationView/AgentsIntegrationPoints.tsx
new file mode 100644
index 0000000..58e8807
--- /dev/null
+++ b/src/components/DocumentationView/AgentsIntegrationPoints.tsx
@@ -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 (
+
+
+ AI Integration Points
+ Features enhanced by AI capabilities
+
+
+
+ {agentsData.integrationPoints.map((point) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsOverviewSection.tsx b/src/components/DocumentationView/AgentsOverviewSection.tsx
new file mode 100644
index 0000000..464b5db
--- /dev/null
+++ b/src/components/DocumentationView/AgentsOverviewSection.tsx
@@ -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 (
+
+
+
+ {agentsData.title}
+
+
{agentsData.subtitle}
+
+
+
+
+
AI Service Architecture
+
{agentsData.overview}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsPromptEngineering.tsx b/src/components/DocumentationView/AgentsPromptEngineering.tsx
new file mode 100644
index 0000000..72a44fc
--- /dev/null
+++ b/src/components/DocumentationView/AgentsPromptEngineering.tsx
@@ -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 (
+
+
+ Prompt Engineering
+ How we optimize AI interactions
+
+
+ {agentsData.promptEngineering.map((item) => (
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/AgentsTab.tsx b/src/components/DocumentationView/AgentsTab.tsx
new file mode 100644
index 0000000..5483f2e
--- /dev/null
+++ b/src/components/DocumentationView/AgentsTab.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdBestPracticesCard.tsx b/src/components/DocumentationView/CicdBestPracticesCard.tsx
new file mode 100644
index 0000000..6dfd065
--- /dev/null
+++ b/src/components/DocumentationView/CicdBestPracticesCard.tsx
@@ -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 (
+
+
+
+
+ Best Practices
+
+
+
+
+ {cicdData.bestPractices.map((practice) => (
+
+
+ {practice}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdBranchStrategySection.tsx b/src/components/DocumentationView/CicdBranchStrategySection.tsx
new file mode 100644
index 0000000..34d0a25
--- /dev/null
+++ b/src/components/DocumentationView/CicdBranchStrategySection.tsx
@@ -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 (
+
+
Branch Strategy
+
+ {cicdData.branches.map((branch) => {
+ const styles = toneStyles[branch.tone]
+ return (
+
+
+
+
+
+
{branch.name}
+
{branch.description}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdDockerCard.tsx b/src/components/DocumentationView/CicdDockerCard.tsx
new file mode 100644
index 0000000..6f8e83b
--- /dev/null
+++ b/src/components/DocumentationView/CicdDockerCard.tsx
@@ -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 (
+
+
+ Docker Configuration
+ Containerization for production deployment
+
+
+
+
Files Included
+
+ {cicdData.docker.files.map((file) => (
+
+
{file.name}
+
{file.description}
+
+ ))}
+
+
+
+
+
+
+
Docker Commands
+
{cicdData.docker.commands}
+
+
+
+
+
+
Features
+
+ {cicdData.docker.features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdEnvVarsCard.tsx b/src/components/DocumentationView/CicdEnvVarsCard.tsx
new file mode 100644
index 0000000..593a4b3
--- /dev/null
+++ b/src/components/DocumentationView/CicdEnvVarsCard.tsx
@@ -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 (
+
+
+ Environment Variables
+ Required configuration for CI/CD platforms
+
+
+
+
+
+
+
+ Variable
+ Description
+ Required
+
+
+
+ {cicdData.environmentVariables.map((variable) => (
+
+
+ {variable.variable}
+
+ {variable.description}
+ {variable.required}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdItems.tsx b/src/components/DocumentationView/CicdItems.tsx
new file mode 100644
index 0000000..c7f0601
--- /dev/null
+++ b/src/components/DocumentationView/CicdItems.tsx
@@ -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 (
+
+
+
+
+
{name}
+
+
{file}
+
{description}
+
+
+
Key Features:
+
+ {features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+ )
+}
+
+export function PipelineStageCard({ stage, description, duration }: {
+ stage: string
+ description: string
+ duration: string
+}) {
+ return (
+
+
+
+
+
{stage}
+
{description}
+
+
+ {duration}
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdOverviewSection.tsx b/src/components/DocumentationView/CicdOverviewSection.tsx
new file mode 100644
index 0000000..47840b9
--- /dev/null
+++ b/src/components/DocumentationView/CicdOverviewSection.tsx
@@ -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 (
+
+
+
+
+
+
+
{cicdData.title}
+
{cicdData.subtitle}
+
+
+
+
+
+
+
Overview
+
{cicdData.overview}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdPipelineSection.tsx b/src/components/DocumentationView/CicdPipelineSection.tsx
new file mode 100644
index 0000000..2d25d19
--- /dev/null
+++ b/src/components/DocumentationView/CicdPipelineSection.tsx
@@ -0,0 +1,21 @@
+import cicdData from '@/data/documentation/cicd-data.json'
+import { PipelineStageCard } from './CicdItems'
+
+export function CicdPipelineSection() {
+ return (
+
+
Pipeline Stages
+
{cicdData.pipeline.intro}
+
+ {cicdData.pipeline.stages.map((stage) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdPlatformsCard.tsx b/src/components/DocumentationView/CicdPlatformsCard.tsx
new file mode 100644
index 0000000..cab3594
--- /dev/null
+++ b/src/components/DocumentationView/CicdPlatformsCard.tsx
@@ -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 (
+
+
+ Available Configurations
+
+
+ {cicdData.platforms.map((platform) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdQuickStartCard.tsx b/src/components/DocumentationView/CicdQuickStartCard.tsx
new file mode 100644
index 0000000..1658550
--- /dev/null
+++ b/src/components/DocumentationView/CicdQuickStartCard.tsx
@@ -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 (
+
+
+
+
+ Quick Start
+
+
+
+
+ {cicdData.quickStart.map((step) => (
+
+
+
+ {step.step}
+
+ {step.title}
+
+
{step.description}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdResourcesCard.tsx b/src/components/DocumentationView/CicdResourcesCard.tsx
new file mode 100644
index 0000000..3df07b0
--- /dev/null
+++ b/src/components/DocumentationView/CicdResourcesCard.tsx
@@ -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 (
+
+
+
+
+ Additional Resources
+
+
+
+
+ {cicdData.resources.map((resource) => (
+
+
+
+ {resource.label} - {resource.description}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/CicdTab.tsx b/src/components/DocumentationView/CicdTab.tsx
new file mode 100644
index 0000000..56bc1be
--- /dev/null
+++ b/src/components/DocumentationView/CicdTab.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/FeatureItems.tsx b/src/components/DocumentationView/FeatureItems.tsx
new file mode 100644
index 0000000..7558160
--- /dev/null
+++ b/src/components/DocumentationView/FeatureItems.tsx
@@ -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 (
+
+
{icon}
+
+
{title}
+
{description}
+
+
+ )
+}
+
+export function AIFeatureCard({ title, description }: { title: string; description: string }) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaFeaturesCard.tsx b/src/components/DocumentationView/PwaFeaturesCard.tsx
new file mode 100644
index 0000000..2f578f4
--- /dev/null
+++ b/src/components/DocumentationView/PwaFeaturesCard.tsx
@@ -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 (
+
+
+ PWA Features
+ Native app capabilities in your browser
+
+
+ {pwaData.features.map((feature) => (
+
+
+
+ {feature.title}
+
+
{feature.description}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaInstallationSection.tsx b/src/components/DocumentationView/PwaInstallationSection.tsx
new file mode 100644
index 0000000..d873755
--- /dev/null
+++ b/src/components/DocumentationView/PwaInstallationSection.tsx
@@ -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 (
+
+
+ {title}
+
+
+ {items.map((item) => (
+
+
{item.title}
+
+ {item.steps.map((step) => (
+ {step}
+ ))}
+
+
+ ))}
+
+
+ )
+}
+
+export function PwaInstallationSection() {
+ return (
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaOfflineSection.tsx b/src/components/DocumentationView/PwaOfflineSection.tsx
new file mode 100644
index 0000000..5837ff7
--- /dev/null
+++ b/src/components/DocumentationView/PwaOfflineSection.tsx
@@ -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 (
+
+ {items.map((item) => (
+
+ •
+ {item}
+
+ ))}
+
+ )
+}
+
+export function PwaOfflineSection() {
+ return (
+
+
Offline Capabilities
+
+
+
+
+
+
+ Works Offline
+
+
+
+
+
+
+
+
+
+
+
+ Requires Internet
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaOverviewSection.tsx b/src/components/DocumentationView/PwaOverviewSection.tsx
new file mode 100644
index 0000000..1e8d4d3
--- /dev/null
+++ b/src/components/DocumentationView/PwaOverviewSection.tsx
@@ -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 (
+
+
+
+
+
+
+
{pwaData.title}
+
{pwaData.subtitle}
+
+
+
+
+
+
+
Overview
+
{pwaData.overview}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaProTipsCard.tsx b/src/components/DocumentationView/PwaProTipsCard.tsx
new file mode 100644
index 0000000..b8ba7e2
--- /dev/null
+++ b/src/components/DocumentationView/PwaProTipsCard.tsx
@@ -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 (
+
+
+
+
+ Pro Tips
+
+
+
+
+ {pwaData.proTips.map((tip) => (
+
+ •
+
+ {tip.title}: {tip.description}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaSettingsCard.tsx b/src/components/DocumentationView/PwaSettingsCard.tsx
new file mode 100644
index 0000000..5b10c6b
--- /dev/null
+++ b/src/components/DocumentationView/PwaSettingsCard.tsx
@@ -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 (
+
+
PWA Settings
+
+ Navigate to the PWA tab to manage all Progressive Web App features:
+
+
+
+
+ Available Controls
+
+
+ {pwaData.settings.map((setting, index) => (
+
+
{setting.title}
+
{setting.description}
+ {index < pwaData.settings.length - 1 &&
}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/PwaTab.tsx b/src/components/DocumentationView/PwaTab.tsx
new file mode 100644
index 0000000..0ce98c8
--- /dev/null
+++ b/src/components/DocumentationView/PwaTab.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/DocumentationView/RoadmapTab.tsx b/src/components/DocumentationView/RoadmapTab.tsx
new file mode 100644
index 0000000..9e55773
--- /dev/null
+++ b/src/components/DocumentationView/RoadmapTab.tsx
@@ -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:
,
+ items: roadmapData.completed
+ },
+ {
+ key: 'planned',
+ title: 'Planned Features',
+ icon:
,
+ items: roadmapData.planned
+ }
+]
+
+export function RoadmapTab() {
+ return (
+
+
+
+
+ {roadmapData.title}
+
+
{roadmapData.subtitle}
+
+
+
+
+ {sections.map((section) => (
+
+
+ {section.icon}
+
{section.title}
+
+
+ {section.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassAnimationsCard.tsx b/src/components/DocumentationView/SassAnimationsCard.tsx
new file mode 100644
index 0000000..a83da81
--- /dev/null
+++ b/src/components/DocumentationView/SassAnimationsCard.tsx
@@ -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 (
+
+
+ Animation Classes
+ Pre-built animation utilities
+
+
+
+ {sassData.animations.map((animation) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassBestPracticesCard.tsx b/src/components/DocumentationView/SassBestPracticesCard.tsx
new file mode 100644
index 0000000..23c27e2
--- /dev/null
+++ b/src/components/DocumentationView/SassBestPracticesCard.tsx
@@ -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 (
+
+
+
+
+ Best Practices
+
+
+
+
+ {sassData.bestPractices.map((practice) => (
+
+
+ {practice}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassComponentsCard.tsx b/src/components/DocumentationView/SassComponentsCard.tsx
new file mode 100644
index 0000000..57203f0
--- /dev/null
+++ b/src/components/DocumentationView/SassComponentsCard.tsx
@@ -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 (
+
+
+ Available Components
+ Custom Material UI components built with Sass
+
+
+
+ {sassData.components.map((component) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassFileStructureCard.tsx b/src/components/DocumentationView/SassFileStructureCard.tsx
new file mode 100644
index 0000000..bd82bb7
--- /dev/null
+++ b/src/components/DocumentationView/SassFileStructureCard.tsx
@@ -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 (
+
+
+ File Structure
+
+
+ {sassData.fileStructure.map((item) => (
+
+
{item.file}
+
{item.description}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassItems.tsx b/src/components/DocumentationView/SassItems.tsx
new file mode 100644
index 0000000..7da099a
--- /dev/null
+++ b/src/components/DocumentationView/SassItems.tsx
@@ -0,0 +1,22 @@
+export function SassComponentItem({ name, classes, description }: { name: string; classes: string[]; description: string }) {
+ return (
+
+
{name}
+
{description}
+
+ {classes.map((cls) => (
+ {cls}
+ ))}
+
+
+ )
+}
+
+export function AnimationItem({ name, description }: { name: string; description: string }) {
+ return (
+
+
{name}
+
{description}
+
+ )
+}
diff --git a/src/components/DocumentationView/SassLayoutCard.tsx b/src/components/DocumentationView/SassLayoutCard.tsx
new file mode 100644
index 0000000..8ab38aa
--- /dev/null
+++ b/src/components/DocumentationView/SassLayoutCard.tsx
@@ -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 (
+
+
+ Layout Components
+ Sass-powered layout utilities
+
+
+ {sassData.layoutComponents.map((item) => (
+ }
+ title={item.title}
+ description={item.description}
+ />
+ ))}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassOverviewSection.tsx b/src/components/DocumentationView/SassOverviewSection.tsx
new file mode 100644
index 0000000..b2627b8
--- /dev/null
+++ b/src/components/DocumentationView/SassOverviewSection.tsx
@@ -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 (
+
+
+
+
+
{sassData.title}
+
{sassData.subtitle}
+
+
+
+
+
+
+
Overview
+
{sassData.overview}
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassQuickStartCard.tsx b/src/components/DocumentationView/SassQuickStartCard.tsx
new file mode 100644
index 0000000..4cd8494
--- /dev/null
+++ b/src/components/DocumentationView/SassQuickStartCard.tsx
@@ -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 (
+
+
+
+
+ Quick Start Example
+
+
+
+
+
{sassData.quickStart.components.title}
+
{sassData.quickStart.components.code}
+
+
+
+
+
+
{sassData.quickStart.mixins.title}
+
{sassData.quickStart.mixins.code}
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassTab.tsx b/src/components/DocumentationView/SassTab.tsx
new file mode 100644
index 0000000..5f0210b
--- /dev/null
+++ b/src/components/DocumentationView/SassTab.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/DocumentationView/SassUtilitiesCard.tsx b/src/components/DocumentationView/SassUtilitiesCard.tsx
new file mode 100644
index 0000000..ddf8199
--- /dev/null
+++ b/src/components/DocumentationView/SassUtilitiesCard.tsx
@@ -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 (
+
+
+ Sass Utilities & Mixins
+ Reusable functions for custom styling
+
+
+
+ {sassData.utilities.map((utility, index) => (
+
+
+
+ {utility.title}
+
+
+
{utility.mixin}
+
{utility.description}
+
{utility.snippet}
+
+ {index < sassData.utilities.length - 1 &&
}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/FaviconDesigner.tsx b/src/components/FaviconDesigner.tsx
index efc762f..994db2b 100644
--- a/src/components/FaviconDesigner.tsx
+++ b/src/components/FaviconDesigner.tsx
@@ -1,1117 +1,108 @@
-import { useState, useRef, useEffect } from 'react'
-import { useKV } from '@/hooks/use-kv'
-import { Button } from '@/components/ui/button'
-import { Card } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Slider } from '@/components/ui/slider'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import { Separator } from '@/components/ui/separator'
-import { Badge } from '@/components/ui/badge'
-import {
- Plus,
- Trash,
- Download,
- Copy,
- PencilSimple,
- Eraser,
- Gradient,
- Sparkle,
- Drop,
-} from '@phosphor-icons/react'
-import { toast } from 'sonner'
-import { BrushEffect, CanvasFilter, FaviconElement, FaviconDesign } from './FaviconDesigner/types'
-import { PRESET_SIZES, ELEMENT_TYPES, DEFAULT_DESIGN } from './FaviconDesigner/constants'
-import { drawCanvas } from './FaviconDesigner/canvasUtils'
+import { FaviconDesignerCanvas } from './FaviconDesigner/FaviconDesignerCanvas'
+import { FaviconDesignerSidebar } from './FaviconDesigner/FaviconDesignerSidebar'
+import { FaviconDesignerToolbar } from './FaviconDesigner/FaviconDesignerToolbar'
+import { useFaviconDesigner } from './FaviconDesigner/useFaviconDesigner'
export function FaviconDesigner() {
- const [designs, setDesigns] = useKV
('favicon-designs', [DEFAULT_DESIGN])
- const [activeDesignId, setActiveDesignId] = useState(DEFAULT_DESIGN.id)
- const [selectedElementId, setSelectedElementId] = useState(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('solid')
- const [gradientColor, setGradientColor] = useState('#ff00ff')
- const [glowIntensity, setGlowIntensity] = useState(10)
- const [currentPath, setCurrentPath] = useState>([])
- const canvasRef = useRef(null)
- const drawingCanvasRef = useRef(null)
-
- const safeDesigns = designs || [DEFAULT_DESIGN]
- const activeDesign = safeDesigns.find((d) => d.id === activeDesignId) || DEFAULT_DESIGN
- const selectedElement = activeDesign.elements.find((e) => e.id === selectedElementId)
-
- useEffect(() => {
- const canvas = canvasRef.current
- if (canvas) {
- drawCanvas(canvas, activeDesign)
- }
- }, [activeDesign])
-
- const handleAddElement = (type: FaviconElement['type']) => {
- const newElement: FaviconElement = {
- id: `element-${Date.now()}`,
- type,
- x: activeDesign.size / 2,
- y: activeDesign.size / 2,
- width: type === 'text' || type === 'emoji' ? 100 : 40,
- height: type === 'text' || type === 'emoji' ? 100 : 40,
- color: '#ffffff',
- rotation: 0,
- ...(type === 'text' && { text: 'A', fontSize: 32, fontWeight: 'bold' }),
- ...(type === 'emoji' && { emoji: '😀', fontSize: 40 }),
- }
-
- setDesigns((current) =>
- (current || []).map((d) =>
- d.id === activeDesignId
- ? { ...d, elements: [...d.elements, newElement], updatedAt: Date.now() }
- : d
- )
- )
- setSelectedElementId(newElement.id)
- }
-
- const handleUpdateElement = (updates: Partial) => {
- if (!selectedElementId) return
-
- setDesigns((current) =>
- (current || []).map((d) =>
- d.id === activeDesignId
- ? {
- ...d,
- elements: d.elements.map((e) => (e.id === selectedElementId ? { ...e, ...updates } : e)),
- updatedAt: Date.now(),
- }
- : d
- )
- )
- }
-
- const handleDeleteElement = (elementId: string) => {
- setDesigns((current) =>
- (current || []).map((d) =>
- d.id === activeDesignId
- ? { ...d, elements: d.elements.filter((e) => e.id !== elementId), updatedAt: Date.now() }
- : d
- )
- )
- setSelectedElementId(null)
- }
-
- const handleUpdateDesign = (updates: Partial) => {
- setDesigns((current) =>
- (current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d))
- )
- }
-
- const handleNewDesign = () => {
- const newDesign: FaviconDesign = {
- id: `design-${Date.now()}`,
- name: `Favicon ${safeDesigns.length + 1}`,
- size: 128,
- backgroundColor: '#7c3aed',
- elements: [],
- createdAt: Date.now(),
- updatedAt: Date.now(),
- }
-
- setDesigns((current) => [...(current || []), newDesign])
- setActiveDesignId(newDesign.id)
- setSelectedElementId(null)
- }
-
- const handleDuplicateDesign = () => {
- const newDesign: FaviconDesign = {
- ...activeDesign,
- id: `design-${Date.now()}`,
- name: `${activeDesign.name} (Copy)`,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- }
-
- setDesigns((current) => [...(current || []), newDesign])
- setActiveDesignId(newDesign.id)
- toast.success('Design duplicated')
- }
-
- const handleDeleteDesign = () => {
- if (safeDesigns.length === 1) {
- toast.error('Cannot delete the last design')
- return
- }
-
- const filteredDesigns = safeDesigns.filter((d) => d.id !== activeDesignId)
- setDesigns(filteredDesigns)
- setActiveDesignId(filteredDesigns[0].id)
- setSelectedElementId(null)
- toast.success('Design deleted')
- }
-
- const handleExport = (format: 'png' | 'ico' | 'svg', size?: number) => {
- const canvas = canvasRef.current
- if (!canvas) return
-
- if (format === 'png') {
- const exportSize = size || activeDesign.size
- const tempCanvas = document.createElement('canvas')
- tempCanvas.width = exportSize
- tempCanvas.height = exportSize
- const ctx = tempCanvas.getContext('2d')
- if (!ctx) return
-
- ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, exportSize, exportSize)
-
- tempCanvas.toBlob((blob) => {
- if (!blob) return
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${activeDesign.name}-${exportSize}x${exportSize}.png`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- toast.success(`Exported as ${exportSize}x${exportSize} PNG`)
- })
- } else if (format === 'ico') {
- canvas.toBlob((blob) => {
- if (!blob) return
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${activeDesign.name}.ico`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- toast.success('Exported as ICO')
- })
- } else if (format === 'svg') {
- const svg = generateSVG()
- const blob = new Blob([svg], { type: 'image/svg+xml' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${activeDesign.name}.svg`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- toast.success('Exported as SVG')
- }
- }
-
- const generateSVG = (): string => {
- const size = activeDesign.size
- let svg = ``
- svg += ` `
-
- activeDesign.elements.forEach((element) => {
- const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})`
-
- switch (element.type) {
- case 'circle':
- svg += ` `
- break
- case 'square':
- svg += ` `
- break
- case 'text':
- svg += `${element.text} `
- break
- }
- })
-
- svg += ' '
- return svg
- }
-
- const handleExportAll = () => {
- PRESET_SIZES.forEach((size) => {
- setTimeout(() => handleExport('png', size), size * 10)
- })
- toast.success('Exporting all sizes...')
- }
-
- const getCanvasCoordinates = (e: React.MouseEvent) => {
- 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) => {
- if (drawMode === 'select') return
-
- setIsDrawing(true)
- const coords = getCanvasCoordinates(e)
- setCurrentPath([coords])
- }
-
- const handleCanvasMouseMove = (e: React.MouseEvent) => {
- 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: 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 imageData = ctx.getImageData(0, 0, activeDesign.size, activeDesign.size)
-
- 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()
- }
- }
-
- 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 {
+ 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,
+ } = useFaviconDesigner()
return (
-
-
-
-
-
- New Design
-
-
-
- Duplicate
-
-
-
- Delete
-
-
-
-
{
- setDrawMode('select')
- setSelectedElementId(null)
- }}
- >
- Select
-
-
{
- setDrawMode('draw')
- setSelectedElementId(null)
- }}
- >
-
- Draw
-
-
{
- setDrawMode('erase')
- setSelectedElementId(null)
- }}
- >
-
- Erase
-
-
-
-
+
1}
+ onNewDesign={handleNewDesign}
+ onDuplicateDesign={handleDuplicateDesign}
+ onDeleteDesign={handleDeleteDesign}
+ onSelectMode={() => {
+ setDrawMode('select')
+ setSelectedElementId(null)
+ }}
+ onDrawMode={() => {
+ setDrawMode('draw')
+ setSelectedElementId(null)
+ }}
+ onEraseMode={() => {
+ setDrawMode('erase')
+ setSelectedElementId(null)
+ }}
+ />
-
-
-
-
-
-
-
-
-
- {activeDesign.size}x{activeDesign.size}
-
- {drawMode !== 'select' && (
-
- {drawMode === 'draw'
- ? `${brushEffect.charAt(0).toUpperCase() + brushEffect.slice(1)}: ${brushSize}px`
- : `Eraser: ${brushSize * 2}px`}
-
- )}
-
-
-
- {PRESET_SIZES.map((size) => (
-
handleExport('png', size)}
- title={`Export ${size}x${size}`}
- >
- {
- if (!canvas) return
- const ctx = canvas.getContext('2d')
- if (!ctx || !canvasRef.current) return
- ctx.drawImage(canvasRef.current, 0, 0, size, size)
- }}
- className="border border-border rounded"
- style={{ width: `${size / 2}px`, height: `${size / 2}px` }}
- />
- {size}px
-
- ))}
-
-
-
-
-
- handleExport('png')}>
-
- Export PNG
-
- handleExport('svg')} variant="outline">
-
- Export SVG
-
-
-
- Export All Sizes
-
-
-
-
-
-
-
- Design Name
- handleUpdateDesign({ name: e.target.value })}
- placeholder="My Favicon"
- />
-
-
-
- Select Design
-
-
-
-
-
- {safeDesigns.map((design) => (
-
- {design.name}
-
- ))}
-
-
-
-
-
- Canvas Size
- handleUpdateDesign({ size: Number(value) })}
- >
-
-
-
-
- {PRESET_SIZES.map((size) => (
-
- {size}x{size}
-
- ))}
-
-
-
-
-
-
-
- Image Filter
- handleUpdateDesign({ filter: value as CanvasFilter })}
- >
-
-
-
-
- None
- Blur
- Brightness
- Contrast
- Grayscale
- Sepia
- Invert
- Saturate
- Hue Rotate
- Pixelate
-
-
-
-
- {activeDesign.filter && activeDesign.filter !== 'none' && (
-
- Filter Intensity: {activeDesign.filterIntensity || 50}%
- handleUpdateDesign({ filterIntensity: value })}
- min={0}
- max={100}
- step={1}
- />
-
- )}
-
-
-
-
-
Add Elements
-
- {ELEMENT_TYPES.map(({ value, label, icon: Icon }) => (
- handleAddElement(value as FaviconElement['type'])}
- className="flex flex-col gap-1 h-auto py-2"
- disabled={drawMode !== 'select'}
- >
-
- {label}
-
- ))}
-
- {drawMode !== 'select' && (
-
- Switch to Select mode to add elements
-
- )}
-
-
- {drawMode !== 'select' && (
- <>
-
-
-
- {drawMode === 'draw' ? 'Brush Settings' : 'Eraser Settings'}
-
-
- {drawMode === 'draw' && (
- <>
-
-
Brush Effect
-
setBrushEffect(value as BrushEffect)}>
-
-
-
-
-
-
-
-
-
-
- Gradient
-
-
-
-
-
- Spray Paint
-
-
-
-
-
- Glow
-
-
-
-
-
-
-
-
- {brushEffect === 'gradient' && (
-
- )}
-
- {brushEffect === 'glow' && (
-
- Glow Intensity: {glowIntensity}px
- setGlowIntensity(value)}
- min={1}
- max={30}
- step={1}
- />
-
- )}
- >
- )}
-
-
-
- {drawMode === 'draw' ? 'Brush' : 'Eraser'} Size: {brushSize}px
-
- setBrushSize(value)}
- min={1}
- max={20}
- step={1}
- />
-
-
- >
- )}
-
-
-
-
-
- Elements ({activeDesign.elements.length})
-
-
-
- {activeDesign.elements.map((element) => (
-
{
- if (drawMode === 'select') {
- setSelectedElementId(element.id)
- }
- }}
- >
-
- {element.type === 'freehand' ? (
-
- ) : (
- ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && (
-
- {(() => {
- const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon
- return
- })()}
-
- )
- )}
-
{element.type}
- {element.text &&
"{element.text}" }
- {element.emoji &&
{element.emoji} }
-
-
{
- e.stopPropagation()
- handleDeleteElement(element.id)
- }}
- >
-
-
-
- ))}
- {activeDesign.elements.length === 0 && (
-
- No elements yet. Add some or start drawing!
-
- )}
-
-
-
-
- {selectedElement && drawMode === 'select' && (
- <>
-
-
-
Edit Element
-
- {selectedElement.type === 'freehand' && (
- <>
-
-
Brush Effect
-
handleUpdateElement({ brushEffect: value as BrushEffect })}
- >
-
-
-
-
-
-
-
-
-
-
- Gradient
-
-
-
-
-
- Spray Paint
-
-
-
-
-
- Glow
-
-
-
-
-
-
-
-
- {selectedElement.brushEffect === 'gradient' && (
-
- )}
-
- {selectedElement.brushEffect === 'glow' && (
-
- Glow Intensity: {selectedElement.glowIntensity || 10}px
- handleUpdateElement({ glowIntensity: value })}
- min={1}
- max={30}
- step={1}
- />
-
- )}
-
-
- Stroke Width: {selectedElement.strokeWidth || 3}px
- handleUpdateElement({ strokeWidth: value })}
- min={1}
- max={20}
- step={1}
- />
-
- >
- )}
-
- {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && (
- <>
- {selectedElement.type === 'text' && (
-
- Text
- handleUpdateElement({ text: e.target.value })}
- placeholder="Enter text"
- />
-
- )}
-
- {selectedElement.type === 'emoji' && (
-
- Emoji
- handleUpdateElement({ emoji: e.target.value })}
- placeholder="😀"
- />
-
- )}
-
-
- Font Size: {selectedElement.fontSize}px
- handleUpdateElement({ fontSize: value })}
- min={12}
- max={200}
- step={1}
- />
-
-
- {selectedElement.type === 'text' && (
-
- Font Weight
- handleUpdateElement({ fontWeight: value })}
- >
-
-
-
-
- Normal
- Bold
- Light
-
-
-
- )}
- >
- )}
-
- {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && (
- <>
-
- Width: {selectedElement.width}px
- handleUpdateElement({ width: value })}
- min={10}
- max={activeDesign.size}
- step={1}
- />
-
-
-
- Height: {selectedElement.height}px
- handleUpdateElement({ height: value })}
- min={10}
- max={activeDesign.size}
- step={1}
- />
-
- >
- )}
-
- {selectedElement.type !== 'freehand' && (
- <>
-
- X Position: {selectedElement.x}px
- handleUpdateElement({ x: value })}
- min={0}
- max={activeDesign.size}
- step={1}
- />
-
-
-
- Y Position: {selectedElement.y}px
- handleUpdateElement({ y: value })}
- min={0}
- max={activeDesign.size}
- step={1}
- />
-
-
-
- Rotation: {selectedElement.rotation}°
- handleUpdateElement({ rotation: value })}
- min={0}
- max={360}
- step={1}
- />
-
- >
- )}
-
- {selectedElement.type !== 'freehand' && (
-
- )}
-
- >
- )}
-
-
+
+
diff --git a/src/components/FaviconDesigner/BrushSettingsPanel.tsx b/src/components/FaviconDesigner/BrushSettingsPanel.tsx
new file mode 100644
index 0000000..8744023
--- /dev/null
+++ b/src/components/FaviconDesigner/BrushSettingsPanel.tsx
@@ -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) => (
+
+
+ {drawMode === 'draw' ? copy.brush.settingsTitle : copy.brush.eraserSettingsTitle}
+
+
+ {drawMode === 'draw' && (
+ <>
+
+
{copy.brush.effectLabel}
+
onBrushEffectChange(value as BrushEffect)}>
+
+
+
+
+
+
+
+ {copy.effects.solid}
+
+
+
+
+
+ {copy.effects.gradient}
+
+
+
+
+
+ {copy.effects.spray}
+
+
+
+
+
+ {copy.effects.glow}
+
+
+
+
+
+
+
+
+ {brushEffect === 'gradient' && (
+
+ )}
+
+ {brushEffect === 'glow' && (
+
+ {formatCopy(copy.brush.glowIntensity, { value: glowIntensity })}
+ onGlowIntensityChange(value)}
+ min={1}
+ max={30}
+ step={1}
+ />
+
+ )}
+ >
+ )}
+
+
+
+ {formatCopy(copy.brush.sizeLabel, {
+ mode: drawMode === 'draw' ? copy.modes.draw : copy.modes.erase,
+ size: brushSize,
+ })}
+
+ onBrushSizeChange(value)} min={1} max={20} step={1} />
+
+
+)
diff --git a/src/components/FaviconDesigner/ColorInspector.tsx b/src/components/FaviconDesigner/ColorInspector.tsx
new file mode 100644
index 0000000..152ad31
--- /dev/null
+++ b/src/components/FaviconDesigner/ColorInspector.tsx
@@ -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) => void
+}
+
+export const ColorInspector = ({ element, onUpdateElement }: ColorInspectorProps) => (
+
+)
diff --git a/src/components/FaviconDesigner/DesignSettingsPanel.tsx b/src/components/FaviconDesigner/DesignSettingsPanel.tsx
new file mode 100644
index 0000000..3f0baaf
--- /dev/null
+++ b/src/components/FaviconDesigner/DesignSettingsPanel.tsx
@@ -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) => void
+ onSelectDesign: (value: string) => void
+}
+
+export const DesignSettingsPanel = ({
+ activeDesign,
+ activeDesignId,
+ designs,
+ onUpdateDesign,
+ onSelectDesign,
+}: DesignSettingsPanelProps) => (
+
+
+ {copy.design.nameLabel}
+ onUpdateDesign({ name: e.target.value })}
+ placeholder={copy.design.namePlaceholder}
+ />
+
+
+
+ {copy.design.selectLabel}
+
+
+
+
+
+ {designs.map((design) => (
+
+ {design.name}
+
+ ))}
+
+
+
+
+
+ {copy.design.sizeLabel}
+ onUpdateDesign({ size: Number(value) })}>
+
+
+
+
+ {PRESET_SIZES.map((size) => (
+
+ {size}x{size}
+
+ ))}
+
+
+
+
+
+
+
+ {copy.design.filterLabel}
+ onUpdateDesign({ filter: value as CanvasFilter })}
+ >
+
+
+
+
+ {(Object.keys(copy.filters) as Array).map((key) => (
+
+ {copy.filters[key]}
+
+ ))}
+
+
+
+
+ {activeDesign.filter && activeDesign.filter !== 'none' && (
+
+ {formatCopy(copy.design.filterIntensity, { value: activeDesign.filterIntensity || 50 })}
+ onUpdateDesign({ filterIntensity: value })}
+ min={0}
+ max={100}
+ step={1}
+ />
+
+ )}
+
+)
diff --git a/src/components/FaviconDesigner/ElementInspectorPanel.tsx b/src/components/FaviconDesigner/ElementInspectorPanel.tsx
new file mode 100644
index 0000000..ae466a8
--- /dev/null
+++ b/src/components/FaviconDesigner/ElementInspectorPanel.tsx
@@ -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) => void
+}
+
+export const ElementInspectorPanel = ({
+ activeDesign,
+ selectedElement,
+ onUpdateElement,
+}: ElementInspectorPanelProps) => (
+
+ {copy.inspector.title}
+
+ {selectedElement.type === 'freehand' && (
+
+ )}
+
+ {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && (
+
+ )}
+
+ {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && (
+
+ )}
+
+ {selectedElement.type !== 'freehand' && (
+
+ )}
+
+ {selectedElement.type !== 'freehand' && }
+
+)
diff --git a/src/components/FaviconDesigner/ElementsPanel.tsx b/src/components/FaviconDesigner/ElementsPanel.tsx
new file mode 100644
index 0000000..ee1c5ec
--- /dev/null
+++ b/src/components/FaviconDesigner/ElementsPanel.tsx
@@ -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) => (
+
+
+
{copy.elements.addTitle}
+
+ {ELEMENT_TYPES.map(({ value, icon: Icon }) => (
+ onAddElement(value as FaviconElement['type'])}
+ className="flex flex-col gap-1 h-auto py-2"
+ disabled={drawMode !== 'select'}
+ >
+
+
+ {copy.elementTypes[value as keyof typeof copy.elementTypes]}
+
+
+ ))}
+
+ {drawMode !== 'select' &&
{copy.elements.selectHint}
}
+
+
+
+
{formatCopy(copy.elements.listTitle, { count: activeDesign.elements.length })}
+
+
+ {activeDesign.elements.map((element) => (
+
{
+ if (drawMode === 'select') {
+ onSelectElement(element.id)
+ }
+ }}
+ >
+
+ {element.type === 'freehand' ? (
+
+ ) : (
+ ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && (
+
+ {(() => {
+ const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon
+ return
+ })()}
+
+ )
+ )}
+
+ {copy.elementTypes[element.type as keyof typeof copy.elementTypes] || element.type}
+
+ {element.text &&
"{element.text}" }
+ {element.emoji &&
{element.emoji} }
+
+
{
+ event.stopPropagation()
+ onDeleteElement(element.id)
+ }}
+ >
+
+
+
+ ))}
+ {activeDesign.elements.length === 0 && (
+
{copy.elements.empty}
+ )}
+
+
+
+
+)
diff --git a/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx b/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx
new file mode 100644
index 0000000..0ba3684
--- /dev/null
+++ b/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx
@@ -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
+ drawingCanvasRef: React.RefObject
+ drawMode: 'select' | 'draw' | 'erase'
+ onExport: (format: 'png' | 'ico' | 'svg', size?: number) => void
+ onExportAll: () => void
+ onMouseDown: (event: React.MouseEvent) => void
+ onMouseMove: (event: React.MouseEvent) => void
+ onMouseUp: () => void
+ onMouseLeave: () => void
+}
+
+export const FaviconDesignerCanvas = ({
+ activeSize,
+ brushEffect,
+ brushSize,
+ canvasRef,
+ drawingCanvasRef,
+ drawMode,
+ onExport,
+ onExportAll,
+ onMouseDown,
+ onMouseMove,
+ onMouseUp,
+ onMouseLeave,
+}: FaviconDesignerCanvasProps) => (
+
+
+
+
+
+
+
+
+
+ {activeSize}x{activeSize}
+
+ {drawMode !== 'select' && (
+
+ {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 })}
+
+ )}
+
+
+
+ {PRESET_SIZES.map((size) => (
+
onExport('png', size)}
+ title={formatCopy(copy.canvas.exportPresetTitle, { size })}
+ >
+ {
+ if (!canvas) return
+ const ctx = canvas.getContext('2d')
+ if (!ctx || !canvasRef.current) return
+ ctx.drawImage(canvasRef.current, 0, 0, size, size)
+ }}
+ className="border border-border rounded"
+ style={{ width: `${size / 2}px`, height: `${size / 2}px` }}
+ />
+
+ {formatCopy(copy.canvas.presetLabel, { size })}
+
+
+ ))}
+
+
+
+
+
+ onExport('png')}>
+
+ {copy.export.png}
+
+ onExport('svg')} variant="outline">
+
+ {copy.export.svg}
+
+
+
+ {copy.export.all}
+
+
+
+)
diff --git a/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx b/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx
new file mode 100644
index 0000000..710e487
--- /dev/null
+++ b/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx
@@ -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) => void
+ onUpdateElement: (updates: Partial) => 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) => (
+
+
+
+
+
+
+
+
+ {drawMode !== 'select' && (
+ <>
+
+
+ >
+ )}
+
+ {selectedElement && drawMode === 'select' && (
+ <>
+
+
+ >
+ )}
+
+
+)
diff --git a/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx b/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx
new file mode 100644
index 0000000..cb9d14d
--- /dev/null
+++ b/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx
@@ -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) => (
+
+
+
+
+
+ {copy.toolbar.newDesign}
+
+
+
+ {copy.toolbar.duplicate}
+
+
+
+ {copy.toolbar.delete}
+
+
+
+
+ {copy.modes.select}
+
+
+
+ {copy.modes.draw}
+
+
+
+ {copy.modes.erase}
+
+
+
+
+)
diff --git a/src/components/FaviconDesigner/FreehandInspector.tsx b/src/components/FaviconDesigner/FreehandInspector.tsx
new file mode 100644
index 0000000..97845d2
--- /dev/null
+++ b/src/components/FaviconDesigner/FreehandInspector.tsx
@@ -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) => void
+}
+
+export const FreehandInspector = ({ element, onUpdateElement }: FreehandInspectorProps) => (
+ <>
+
+
{copy.brush.effectLabel}
+
onUpdateElement({ brushEffect: value as BrushEffect })}
+ >
+
+
+
+
+
+
+
+ {copy.effects.solid}
+
+
+
+
+
+ {copy.effects.gradient}
+
+
+
+
+
+ {copy.effects.spray}
+
+
+
+
+
+ {copy.effects.glow}
+
+
+
+
+
+
+
+
+ {element.brushEffect === 'gradient' && (
+
+ )}
+
+ {element.brushEffect === 'glow' && (
+
+ {formatCopy(copy.brush.glowIntensity, { value: element.glowIntensity || 10 })}
+ onUpdateElement({ glowIntensity: value })}
+ min={1}
+ max={30}
+ step={1}
+ />
+
+ )}
+
+
+ {formatCopy(copy.inspector.strokeWidth, { value: element.strokeWidth || 3 })}
+ onUpdateElement({ strokeWidth: value })}
+ min={1}
+ max={20}
+ step={1}
+ />
+
+ >
+)
diff --git a/src/components/FaviconDesigner/ShapeInspector.tsx b/src/components/FaviconDesigner/ShapeInspector.tsx
new file mode 100644
index 0000000..edb2a59
--- /dev/null
+++ b/src/components/FaviconDesigner/ShapeInspector.tsx
@@ -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) => void
+}
+
+export const ShapeInspector = ({ element, activeDesign, onUpdateElement }: ShapeInspectorProps) => (
+ <>
+
+ {formatCopy(copy.inspector.width, { value: element.width })}
+ onUpdateElement({ width: value })}
+ min={10}
+ max={activeDesign.size}
+ step={1}
+ />
+
+
+ {formatCopy(copy.inspector.height, { value: element.height })}
+ onUpdateElement({ height: value })}
+ min={10}
+ max={activeDesign.size}
+ step={1}
+ />
+
+ >
+)
diff --git a/src/components/FaviconDesigner/TextEmojiInspector.tsx b/src/components/FaviconDesigner/TextEmojiInspector.tsx
new file mode 100644
index 0000000..1fcd145
--- /dev/null
+++ b/src/components/FaviconDesigner/TextEmojiInspector.tsx
@@ -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) => void
+}
+
+export const TextEmojiInspector = ({ element, onUpdateElement }: TextEmojiInspectorProps) => (
+ <>
+ {element.type === 'text' && (
+
+ {copy.inspector.textLabel}
+ onUpdateElement({ text: event.target.value })}
+ placeholder={copy.inspector.textPlaceholder}
+ />
+
+ )}
+
+ {element.type === 'emoji' && (
+
+ {copy.inspector.emojiLabel}
+ onUpdateElement({ emoji: event.target.value })}
+ placeholder={copy.inspector.emojiPlaceholder}
+ />
+
+ )}
+
+
+ {formatCopy(copy.inspector.fontSize, { value: element.fontSize })}
+ onUpdateElement({ fontSize: value })}
+ min={12}
+ max={200}
+ step={1}
+ />
+
+
+ {element.type === 'text' && (
+
+ {copy.inspector.fontWeight}
+ onUpdateElement({ fontWeight: value })}>
+
+
+
+
+ {copy.fontWeights.normal}
+ {copy.fontWeights.bold}
+ {copy.fontWeights.lighter}
+
+
+
+ )}
+ >
+)
diff --git a/src/components/FaviconDesigner/TransformInspector.tsx b/src/components/FaviconDesigner/TransformInspector.tsx
new file mode 100644
index 0000000..292e371
--- /dev/null
+++ b/src/components/FaviconDesigner/TransformInspector.tsx
@@ -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) => void
+}
+
+export const TransformInspector = ({ element, activeDesign, onUpdateElement }: TransformInspectorProps) => (
+ <>
+
+ {formatCopy(copy.inspector.xPosition, { value: element.x })}
+ onUpdateElement({ x: value })}
+ min={0}
+ max={activeDesign.size}
+ step={1}
+ />
+
+
+ {formatCopy(copy.inspector.yPosition, { value: element.y })}
+ onUpdateElement({ y: value })}
+ min={0}
+ max={activeDesign.size}
+ step={1}
+ />
+
+
+ {formatCopy(copy.inspector.rotation, { value: element.rotation })}
+ onUpdateElement({ rotation: value })}
+ min={0}
+ max={360}
+ step={1}
+ />
+
+ >
+)
diff --git a/src/components/FaviconDesigner/constants.ts b/src/components/FaviconDesigner/constants.ts
index 70c6c78..ac1fed6 100644
--- a/src/components/FaviconDesigner/constants.ts
+++ b/src/components/FaviconDesigner/constants.ts
@@ -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 = {
+ 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(),
}
diff --git a/src/components/FaviconDesigner/formatCopy.ts b/src/components/FaviconDesigner/formatCopy.ts
new file mode 100644
index 0000000..ff1057b
--- /dev/null
+++ b/src/components/FaviconDesigner/formatCopy.ts
@@ -0,0 +1,5 @@
+export const formatCopy = (template: string, values: Record = {}) =>
+ template.replace(/\{(\w+)\}/g, (match, key: string) => {
+ const value = values[key]
+ return value === undefined ? match : String(value)
+ })
diff --git a/src/components/FaviconDesigner/useFaviconDesigner.ts b/src/components/FaviconDesigner/useFaviconDesigner.ts
new file mode 100644
index 0000000..738ad08
--- /dev/null
+++ b/src/components/FaviconDesigner/useFaviconDesigner.ts
@@ -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('favicon-designs', [DEFAULT_DESIGN])
+ const [activeDesignId, setActiveDesignId] = useState(DEFAULT_DESIGN.id)
+ const [selectedElementId, setSelectedElementId] = useState(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('solid')
+ const [gradientColor, setGradientColor] = useState('#ff00ff')
+ const [glowIntensity, setGlowIntensity] = useState(10)
+ const [currentPath, setCurrentPath] = useState>([])
+ const canvasRef = useRef(null)
+ const drawingCanvasRef = useRef(null)
+
+ const safeDesigns = designs || [DEFAULT_DESIGN]
+ const activeDesign = safeDesigns.find((d) => d.id === activeDesignId) || DEFAULT_DESIGN
+ const selectedElement = activeDesign.elements.find((e) => e.id === selectedElementId)
+
+ useEffect(() => {
+ 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) => {
+ if (!selectedElementId) return
+
+ setDesigns((current) =>
+ (current || []).map((d) =>
+ d.id === activeDesignId
+ ? {
+ ...d,
+ elements: d.elements.map((e) => (e.id === selectedElementId ? { ...e, ...updates } : e)),
+ updatedAt: Date.now(),
+ }
+ : d
+ )
+ )
+ }
+
+ const handleDeleteElement = (elementId: string) => {
+ setDesigns((current) =>
+ (current || []).map((d) =>
+ d.id === activeDesignId
+ ? { ...d, elements: d.elements.filter((e) => e.id !== elementId), updatedAt: Date.now() }
+ : d
+ )
+ )
+ setSelectedElementId(null)
+ }
+
+ const handleUpdateDesign = (updates: Partial) => {
+ setDesigns((current) =>
+ (current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d))
+ )
+ }
+
+ const handleNewDesign = () => {
+ const newDesign: FaviconDesign = {
+ id: `design-${Date.now()}`,
+ name: 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 += ` `
+
+ activeDesign.elements.forEach((element) => {
+ const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})`
+
+ switch (element.type) {
+ case 'circle':
+ svg += ` `
+ break
+ case 'square':
+ svg += ` `
+ break
+ case 'text':
+ svg += `${element.text} `
+ break
+ }
+ })
+
+ svg += ' '
+ return svg
+ }
+
+ const 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) => {
+ 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) => {
+ if (drawMode === 'select') return
+
+ setIsDrawing(true)
+ const coords = getCanvasCoordinates(e)
+ setCurrentPath([coords])
+ }
+
+ const handleCanvasMouseMove = (e: React.MouseEvent) => {
+ 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,
+ }
+}
diff --git a/src/components/FeatureIdeaCloud.tsx b/src/components/FeatureIdeaCloud.tsx
index 2b56f30..bf13871 100644
--- a/src/components/FeatureIdeaCloud.tsx
+++ b/src/components/FeatureIdeaCloud.tsx
@@ -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 & { 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('feature-ideas', SEED_IDEAS)
+ const [groups, setGroups] = useKV('feature-idea-groups', [])
+ const [savedEdges, setSavedEdges] = useKV[]>('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>('feature-idea-node-positions', {})
+
+ const [nodes, setNodes, onNodesChange] = useNodesState([])
+ const [edges, setEdges, onEdgesChange] = useEdgesState([])
+ const [selectedIdea, setSelectedIdea] = useState(null)
+ const [selectedGroup, setSelectedGroup] = useState(null)
+ const [selectedEdge, setSelectedEdge] = useState | 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[]) => {
+ const nodeConnectionMap = new Map>>()
+
+ 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[] = safeGroups.map((group) => ({
+ id: group.id,
+ type: 'groupNode',
+ position: safeNodePositions[group.id] || { x: 0, y: 0 },
+ data: group,
+ style: {
+ zIndex: -1,
+ },
+ }))
+
+ const ideaNodes: Node[] = 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
+ setSelectedIdea(customEvent.detail)
+ setEditDialogOpen(true)
+ }
+
+ const handleEditGroup = (e: Event) => {
+ const customEvent = e as CustomEvent
+ 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 = {}
+ 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[],
+ sourceNodeId: string,
+ sourceHandleId: string,
+ targetNodeId: string,
+ targetHandleId: string,
+ excludeEdgeId?: string
+ ): { filteredEdges: Edge[], 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 = {
+ 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) => {
+ setSelectedEdge(edge)
+ setEdgeDialogOpen(true)
+ }, [])
+
+ const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => {
+ 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 = {
+ 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 = {
+ 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 = {}
+ const newNodes: Node[] = 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 (
diff --git a/src/components/FeatureIdeaCloud/GroupNode.tsx b/src/components/FeatureIdeaCloud/GroupNode.tsx
new file mode 100644
index 0000000..61a1968
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/GroupNode.tsx
@@ -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
) {
+ const colorScheme = GROUP_COLORS.find(c => c.value === data.color) || GROUP_COLORS[0]
+
+ return (
+
+
+ {data.label}
+
+
{
+ e.stopPropagation()
+ dispatchEditGroup(data)
+ }}
+ >
+
+
+
+ )
+}
diff --git a/src/components/FeatureIdeaCloud/IdeaNode.tsx b/src/components/FeatureIdeaCloud/IdeaNode.tsx
new file mode 100644
index 0000000..1b1ed52
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/IdeaNode.tsx
@@ -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 & { id: string }) {
+ const [connectionCounts, setConnectionCounts] = useState>({
+ 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 (
+
+ {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 })}
+
+
+
+
+
{data.title}
+ {
+ e.stopPropagation()
+ dispatchEditIdea(data)
+ }}
+ >
+
+
+
+
+ {data.description}
+
+
+
+ {data.category}
+
+
+ {data.status}
+
+
+
+
+
+ )
+}
diff --git a/src/components/FeatureIdeaCloud/constants.ts b/src/components/FeatureIdeaCloud/constants.ts
index 238dcf1..08cc2fd 100644
--- a/src/components/FeatureIdeaCloud/constants.ts
+++ b/src/components/FeatureIdeaCloud/constants.ts
@@ -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)' },
-]
diff --git a/src/components/FeatureIdeaCloud/data/categories.json b/src/components/FeatureIdeaCloud/data/categories.json
new file mode 100644
index 0000000..569aa74
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/data/categories.json
@@ -0,0 +1,16 @@
+[
+ "AI/ML",
+ "Collaboration",
+ "Community",
+ "DevOps",
+ "Testing",
+ "Performance",
+ "Design",
+ "Database",
+ "Mobile",
+ "Accessibility",
+ "Productivity",
+ "Security",
+ "Analytics",
+ "Other"
+]
diff --git a/src/components/FeatureIdeaCloud/data/group-colors.json b/src/components/FeatureIdeaCloud/data/group-colors.json
new file mode 100644
index 0000000..4d4a322
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/data/group-colors.json
@@ -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)" }
+]
diff --git a/src/components/FeatureIdeaCloud/data/priorities.json b/src/components/FeatureIdeaCloud/data/priorities.json
new file mode 100644
index 0000000..0b1f153
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/data/priorities.json
@@ -0,0 +1,5 @@
+[
+ "low",
+ "medium",
+ "high"
+]
diff --git a/src/components/FeatureIdeaCloud/data/seed-ideas.json b/src/components/FeatureIdeaCloud/data/seed-ideas.json
new file mode 100644
index 0000000..607b2df
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/data/seed-ideas.json
@@ -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
+ }
+]
diff --git a/src/components/FeatureIdeaCloud/data/statuses.json b/src/components/FeatureIdeaCloud/data/statuses.json
new file mode 100644
index 0000000..dc6b009
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/data/statuses.json
@@ -0,0 +1,6 @@
+[
+ "idea",
+ "planned",
+ "in-progress",
+ "completed"
+]
diff --git a/src/components/FeatureIdeaCloud/dispatchConnectionCountUpdate.ts b/src/components/FeatureIdeaCloud/dispatchConnectionCountUpdate.ts
new file mode 100644
index 0000000..3d7f85c
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/dispatchConnectionCountUpdate.ts
@@ -0,0 +1,6 @@
+export function dispatchConnectionCountUpdate(nodeId: string, counts: Record) {
+ const event = new CustomEvent('updateConnectionCounts', {
+ detail: { nodeId, counts }
+ })
+ window.dispatchEvent(event)
+}
diff --git a/src/components/FeatureIdeaCloud/dispatchEditGroup.ts b/src/components/FeatureIdeaCloud/dispatchEditGroup.ts
new file mode 100644
index 0000000..3af9800
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/dispatchEditGroup.ts
@@ -0,0 +1,6 @@
+import { IdeaGroup } from './types'
+
+export function dispatchEditGroup(group: IdeaGroup) {
+ const event = new CustomEvent('editGroup', { detail: group })
+ window.dispatchEvent(event)
+}
diff --git a/src/components/FeatureIdeaCloud/dispatchEditIdea.ts b/src/components/FeatureIdeaCloud/dispatchEditIdea.ts
new file mode 100644
index 0000000..2a7bfdd
--- /dev/null
+++ b/src/components/FeatureIdeaCloud/dispatchEditIdea.ts
@@ -0,0 +1,6 @@
+import { FeatureIdea } from './types'
+
+export function dispatchEditIdea(idea: FeatureIdea) {
+ const event = new CustomEvent('editIdea', { detail: idea })
+ window.dispatchEvent(event)
+}
diff --git a/src/components/FeatureIdeaCloud/utils.tsx b/src/components/FeatureIdeaCloud/generateHandles.tsx
similarity index 69%
rename from src/components/FeatureIdeaCloud/utils.tsx
rename to src/components/FeatureIdeaCloud/generateHandles.tsx
index 1a08ee7..01411c6 100644
--- a/src/components/FeatureIdeaCloud/utils.tsx
+++ b/src/components/FeatureIdeaCloud/generateHandles.tsx
@@ -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 = (
) {
- 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)
-}
diff --git a/src/components/FeatureIdeaCloud/nodes.tsx b/src/components/FeatureIdeaCloud/nodes.tsx
index b1a7b88..7009579 100644
--- a/src/components/FeatureIdeaCloud/nodes.tsx
+++ b/src/components/FeatureIdeaCloud/nodes.tsx
@@ -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) {
const colorScheme = GROUP_COLORS.find(c => c.value === data.color) || GROUP_COLORS[0]
diff --git a/src/components/FeatureIdeaCloud/utils.ts b/src/components/FeatureIdeaCloud/utils.ts
deleted file mode 100644
index b096f6a..0000000
--- a/src/components/FeatureIdeaCloud/utils.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export function dispatchConnectionCountUpdate(nodeId: string, counts: Record) {
- 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)
-}
diff --git a/src/components/ProjectSettingsDesigner.tsx b/src/components/ProjectSettingsDesigner.tsx
index f9a4419..404bd78 100644
--- a/src/components/ProjectSettingsDesigner.tsx
+++ b/src/components/ProjectSettingsDesigner.tsx
@@ -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(null)
- const [scriptDialogOpen, setScriptDialogOpen] = useState(false)
- const [scriptKey, setScriptKey] = useState('')
- const [scriptValue, setScriptValue] = useState('')
- const [editingScriptKey, setEditingScriptKey] = useState(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 (
@@ -121,9 +54,9 @@ export function ProjectSettingsDesigner({
-
Project Settings
+
{projectSettingsCopy.header.title}
- Configure Next.js and npm settings
+ {projectSettingsCopy.header.description}
@@ -132,488 +65,66 @@ export function ProjectSettingsDesigner({
- Next.js Config
- NPM Packages
- Scripts
- Data
+ {projectSettingsCopy.tabs.nextjs}
+ {projectSettingsCopy.tabs.packages}
+ {projectSettingsCopy.tabs.scripts}
+ {projectSettingsCopy.tabs.data}
-
-
-
- Application Settings
- Basic Next.js application configuration
-
-
-
- Application Name
-
- onNextjsConfigChange((current) => ({
- ...current,
- appName: e.target.value,
- }))
- }
- placeholder="my-nextjs-app"
- />
-
-
-
-
Import Alias
-
- onNextjsConfigChange((current) => ({
- ...current,
- importAlias: e.target.value,
- }))
- }
- placeholder="@/*"
- />
-
- Used for module imports (e.g., import {'{'} Button {'}'} from "@/components")
-
-
-
-
-
-
-
- Features
- Enable or disable Next.js features
-
-
-
-
-
TypeScript
-
- Use TypeScript for type safety
-
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- typescript: checked,
- }))
- }
- />
-
-
-
-
-
ESLint
-
Code linting and formatting
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- eslint: checked,
- }))
- }
- />
-
-
-
-
-
Tailwind CSS
-
Utility-first CSS framework
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- tailwind: checked,
- }))
- }
- />
-
-
-
-
-
Use src/ Directory
-
- Organize code inside src/ folder
-
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- srcDirectory: checked,
- }))
- }
- />
-
-
-
-
-
App Router
-
- Use the new App Router (vs Pages Router)
-
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- appRouter: checked,
- }))
- }
- />
-
-
-
-
-
Turbopack (Beta)
-
- Faster incremental bundler
-
-
-
- onNextjsConfigChange((current) => ({
- ...current,
- turbopack: checked,
- }))
- }
- />
-
-
-
-
+
-
-
-
-
NPM Packages
-
- Manage project dependencies
-
-
-
-
- Add Package
-
-
-
-
- Package Manager
-
- onNpmSettingsChange((current) => ({
- ...current,
- packageManager: value,
- }))
- }
- >
-
-
-
-
- npm
- yarn
- pnpm
-
-
-
-
-
-
-
Dependencies
-
- {npmSettings.packages
- .filter((pkg) => !pkg.isDev)
- .map((pkg) => (
-
-
-
-
-
-
-
{pkg.name}
-
{pkg.version}
-
- {pkg.description && (
-
- {pkg.description}
-
- )}
-
-
- handleEditPackage(pkg)}
- >
- Edit
-
- handleDeletePackage(pkg.id)}
- >
-
-
-
-
-
-
- ))}
- {npmSettings.packages.filter((pkg) => !pkg.isDev).length === 0 && (
-
- No dependencies added yet
-
- )}
-
-
-
-
-
Dev Dependencies
-
- {npmSettings.packages
- .filter((pkg) => pkg.isDev)
- .map((pkg) => (
-
-
-
-
-
-
-
{pkg.name}
-
{pkg.version}
-
- dev
-
-
- {pkg.description && (
-
- {pkg.description}
-
- )}
-
-
- handleEditPackage(pkg)}
- >
- Edit
-
- handleDeletePackage(pkg.id)}
- >
-
-
-
-
-
-
- ))}
- {npmSettings.packages.filter((pkg) => pkg.isDev).length === 0 && (
-
- No dev dependencies added yet
-
- )}
-
-
-
-
+
-
-
-
-
NPM Scripts
-
- Define custom commands for your project
-
-
-
-
- Add Script
-
-
-
-
- {Object.entries(npmSettings.scripts).map(([key, value]) => (
-
-
-
-
-
-
- {key}
-
-
- {value}
-
-
-
- handleEditScript(key, value)}
- >
- Edit
-
- handleDeleteScript(key)}
- >
-
-
-
-
-
-
- ))}
- {Object.keys(npmSettings.scripts).length === 0 && (
-
- No scripts defined yet
-
- )}
-
-
+
-
-
-
+
-
-
-
-
- {editingPackage?.name ? 'Edit Package' : 'Add Package'}
-
- Configure npm package details
-
- {editingPackage && (
-
-
- Package Name
-
- setEditingPackage({ ...editingPackage, name: e.target.value })
- }
- placeholder="e.g., react-query, axios"
- />
-
-
- Version
-
- setEditingPackage({ ...editingPackage, version: e.target.value })
- }
- placeholder="latest, ^1.0.0, ~2.3.4"
- />
-
-
- Description (Optional)
-
- setEditingPackage({ ...editingPackage, description: e.target.value })
- }
- placeholder="What is this package for?"
- />
-
-
- Development Dependency
-
- setEditingPackage({ ...editingPackage, isDev: checked })
- }
- />
-
-
- )}
-
- setPackageDialogOpen(false)}>
- Cancel
-
- Save Package
-
-
-
+
-
-
-
- {editingScriptKey ? 'Edit Script' : 'Add Script'}
- Define a custom npm script command
-
-
-
- setScriptDialogOpen(false)}>
- Cancel
-
- Save Script
-
-
-
+
)
}
diff --git a/src/components/project-settings/DataTab.tsx b/src/components/project-settings/DataTab.tsx
new file mode 100644
index 0000000..f490f05
--- /dev/null
+++ b/src/components/project-settings/DataTab.tsx
@@ -0,0 +1,9 @@
+import { SeedDataManager } from '@/components/molecules'
+
+export function DataTab() {
+ return (
+
+ )
+}
diff --git a/src/components/project-settings/NextJsApplicationCard.tsx b/src/components/project-settings/NextJsApplicationCard.tsx
new file mode 100644
index 0000000..ac97ba6
--- /dev/null
+++ b/src/components/project-settings/NextJsApplicationCard.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/NextJsConfigTab.tsx b/src/components/project-settings/NextJsConfigTab.tsx
new file mode 100644
index 0000000..fab7665
--- /dev/null
+++ b/src/components/project-settings/NextJsConfigTab.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/NextJsFeaturesCard.tsx b/src/components/project-settings/NextJsFeaturesCard.tsx
new file mode 100644
index 0000000..0a5eaa8
--- /dev/null
+++ b/src/components/project-settings/NextJsFeaturesCard.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/PackageDialog.tsx b/src/components/project-settings/PackageDialog.tsx
new file mode 100644
index 0000000..9c88fd2
--- /dev/null
+++ b/src/components/project-settings/PackageDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/PackageListSection.tsx b/src/components/project-settings/PackageListSection.tsx
new file mode 100644
index 0000000..75a07f7
--- /dev/null
+++ b/src/components/project-settings/PackageListSection.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/PackagesTab.tsx b/src/components/project-settings/PackagesTab.tsx
new file mode 100644
index 0000000..9d78b51
--- /dev/null
+++ b/src/components/project-settings/PackagesTab.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/ScriptDialog.tsx b/src/components/project-settings/ScriptDialog.tsx
new file mode 100644
index 0000000..a91d36f
--- /dev/null
+++ b/src/components/project-settings/ScriptDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/ScriptsTab.tsx b/src/components/project-settings/ScriptsTab.tsx
new file mode 100644
index 0000000..02405aa
--- /dev/null
+++ b/src/components/project-settings/ScriptsTab.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/project-settings/useProjectSettingsActions.ts b/src/components/project-settings/useProjectSettingsActions.ts
new file mode 100644
index 0000000..de65c8d
--- /dev/null
+++ b/src/components/project-settings/useProjectSettingsActions.ts
@@ -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