diff --git a/docs/reference/AGENTS.md b/docs/reference/AGENTS.md index b47ec15..a28001f 100644 --- a/docs/reference/AGENTS.md +++ b/docs/reference/AGENTS.md @@ -212,31 +212,31 @@ Repairs multiple errors efficiently. - Validates fixes don't introduce new errors - Returns repaired files and success status -### Generators (`/src/lib/generators.ts`) +### Generators (`/src/lib/generators/*`) Code generation utilities for project export. #### Functions -##### `generateNextJSProject(appName: string, models: PrismaModel[], components: ComponentNode[], theme: ThemeConfig)` +##### `generateNextJSProject(appName: string, models: PrismaModel[], components: ComponentNode[], theme: ThemeConfig)` (`/src/lib/generators/generateNextJSProject.ts`) Creates complete Next.js file structure. -##### `generatePrismaSchema(models: PrismaModel[])` +##### `generatePrismaSchema(models: PrismaModel[])` (`/src/lib/generators/generatePrismaSchema.ts`) Converts visual models to Prisma schema syntax. -##### `generateMUITheme(theme: ThemeConfig)` +##### `generateMUITheme(theme: ThemeConfig)` (`/src/lib/generators/generateMUITheme.ts`) Exports Material UI theme configuration. -##### `generatePlaywrightTests(tests: PlaywrightTest[])` +##### `generatePlaywrightTests(tests: PlaywrightTest[])` (`/src/lib/generators/generatePlaywrightTests.ts`) Converts visual test definitions to Playwright code. -##### `generateStorybookStories(stories: StorybookStory[])` +##### `generateStorybookStories(stories: StorybookStory[])` (`/src/lib/generators/generateStorybookStories.ts`) Creates Storybook CSF3 story files. -##### `generateUnitTests(tests: UnitTest[])` +##### `generateUnitTests(tests: UnitTest[])` (`/src/lib/generators/generateUnitTests.ts`) Generates Vitest test files with React Testing Library. -##### `generateFlaskApp(config: FlaskConfig)` +##### `generateFlaskApp(config: FlaskConfig)` (`/src/lib/generators/generateFlaskApp.ts`) Creates Flask application with blueprints and routes. ## Integration Points diff --git a/src/components/DocumentationView.tsx b/src/components/DocumentationView.tsx index d852710..7fbdcc9 100644 --- a/src/components/DocumentationView.tsx +++ b/src/components/DocumentationView.tsx @@ -1,27 +1,11 @@ import { useState } from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { ScrollArea } from '@/components/ui/scroll-area' -import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' import { Input } from '@/components/ui/input' -import { - BookOpen, - MapPin, - Clock, - Code, - Database, - Tree, - PaintBrush, - Flask, - Play, - Cube, - Wrench, - Gear, - Package, - Rocket, - Target, - Lightbulb, +import { + BookOpen, + FileCode, + GitBranch, MagnifyingGlass, GitBranch, FileCode, @@ -46,34 +30,14 @@ export function DocumentationView() {
-
- - - - README + + {tabs.map((tab) => ( + + {tab.icon} + {tab.label} - - - Roadmap - - - - Agents Files - - - - PWA Guide - - - - Sass Styles Guide - - - - CI/CD Guide - - -
+ ))} +
-
-
-
- -
-
-

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:
-
    -
  1. Look for install icon (⊕) in address bar
  2. -
  3. Click "Install" or use prompt in app
  4. -
  5. App added to applications menu
  6. -
-
-
-
Safari (macOS):
-
    -
  1. Click File → Add to Dock
  2. -
  3. App appears in Dock
  4. -
-
-
-
- - - - Mobile Installation - - -
-
iOS (Safari):
-
    -
  1. Tap Share button
  2. -
  3. Select "Add to Home Screen"
  4. -
  5. Tap "Add"
  6. -
-
-
-
Android (Chrome):
-
    -
  1. Tap menu (three dots)
  2. -
  3. Select "Install app"
  4. -
  5. Confirm installation
  6. -
-
-
-
-
-
- -
-

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

- -
-
-
- ) -}`} -
-
- - - -
-

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 - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
VariableDescriptionRequired
NODE_VERSIONNode.js version (default: 20)No
REGISTRYDocker registry URL (default: ghcr.io)No
STAGING_WEBHOOK_URLWebhook for staging deploymentsOptional
PRODUCTION_WEBHOOK_URLWebhook for production deploymentsOptional
CODECOV_TOKENCodecov integration tokenOptional
SLACK_WEBHOOKSlack webhook for notificationsOptional
-
-
-
-
- -
-

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 + + +
+
+ + + + + + + + + + {cicdData.environmentVariables.map((variable) => ( + + + + + + ))} + +
VariableDescriptionRequired
+ {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) => ( +
  1. {step}
  2. + ))} +
+
+ ))} +
+
+ ) +} + +export function PwaInstallationSection() { + return ( +
+

Installation

+
+ + +
+
+ ) +} 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 (
-
-
-
- - - -
-
- - - -
-
-
+ 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 -
- ))} -
-
-
- -
- - - -
-
- - -
-
- - handleUpdateDesign({ name: e.target.value })} - placeholder="My Favicon" - /> -
- -
- - -
- -
- - -
- -
- -
- handleUpdateDesign({ backgroundColor: e.target.value })} - className="w-20 h-10" - /> - handleUpdateDesign({ backgroundColor: e.target.value })} - placeholder="#7c3aed" - /> -
-
- -
- - -
- - {activeDesign.filter && activeDesign.filter !== 'none' && ( -
- - handleUpdateDesign({ filterIntensity: value })} - min={0} - max={100} - step={1} - /> -
- )} - - - -
- -
- {ELEMENT_TYPES.map(({ value, label, icon: Icon }) => ( - - ))} -
- {drawMode !== 'select' && ( -

- Switch to Select mode to add elements -

- )} -
- - {drawMode !== 'select' && ( - <> - -
- - - {drawMode === 'draw' && ( - <> -
- - -
- -
- -
- setBrushColor(e.target.value)} - className="w-20 h-10" - /> - setBrushColor(e.target.value)} - placeholder="#ffffff" - /> -
-
- - {brushEffect === 'gradient' && ( -
- -
- setGradientColor(e.target.value)} - className="w-20 h-10" - /> - setGradientColor(e.target.value)} - placeholder="#ff00ff" - /> -
-
- )} - - {brushEffect === 'glow' && ( -
- - setGlowIntensity(value)} - min={1} - max={30} - step={1} - /> -
- )} - - )} - -
- - setBrushSize(value)} - min={1} - max={20} - step={1} - /> -
-
- - )} - - - -
- - -
- {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}} -
- -
- ))} - {activeDesign.elements.length === 0 && ( -

- No elements yet. Add some or start drawing! -

- )} -
-
-
- - {selectedElement && drawMode === 'select' && ( - <> - -
- - - {selectedElement.type === 'freehand' && ( - <> -
- - -
- -
- -
- handleUpdateElement({ color: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ color: e.target.value })} - placeholder="#ffffff" - /> -
-
- - {selectedElement.brushEffect === 'gradient' && ( -
- -
- handleUpdateElement({ gradientColor: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ gradientColor: e.target.value })} - placeholder="#ff00ff" - /> -
-
- )} - - {selectedElement.brushEffect === 'glow' && ( -
- - handleUpdateElement({ glowIntensity: value })} - min={1} - max={30} - step={1} - /> -
- )} - -
- - handleUpdateElement({ strokeWidth: value })} - min={1} - max={20} - step={1} - /> -
- - )} - - {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && ( - <> - {selectedElement.type === 'text' && ( -
- - handleUpdateElement({ text: e.target.value })} - placeholder="Enter text" - /> -
- )} - - {selectedElement.type === 'emoji' && ( -
- - handleUpdateElement({ emoji: e.target.value })} - placeholder="😀" - /> -
- )} - -
- - handleUpdateElement({ fontSize: value })} - min={12} - max={200} - step={1} - /> -
- - {selectedElement.type === 'text' && ( -
- - -
- )} - - )} - - {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && ( - <> -
- - handleUpdateElement({ width: value })} - min={10} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ height: value })} - min={10} - max={activeDesign.size} - step={1} - /> -
- - )} - - {selectedElement.type !== 'freehand' && ( - <> -
- - handleUpdateElement({ x: value })} - min={0} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ y: value })} - min={0} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ rotation: value })} - min={0} - max={360} - step={1} - /> -
- - )} - - {selectedElement.type !== 'freehand' && ( -
- -
- handleUpdateElement({ color: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ color: e.target.value })} - placeholder="#ffffff" - /> -
-
- )} -
- - )} -
-
+ +
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' && ( + <> +
+ + +
+ +
+ +
+ onBrushColorChange(event.target.value)} + className="w-20 h-10" + /> + onBrushColorChange(event.target.value)} + placeholder={copy.placeholders.color} + /> +
+
+ + {brushEffect === 'gradient' && ( +
+ +
+ onGradientColorChange(event.target.value)} + className="w-20 h-10" + /> + onGradientColorChange(event.target.value)} + placeholder={copy.placeholders.gradient} + /> +
+
+ )} + + {brushEffect === 'glow' && ( +
+ + onGlowIntensityChange(value)} + min={1} + max={30} + step={1} + /> +
+ )} + + )} + +
+ + 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) => ( +
+ +
+ onUpdateElement({ color: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ color: event.target.value })} + placeholder={copy.placeholders.color} + /> +
+
+) 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) => ( +
+
+ + onUpdateDesign({ name: e.target.value })} + placeholder={copy.design.namePlaceholder} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ onUpdateDesign({ backgroundColor: e.target.value })} + className="w-20 h-10" + /> + onUpdateDesign({ backgroundColor: e.target.value })} + placeholder={copy.design.backgroundPlaceholder} + /> +
+
+ +
+ + +
+ + {activeDesign.filter && activeDesign.filter !== 'none' && ( +
+ + 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) => ( +
+ + + {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) => ( +
+
+ +
+ {ELEMENT_TYPES.map(({ value, icon: Icon }) => ( + + ))} +
+ {drawMode !== 'select' &&

{copy.elements.selectHint}

} +
+ +
+ + +
+ {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}} +
+ +
+ ))} + {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 })} + +
+ ))} +
+
+
+ +
+ + + +
+
+) 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) => ( +
+
+
+ + + +
+
+ + + +
+
+
+) 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) => ( + <> +
+ + +
+ +
+ +
+ onUpdateElement({ color: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ color: event.target.value })} + placeholder={copy.placeholders.color} + /> +
+
+ + {element.brushEffect === 'gradient' && ( +
+ +
+ onUpdateElement({ gradientColor: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ gradientColor: event.target.value })} + placeholder={copy.placeholders.gradient} + /> +
+
+ )} + + {element.brushEffect === 'glow' && ( +
+ + onUpdateElement({ glowIntensity: value })} + min={1} + max={30} + step={1} + /> +
+ )} + +
+ + 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) => ( + <> +
+ + onUpdateElement({ width: value })} + min={10} + max={activeDesign.size} + step={1} + /> +
+
+ + 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' && ( +
+ + onUpdateElement({ text: event.target.value })} + placeholder={copy.inspector.textPlaceholder} + /> +
+ )} + + {element.type === 'emoji' && ( +
+ + onUpdateElement({ emoji: event.target.value })} + placeholder={copy.inspector.emojiPlaceholder} + /> +
+ )} + +
+ + onUpdateElement({ fontSize: value })} + min={12} + max={200} + step={1} + /> +
+ + {element.type === 'text' && ( +
+ + +
+ )} + +) 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) => ( + <> +
+ + onUpdateElement({ x: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+
+ + onUpdateElement({ y: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+
+ + 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} +
+ +
+ ) +} 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}

+ +
+

+ {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 - - -
- - - onNextjsConfigChange((current) => ({ - ...current, - appName: e.target.value, - })) - } - placeholder="my-nextjs-app" - /> -
- -
- - - 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 - - -
-
- -

- Use TypeScript for type safety -

-
- - onNextjsConfigChange((current) => ({ - ...current, - typescript: checked, - })) - } - /> -
- -
-
- -

Code linting and formatting

-
- - onNextjsConfigChange((current) => ({ - ...current, - eslint: checked, - })) - } - /> -
- -
-
- -

Utility-first CSS framework

-
- - onNextjsConfigChange((current) => ({ - ...current, - tailwind: checked, - })) - } - /> -
- -
-
- -

- Organize code inside src/ folder -

-
- - onNextjsConfigChange((current) => ({ - ...current, - srcDirectory: checked, - })) - } - /> -
- -
-
- -

- Use the new App Router (vs Pages Router) -

-
- - onNextjsConfigChange((current) => ({ - ...current, - appRouter: checked, - })) - } - /> -
- -
-
- -

- Faster incremental bundler -

-
- - onNextjsConfigChange((current) => ({ - ...current, - turbopack: checked, - })) - } - /> -
-
-
-
+
-
-
-
-

NPM Packages

-

- Manage project dependencies -

-
- -
- -
- - -
- -
-
-

Dependencies

-
- {npmSettings.packages - .filter((pkg) => !pkg.isDev) - .map((pkg) => ( - - -
-
-
- - {pkg.name} - {pkg.version} -
- {pkg.description && ( -

- {pkg.description} -

- )} -
-
- - -
-
-
-
- ))} - {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} -

- )} -
-
- - -
-
-
-
- ))} - {npmSettings.packages.filter((pkg) => pkg.isDev).length === 0 && ( - -

No dev dependencies added yet

-
- )} -
-
-
-
+
-
-
-
-

NPM Scripts

-

- Define custom commands for your project -

-
- -
- -
- {Object.entries(npmSettings.scripts).map(([key, value]) => ( - - -
-
-
- - {key} -
- - {value} - -
-
- - -
-
-
-
- ))} - {Object.keys(npmSettings.scripts).length === 0 && ( - -

No scripts defined yet

-
- )} -
-
+
-
- -
+
- - - - - {editingPackage?.name ? 'Edit Package' : 'Add Package'} - - Configure npm package details - - {editingPackage && ( -
-
- - - setEditingPackage({ ...editingPackage, name: e.target.value }) - } - placeholder="e.g., react-query, axios" - /> -
-
- - - setEditingPackage({ ...editingPackage, version: e.target.value }) - } - placeholder="latest, ^1.0.0, ~2.3.4" - /> -
-
- - - setEditingPackage({ ...editingPackage, description: e.target.value }) - } - placeholder="What is this package for?" - /> -
-
- - - setEditingPackage({ ...editingPackage, isDev: checked }) - } - /> -
-
- )} - - - - -
-
+ - - - - {editingScriptKey ? 'Edit Script' : 'Add Script'} - Define a custom npm script command - -
-
- - setScriptKey(e.target.value)} - placeholder="e.g., dev, build, test" - /> -
-
- - setScriptValue(e.target.value)} - placeholder="e.g., next dev, tsc --noEmit" - /> -
-
- - - - -
-
+
) } 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 ( + + + {application.title} + {application.description} + + +
+ + + onNextjsConfigChange((current) => ({ + ...current, + appName: e.target.value, + })) + } + placeholder={application.fields.appName.placeholder} + /> +
+ +
+ + + onNextjsConfigChange((current) => ({ + ...current, + importAlias: e.target.value, + })) + } + placeholder={application.fields.importAlias.placeholder} + /> +

+ {application.fields.importAlias.helper} +

+
+
+
+ ) +} 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 ( + + + {features.title} + {features.description} + + +
+
+ +

+ {features.items.typescript.description} +

+
+ + onNextjsConfigChange((current) => ({ + ...current, + typescript: checked, + })) + } + /> +
+ +
+
+ +

{features.items.eslint.description}

+
+ + onNextjsConfigChange((current) => ({ + ...current, + eslint: checked, + })) + } + /> +
+ +
+
+ +

+ {features.items.tailwind.description} +

+
+ + onNextjsConfigChange((current) => ({ + ...current, + tailwind: checked, + })) + } + /> +
+ +
+
+ +

+ {features.items.srcDirectory.description} +

+
+ + onNextjsConfigChange((current) => ({ + ...current, + srcDirectory: checked, + })) + } + /> +
+ +
+
+ +

+ {features.items.appRouter.description} +

+
+ + onNextjsConfigChange((current) => ({ + ...current, + appRouter: checked, + })) + } + /> +
+ +
+
+ +

+ {features.items.turbopack.description} +

+
+ + onNextjsConfigChange((current) => ({ + ...current, + turbopack: checked, + })) + } + /> +
+
+
+ ) +} 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 ( + + + + {isEditing ? copy.title.edit : copy.title.add} + {copy.description} + + {editingPackage && ( +
+
+ + + setEditingPackage({ ...editingPackage, name: e.target.value }) + } + placeholder={copy.fields.name.placeholder} + /> +
+
+ + + setEditingPackage({ ...editingPackage, version: e.target.value }) + } + placeholder={copy.fields.version.placeholder} + /> +
+
+ + + setEditingPackage({ ...editingPackage, description: e.target.value }) + } + placeholder={copy.fields.description.placeholder} + /> +
+
+ + + setEditingPackage({ ...editingPackage, isDev: checked }) + } + /> +
+
+ )} + + + + +
+
+ ) +} 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 ( +
+

{title}

+
+ {packages.map((pkg) => ( + + +
+
+
+ + {pkg.name} + {pkg.version} + {showDevBadge && ( + + dev + + )} +
+ {pkg.description && ( +

{pkg.description}

+ )} +
+
+ + +
+
+
+
+ ))} + {packages.length === 0 && ( + +

{emptyCopy}

+
+ )} +
+
+ ) +} 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 ( +
+
+
+

{copy.title}

+

{copy.description}

+
+ +
+ +
+ + +
+ +
+ + +
+
+ ) +} 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 ( + + + + {editingScriptKey ? copy.title.edit : copy.title.add} + {copy.description} + +
+
+ + setScriptKey(e.target.value)} + placeholder={copy.fields.name.placeholder} + /> +
+
+ + setScriptValue(e.target.value)} + placeholder={copy.fields.command.placeholder} + /> +
+
+ + + + +
+
+ ) +} 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 ( +
+
+
+

{copy.title}

+

{copy.description}

+
+ +
+ +
+ {scripts.map(([key, value]) => ( + + +
+
+
+ + {key} +
+ {value} +
+
+ + +
+
+
+
+ ))} + {scripts.length === 0 && ( + +

{copy.empty}

+
+ )} +
+
+ ) +} 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(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 } + } + return { ...current, packages: [...current.packages, editingPackage] } + }) + + setPackageDialogOpen(false) + setEditingPackage(null) + } + + const handleDeletePackage = (packageId: string) => { + onNpmSettingsChange((current) => ({ + ...current, + packages: current.packages.filter((p) => p.id !== packageId), + })) + } + + const handleAddScript = () => { + setScriptKey('') + setScriptValue('') + setEditingScriptKey(null) + setScriptDialogOpen(true) + } + + const handleEditScript = (key: string, value: string) => { + setScriptKey(key) + setScriptValue(value) + setEditingScriptKey(key) + setScriptDialogOpen(true) + } + + const handleSaveScript = () => { + if (!scriptKey || !scriptValue) return + + onNpmSettingsChange((current) => { + const scripts = { ...current.scripts } + if (editingScriptKey && editingScriptKey !== scriptKey) { + delete scripts[editingScriptKey] + } + scripts[scriptKey] = scriptValue + return { ...current, scripts } + }) + + setScriptDialogOpen(false) + setScriptKey('') + setScriptValue('') + setEditingScriptKey(null) + } + + const handleDeleteScript = (key: string) => { + onNpmSettingsChange((current) => { + const scripts = { ...current.scripts } + delete scripts[key] + return { ...current, scripts } + }) + } + + return { + packageDialogOpen, + setPackageDialogOpen, + editingPackage, + setEditingPackage, + scriptDialogOpen, + setScriptDialogOpen, + scriptKey, + setScriptKey, + scriptValue, + setScriptValue, + editingScriptKey, + setEditingScriptKey, + handleAddPackage, + handleEditPackage, + handleSavePackage, + handleDeletePackage, + handleAddScript, + handleEditScript, + handleSaveScript, + handleDeleteScript, + } +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx deleted file mode 100644 index 9d3c184..0000000 --- a/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,722 +0,0 @@ -"use client" - -import { CSSProperties, ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react" -import { Slot } from "@radix-ui/react-slot" -import { VariantProps, cva } from "class-variance-authority" -import PanelLeftIcon from "lucide-react/dist/esm/icons/panel-left" - -import { useIsMobile } from "@/hooks/use-mobile" -import { useThemeConfig } from "@/hooks/use-theme-config" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" - -const SIDEBAR_COOKIE_NAME = "sidebar_state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_KEYBOARD_SHORTCUT = "b" - -type SidebarContextProps = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = createContext(null) - -function useSidebar() { - const context = useContext(SidebarContext) - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") - } - - return context -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void -}) { - const isMobile = useIsMobile() - const { themeConfig } = useThemeConfig() - const [openMobile, setOpenMobile] = useState(false) - - const sidebarWidth = themeConfig.sidebar?.width || '16rem' - const sidebarWidthIcon = themeConfig.sidebar?.widthIcon || '3rem' - - const [_open, _setOpen] = useState(defaultOpen) - const open = openProp ?? _open - const setOpen = useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value - if (setOpenProp) { - setOpenProp(openState) - } else { - _setOpen(openState) - } - - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - }, - [setOpenProp, open] - ) - - const toggleSidebar = useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) - - const state = open ? "expanded" : "collapsed" - - const contextValue = useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) - - return ( - - -
- {children} -
-
-
- ) -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() - const { themeConfig } = useThemeConfig() - - const sidebarWidthMobile = themeConfig.sidebar?.widthMobile || '18rem' - - if (collapsible === "none") { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - - return ( -
-
- -
- ) -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: ComponentProps) { - const { toggleSidebar } = useSidebar() - - return ( - - ) -} - -function SidebarRail({ className, ...props }: ComponentProps<"button">) { - const { toggleSidebar } = useSidebar() - - return ( - + ) +} + +export { SidebarTrigger } diff --git a/src/components/ui/sidebar/sidebar.tsx b/src/components/ui/sidebar/sidebar.tsx new file mode 100644 index 0000000..09dac72 --- /dev/null +++ b/src/components/ui/sidebar/sidebar.tsx @@ -0,0 +1,114 @@ +"use client" + +import { CSSProperties, ComponentProps } from "react" + +import sidebarConfig from "@/data/sidebar-config.json" +import { useThemeConfig } from "@/hooks/use-theme-config" +import { cn } from "@/lib/utils" +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { useSidebar } from "@/components/ui/sidebar/use-sidebar" + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { themeConfig } = useThemeConfig() + + const sidebarWidthMobile = themeConfig.sidebar?.widthMobile || "18rem" + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + {sidebarConfig.labels.sidebarTitle} + {sidebarConfig.labels.sidebarDescription} + +
{children}
+
+
+ ) + } + + return ( +
+
+ +
+ ) +} + +export { Sidebar } diff --git a/src/components/ui/sidebar/use-sidebar.ts b/src/components/ui/sidebar/use-sidebar.ts new file mode 100644 index 0000000..334896f --- /dev/null +++ b/src/components/ui/sidebar/use-sidebar.ts @@ -0,0 +1,14 @@ +import { useContext } from "react" + +import { SidebarContext } from "@/components/ui/sidebar/sidebar-context" + +function useSidebar() { + const context = useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +export { useSidebar } diff --git a/src/data/documentation/agents-data.json b/src/data/documentation/agents-data.json index d3e0585..2593bf9 100644 --- a/src/data/documentation/agents-data.json +++ b/src/data/documentation/agents-data.json @@ -1,4 +1,6 @@ { + "title": "Agents Files", + "subtitle": "AI agent configuration and service architecture", "overview": "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.", "coreServices": [ { @@ -30,18 +32,81 @@ ] }, { - "filename": "generators.ts", - "path": "/src/lib/generators.ts", - "description": "Code generation utilities for project export", + "filename": "generateNextJSProject.ts", + "path": "/src/lib/generators/generateNextJSProject.ts", + "description": "Generates Next.js project scaffolding", "features": [ "Next.js project structure generation", - "Prisma schema file generation", - "Material UI theme configuration", - "Playwright test file generation", - "Storybook story file generation", - "Unit test file generation", - "Flask application structure", - "Package.json configuration" + "Package.json defaults and scripts", + "Prisma schema wiring for data models", + "Material UI theme bootstrap", + "README and environment defaults" + ] + }, + { + "filename": "generatePrismaSchema.ts", + "path": "/src/lib/generators/generatePrismaSchema.ts", + "description": "Creates Prisma schema files from models", + "features": [ + "Model definition generation", + "Field-level attributes and defaults", + "Datasource and client config", + "Array and optional field handling" + ] + }, + { + "filename": "generateMUITheme.ts", + "path": "/src/lib/generators/generateMUITheme.ts", + "description": "Builds Material UI theme configuration", + "features": [ + "Light and dark palette generation", + "Typography and spacing configuration", + "Shape/border radius settings", + "Theme export defaults" + ] + }, + { + "filename": "generatePlaywrightTests.ts", + "path": "/src/lib/generators/generatePlaywrightTests.ts", + "description": "Generates Playwright test suites", + "features": [ + "Test suite scaffolding", + "Step-by-step action scripts", + "Assertion generation", + "Default test fallback" + ] + }, + { + "filename": "generateStorybookStories.ts", + "path": "/src/lib/generators/generateStorybookStories.ts", + "description": "Creates Storybook story files", + "features": [ + "Stories grouped by component", + "Meta configuration generation", + "Args mapping for variants", + "Story file structure" + ] + }, + { + "filename": "generateUnitTests.ts", + "path": "/src/lib/generators/generateUnitTests.ts", + "description": "Generates unit test files", + "features": [ + "Component, hook, and module tests", + "Test setup and teardown blocks", + "Assertion generation", + "Dynamic test naming" + ] + }, + { + "filename": "generateFlaskApp.ts", + "path": "/src/lib/generators/generateFlaskApp.ts", + "description": "Creates Flask backend scaffolding", + "features": [ + "App factory generation", + "Blueprint registration", + "Environment and requirements files", + "README and setup instructions" ] } ], diff --git a/src/data/documentation/cicd-data.json b/src/data/documentation/cicd-data.json index 3163b42..40820dd 100644 --- a/src/data/documentation/cicd-data.json +++ b/src/data/documentation/cicd-data.json @@ -53,62 +53,66 @@ ] } ], - "pipelineStages": [ - { - "stage": "Lint", - "description": "ESLint and TypeScript type checking to ensure code quality", - "duration": "~1-2 min" - }, - { - "stage": "Test", - "description": "Unit tests with coverage reporting and result artifacts", - "duration": "~2-3 min" - }, - { - "stage": "Build", - "description": "Production build with artifact generation for deployment", - "duration": "~2-4 min" - }, - { - "stage": "E2E Tests", - "description": "Playwright end-to-end tests with HTML reports", - "duration": "~3-5 min" - }, - { - "stage": "Security", - "description": "npm audit and Trivy vulnerability scanning", - "duration": "~1-2 min" - }, - { - "stage": "Docker Build", - "description": "Multi-stage Docker image build and push to registry", - "duration": "~3-5 min" - }, - { - "stage": "Deploy", - "description": "Automated deployment to staging and production environments", - "duration": "~1-3 min" - } - ], - "dockerConfig": { + "pipeline": { + "intro": "All CI/CD configurations follow a similar multi-stage pipeline structure:", + "stages": [ + { + "stage": "Lint", + "description": "ESLint and TypeScript type checking to ensure code quality", + "duration": "~1-2 min" + }, + { + "stage": "Test", + "description": "Unit tests with coverage reporting and result artifacts", + "duration": "~2-3 min" + }, + { + "stage": "Build", + "description": "Production build with artifact generation for deployment", + "duration": "~2-4 min" + }, + { + "stage": "E2E Tests", + "description": "Playwright end-to-end tests with HTML reports", + "duration": "~3-5 min" + }, + { + "stage": "Security", + "description": "npm audit and Trivy vulnerability scanning", + "duration": "~1-2 min" + }, + { + "stage": "Docker Build", + "description": "Multi-stage Docker image build and push to registry", + "duration": "~3-5 min" + }, + { + "stage": "Deploy", + "description": "Automated deployment to staging and production environments", + "duration": "~1-3 min" + } + ] + }, + "docker": { "files": [ { - "file": "Dockerfile", + "name": "Dockerfile", "description": "Multi-stage build with Node.js builder and Nginx runtime" }, { - "file": "nginx.conf", + "name": "nginx.conf", "description": "Production Nginx configuration with health checks and caching" }, { - "file": "docker-compose.yml", + "name": "docker-compose.yml", "description": "Local development and deployment orchestration" }, { - "file": ".dockerignore", + "name": ".dockerignore", "description": "Optimized build context by excluding unnecessary files" } ], + "commands": "# Build image locally\ndocker build -t codeforge:local .\n\n# Run container\ndocker run -p 3000:80 codeforge:local\n\n# Use docker-compose\ndocker-compose up -d\n\n# Pull from registry\ndocker pull ghcr.io//:latest", "features": [ "Multi-stage build reduces final image size to ~50MB", "Nginx serves static files with gzip compression", @@ -121,54 +125,54 @@ { "variable": "NODE_VERSION", "description": "Node.js version (default: 20)", - "required": false + "required": "No" }, { "variable": "REGISTRY", "description": "Docker registry URL (default: ghcr.io)", - "required": false + "required": "No" }, { "variable": "STAGING_WEBHOOK_URL", "description": "Webhook for staging deployments", - "required": false + "required": "Optional" }, { "variable": "PRODUCTION_WEBHOOK_URL", "description": "Webhook for production deployments", - "required": false + "required": "Optional" }, { "variable": "CODECOV_TOKEN", "description": "Codecov integration token", - "required": false + "required": "Optional" }, { "variable": "SLACK_WEBHOOK", "description": "Slack webhook for notifications", - "required": false + "required": "Optional" } ], - "branchStrategy": [ + "branches": [ { - "branch": "main", + "name": "main", "description": "Production branch - deploys to production environment (manual approval required)", - "color": "green" + "tone": "green" }, { - "branch": "develop", + "name": "develop", "description": "Development branch - automatically deploys to staging environment", - "color": "blue" + "tone": "blue" }, { - "branch": "feature/*", + "name": "feature/*", "description": "Feature branches - runs tests only, no deployment", - "color": "purple" + "tone": "purple" }, { - "branch": "v* tags", + "name": "v* tags", "description": "Version tags - triggers release workflow with artifacts and changelog", - "color": "orange" + "tone": "orange" } ], "quickStart": [ @@ -203,25 +207,25 @@ "Use semantic versioning for releases (v1.0.0, v1.1.0, etc.)", "Configure Slack or email notifications for deployment status" ], - "additionalResources": [ + "resources": [ { - "file": "CI_CD_GUIDE.md", + "label": "CI_CD_GUIDE.md", "description": "Detailed setup guide for all platforms" }, { - "file": ".github/workflows/", + "label": ".github/workflows/", "description": "GitHub Actions workflows" }, { - "file": ".gitlab-ci.yml", + "label": ".gitlab-ci.yml", "description": "GitLab CI configuration" }, { - "file": "Jenkinsfile", + "label": "Jenkinsfile", "description": "Jenkins pipeline definition" }, { - "file": ".circleci/config.yml", + "label": ".circleci/config.yml", "description": "CircleCI configuration" } ] diff --git a/src/data/documentation/pwa-data.json b/src/data/documentation/pwa-data.json index 3404f74..d599c31 100644 --- a/src/data/documentation/pwa-data.json +++ b/src/data/documentation/pwa-data.json @@ -31,7 +31,7 @@ "installation": { "desktop": [ { - "platform": "Chrome/Edge/Brave", + "title": "Chrome/Edge/Brave:", "steps": [ "Look for install icon (⊕) in address bar", "Click \"Install\" or use prompt in app", @@ -39,7 +39,7 @@ ] }, { - "platform": "Safari (macOS)", + "title": "Safari (macOS):", "steps": [ "Click File → Add to Dock", "App appears in Dock" @@ -48,15 +48,15 @@ ], "mobile": [ { - "platform": "iOS (Safari)", + "title": "iOS (Safari):", "steps": [ "Tap Share button", "Select \"Add to Home Screen\"", - "Confirm installation" + "Tap \"Add\"" ] }, { - "platform": "Android (Chrome)", + "title": "Android (Chrome):", "steps": [ "Tap menu (three dots)", "Select \"Install app\"", @@ -87,7 +87,7 @@ "description": "Install pending updates when new versions are available" } ], - "offlineCapabilities": { + "offline": { "worksOffline": [ "View and edit existing projects", "Browse files and code", diff --git a/src/data/documentation/roadmap-data.json b/src/data/documentation/roadmap-data.json index e212c02..18b2bdd 100644 --- a/src/data/documentation/roadmap-data.json +++ b/src/data/documentation/roadmap-data.json @@ -1,4 +1,6 @@ { + "title": "Product Roadmap", + "subtitle": "Features delivered and planned for CodeForge development", "completed": [ { "title": "Monaco Code Editor Integration", diff --git a/src/data/documentation/sass-data.json b/src/data/documentation/sass-data.json index 89e9ad3..60781e3 100644 --- a/src/data/documentation/sass-data.json +++ b/src/data/documentation/sass-data.json @@ -27,7 +27,13 @@ "components": [ { "name": "Buttons", - "classes": ["mui-custom-button--primary", "mui-custom-button--secondary", "mui-custom-button--accent", "mui-custom-button--outline", "mui-custom-button--ghost"], + "classes": [ + "mui-custom-button--primary", + "mui-custom-button--secondary", + "mui-custom-button--accent", + "mui-custom-button--outline", + "mui-custom-button--ghost" + ], "description": "Custom styled buttons with hover effects and variants" }, { @@ -78,56 +84,62 @@ ], "layoutComponents": [ { - "name": "custom-mui-container", + "title": "custom-mui-container", "description": "Max-width container with responsive padding" }, { - "name": "custom-mui-grid", + "title": "custom-mui-grid", "description": "CSS Grid layouts with responsive columns (--cols-1 to --cols-12, --responsive)" }, { - "name": "custom-mui-flex", + "title": "custom-mui-flex", "description": "Flexbox utilities (--row, --col, --wrap, --center, --between, --around)" }, { - "name": "custom-mui-stack", + "title": "custom-mui-stack", "description": "Vertical/horizontal stacks with configurable gaps" }, { - "name": "custom-mui-surface", + "title": "custom-mui-surface", "description": "Interactive surfaces with elevation and hover effects" } ], "utilities": [ { - "name": "@include respond-to($breakpoint)", + "title": "Responsive Design", + "mixin": "@include respond-to($breakpoint)", "description": "Generate media queries for xs, sm, md, lg, xl, 2xl breakpoints", - "example": "@include respond-to('lg') {\n padding: 2rem;\n}" + "snippet": "@include respond-to('lg') {\n padding: 2rem;\n}" }, { - "name": "@include elevation($level)", + "title": "Elevation & Shadows", + "mixin": "@include elevation($level)", "description": "Apply box shadows with levels 1-4", - "example": "@include elevation(2);" + "snippet": "@include elevation(2);" }, { - "name": "@include glassmorphism($blur, $opacity)", + "title": "Glassmorphism", + "mixin": "@include glassmorphism($blur, $opacity)", "description": "Create frosted glass effects with backdrop blur", - "example": "@include glassmorphism(16px, 0.1);" + "snippet": "@include glassmorphism(16px, 0.1);" }, { - "name": "get-color($palette, $shade)", + "title": "Color Functions", + "mixin": "get-color($palette, $shade)", "description": "Access colors from predefined palettes (primary, secondary, accent, success, error, warning)", - "example": "color: get-color('primary', 500);" + "snippet": "color: get-color('primary', 500);" }, { - "name": "@include truncate($lines)", + "title": "Text Truncation", + "mixin": "@include truncate($lines)", "description": "Truncate text with ellipsis after specified lines", - "example": "@include truncate(2);" + "snippet": "@include truncate(2);" }, { - "name": "@include show-scrollbar($track, $thumb)", + "title": "Custom Scrollbars", + "mixin": "@include show-scrollbar($track, $thumb)", "description": "Style webkit scrollbars with custom colors", - "example": "@include show-scrollbar(rgba(0,0,0,0.1), rgba(0,0,0,0.3));" + "snippet": "@include show-scrollbar(rgba(0,0,0,0.1), rgba(0,0,0,0.3));" } ], "animations": [ @@ -142,6 +154,16 @@ { "name": "animate-float", "description": "Floating up and down" }, { "name": "animate-glow", "description": "Glowing shadow effect" } ], + "quickStart": { + "components": { + "title": "Using Custom Components", + "code": "import './styles/main.scss'\n\nfunction MyComponent() {\n return (\n
\n
\n
\n

Card Title

\n

Card content

\n \n
\n
\n
\n )\n}" + }, + "mixins": { + "title": "Creating Custom Styles with Mixins", + "code": "@use './styles/utilities' as *;\n@use './styles/variables' as *;\n\n.my-custom-component {\n @include elevation(2);\n @include responsive-padding(spacing('6'));\n background: get-color('primary', 500);\n \n @include respond-to('md') {\n @include elevation(3);\n }\n \n &:hover {\n @include glassmorphism(12px, 0.15);\n }\n}" + } + }, "bestPractices": [ "Import main.scss in your index.css to access all Sass components and utilities", "Use @use instead of @import for better module encapsulation", diff --git a/src/data/favicon-designer-presets.json b/src/data/favicon-designer-presets.json new file mode 100644 index 0000000..76c4fe4 --- /dev/null +++ b/src/data/favicon-designer-presets.json @@ -0,0 +1,36 @@ +{ + "presetSizes": [16, 32, 48, 64, 128, 256, 512], + "elementTypes": [ + { "value": "circle", "label": "Circle" }, + { "value": "square", "label": "Square" }, + { "value": "triangle", "label": "Triangle" }, + { "value": "star", "label": "Star" }, + { "value": "heart", "label": "Heart" }, + { "value": "polygon", "label": "Polygon" }, + { "value": "text", "label": "Text" }, + { "value": "emoji", "label": "Emoji" } + ], + "defaultDesign": { + "id": "default", + "name": "My Favicon", + "size": 128, + "backgroundColor": "#7c3aed", + "elements": [ + { + "id": "1", + "type": "text", + "x": 64, + "y": 64, + "width": 100, + "height": 100, + "color": "#ffffff", + "rotation": 0, + "text": "CF", + "fontSize": 48, + "fontWeight": "bold" + } + ], + "createdAt": 0, + "updatedAt": 0 + } +} diff --git a/src/data/favicon-designer.json b/src/data/favicon-designer.json new file mode 100644 index 0000000..7d310a7 --- /dev/null +++ b/src/data/favicon-designer.json @@ -0,0 +1,120 @@ +{ + "toolbar": { + "newDesign": "New Design", + "duplicate": "Duplicate", + "delete": "Delete" + }, + "modes": { + "select": "Select", + "draw": "Draw", + "erase": "Erase" + }, + "toasts": { + "designDuplicated": "Design duplicated", + "cannotDeleteLast": "Cannot delete the last design", + "designDeleted": "Design deleted", + "exportedPng": "Exported as {size}x{size} PNG", + "exportedIco": "Exported as ICO", + "exportedSvg": "Exported as SVG", + "exportAll": "Exporting all sizes..." + }, + "canvas": { + "exportPresetTitle": "Export {size}x{size}", + "brushBadge": "{effect}: {size}px", + "eraserBadge": "Eraser: {size}px", + "presetLabel": "{size}px" + }, + "export": { + "png": "Export PNG", + "svg": "Export SVG", + "all": "Export All Sizes" + }, + "design": { + "nameLabel": "Design Name", + "namePlaceholder": "My Favicon", + "newDesignName": "Favicon {count}", + "duplicateSuffix": " (Copy)", + "selectLabel": "Select Design", + "sizeLabel": "Canvas Size", + "backgroundLabel": "Background Color", + "backgroundPlaceholder": "#7c3aed", + "filterLabel": "Image Filter", + "filterIntensity": "Filter Intensity: {value}%" + }, + "filters": { + "none": "None", + "blur": "Blur", + "brightness": "Brightness", + "contrast": "Contrast", + "grayscale": "Grayscale", + "sepia": "Sepia", + "invert": "Invert", + "saturate": "Saturate", + "hue-rotate": "Hue Rotate", + "pixelate": "Pixelate" + }, + "elements": { + "addTitle": "Add Elements", + "selectHint": "Switch to Select mode to add elements", + "listTitle": "Elements ({count})", + "empty": "No elements yet. Add some or start drawing!" + }, + "brush": { + "settingsTitle": "Brush Settings", + "eraserSettingsTitle": "Eraser Settings", + "effectLabel": "Brush Effect", + "colorLabel": "Brush Color", + "gradientColorLabel": "Gradient End Color", + "glowIntensity": "Glow Intensity: {value}px", + "sizeLabel": "{mode} Size: {size}px" + }, + "inspector": { + "title": "Edit Element", + "strokeColor": "Stroke Color", + "strokeWidth": "Stroke Width: {value}px", + "textLabel": "Text", + "textPlaceholder": "Enter text", + "emojiLabel": "Emoji", + "emojiPlaceholder": "😀", + "fontSize": "Font Size: {value}px", + "fontWeight": "Font Weight", + "width": "Width: {value}px", + "height": "Height: {value}px", + "xPosition": "X Position: {value}px", + "yPosition": "Y Position: {value}px", + "rotation": "Rotation: {value}°", + "color": "Color" + }, + "effects": { + "solid": "Solid", + "gradient": "Gradient", + "spray": "Spray Paint", + "glow": "Glow" + }, + "fontWeights": { + "normal": "Normal", + "bold": "Bold", + "lighter": "Light" + }, + "elementTypes": { + "circle": "Circle", + "square": "Square", + "triangle": "Triangle", + "star": "Star", + "heart": "Heart", + "polygon": "Polygon", + "text": "Text", + "emoji": "Emoji", + "freehand": "Freehand" + }, + "defaults": { + "designName": "My Favicon", + "designText": "CF", + "newText": "A", + "newEmoji": "😀" + }, + "placeholders": { + "color": "#ffffff", + "gradient": "#ff00ff" + } +} diff --git a/src/data/project-settings.json b/src/data/project-settings.json new file mode 100644 index 0000000..c68aa0c --- /dev/null +++ b/src/data/project-settings.json @@ -0,0 +1,120 @@ +{ + "header": { + "title": "Project Settings", + "description": "Configure Next.js and npm settings" + }, + "tabs": { + "nextjs": "Next.js Config", + "packages": "NPM Packages", + "scripts": "Scripts", + "data": "Data" + }, + "nextjs": { + "application": { + "title": "Application Settings", + "description": "Basic Next.js application configuration", + "fields": { + "appName": { + "label": "Application Name", + "placeholder": "my-nextjs-app" + }, + "importAlias": { + "label": "Import Alias", + "placeholder": "@/*", + "helper": "Used for module imports (e.g., import { Button } from \"@/components\")" + } + } + }, + "features": { + "title": "Features", + "description": "Enable or disable Next.js features", + "items": { + "typescript": { + "label": "TypeScript", + "description": "Use TypeScript for type safety" + }, + "eslint": { + "label": "ESLint", + "description": "Code linting and formatting" + }, + "tailwind": { + "label": "Tailwind CSS", + "description": "Utility-first CSS framework" + }, + "srcDirectory": { + "label": "Use src/ Directory", + "description": "Organize code inside src/ folder" + }, + "appRouter": { + "label": "App Router", + "description": "Use the new App Router (vs Pages Router)" + }, + "turbopack": { + "label": "Turbopack (Beta)", + "description": "Faster incremental bundler" + } + } + } + }, + "packages": { + "title": "NPM Packages", + "description": "Manage project dependencies", + "packageManager": { + "label": "Package Manager" + }, + "dependencies": { + "title": "Dependencies", + "empty": "No dependencies added yet" + }, + "devDependencies": { + "title": "Dev Dependencies", + "empty": "No dev dependencies added yet" + }, + "dialog": { + "title": { + "add": "Add Package", + "edit": "Edit Package" + }, + "description": "Configure npm package details", + "fields": { + "name": { + "label": "Package Name", + "placeholder": "e.g., react-query, axios" + }, + "version": { + "label": "Version", + "placeholder": "latest, ^1.0.0, ~2.3.4" + }, + "description": { + "label": "Description (Optional)", + "placeholder": "What is this package for?" + }, + "devDependency": { + "label": "Development Dependency" + } + } + } + }, + "scripts": { + "title": "NPM Scripts", + "description": "Define custom commands for your project", + "empty": "No scripts defined yet", + "dialog": { + "title": { + "add": "Add Script", + "edit": "Edit Script" + }, + "description": "Define a custom npm script command", + "fields": { + "name": { + "label": "Script Name", + "placeholder": "e.g., dev, build, test" + }, + "command": { + "label": "Command", + "placeholder": "e.g., next dev, tsc --noEmit" + } + } + } + } +} diff --git a/src/data/sidebar-config.json b/src/data/sidebar-config.json new file mode 100644 index 0000000..3a3e150 --- /dev/null +++ b/src/data/sidebar-config.json @@ -0,0 +1,12 @@ +{ + "cookie": { + "name": "sidebar_state", + "maxAgeSeconds": 604800 + }, + "keyboardShortcut": "b", + "labels": { + "toggleSidebar": "Toggle Sidebar", + "sidebarTitle": "Sidebar", + "sidebarDescription": "Displays the mobile sidebar." + } +} diff --git a/src/lib/generators.ts b/src/lib/generators.ts deleted file mode 100644 index 50c52e6..0000000 --- a/src/lib/generators.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, FlaskBlueprint, FlaskEndpoint } from '@/types/project' - -export function generatePrismaSchema(models: PrismaModel[]): string { - let schema = `generator client {\n provider = "prisma-client-js"\n}\n\n` - schema += `datasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n` - - models.forEach((model) => { - schema += `model ${model.name} {\n` - model.fields.forEach((field) => { - let fieldLine = ` ${field.name} ${field.type}` - if (field.isArray) fieldLine += '[]' - if (field.isRequired && !field.defaultValue) fieldLine += '' - else if (!field.isRequired) fieldLine += '?' - if (field.isUnique) fieldLine += ' @unique' - if (field.defaultValue) fieldLine += ` @default(${field.defaultValue})` - schema += fieldLine + '\n' - }) - schema += `}\n\n` - }) - - return schema -} - -export function generateComponentCode(node: ComponentNode, indent: number = 0): string { - const spaces = ' '.repeat(indent) - const propsStr = Object.entries(node.props) - .map(([key, value]) => { - if (typeof value === 'string') return `${key}="${value}"` - if (typeof value === 'boolean') return value ? key : '' - return `${key}={${JSON.stringify(value)}}` - }) - .filter(Boolean) - .join(' ') - - if (node.children.length === 0) { - return `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''} />` - } - - let code = `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''}>\n` - node.children.forEach((child) => { - code += generateComponentCode(child, indent + 1) + '\n' - }) - code += `${spaces}` - - return code -} - -export function generateMUITheme(theme: ThemeConfig): string { - if (!theme.variants || theme.variants.length === 0) { - return `import { createTheme } from '@mui/material/styles'; - -export const theme = createTheme({ - palette: { - mode: 'light', - }, -});` - } - - const lightVariant = theme.variants.find((v) => v.id === 'light') || theme.variants[0] - const darkVariant = theme.variants.find((v) => v.id === 'dark') - - let themeCode = `import { createTheme } from '@mui/material/styles'; - -export const lightTheme = createTheme({ - palette: { - mode: 'light', - primary: { - main: '${lightVariant.colors.primaryColor}', - }, - secondary: { - main: '${lightVariant.colors.secondaryColor}', - }, - error: { - main: '${lightVariant.colors.errorColor}', - }, - warning: { - main: '${lightVariant.colors.warningColor}', - }, - success: { - main: '${lightVariant.colors.successColor}', - }, - background: { - default: '${lightVariant.colors.background}', - paper: '${lightVariant.colors.surface}', - }, - text: { - primary: '${lightVariant.colors.text}', - secondary: '${lightVariant.colors.textSecondary}', - }, - }, - typography: { - fontFamily: '${theme.fontFamily}', - fontSize: ${theme.fontSize.medium}, - }, - spacing: ${theme.spacing}, - shape: { - borderRadius: ${theme.borderRadius}, - }, -}); -` - - if (darkVariant) { - themeCode += ` -export const darkTheme = createTheme({ - palette: { - mode: 'dark', - primary: { - main: '${darkVariant.colors.primaryColor}', - }, - secondary: { - main: '${darkVariant.colors.secondaryColor}', - }, - error: { - main: '${darkVariant.colors.errorColor}', - }, - warning: { - main: '${darkVariant.colors.warningColor}', - }, - success: { - main: '${darkVariant.colors.successColor}', - }, - background: { - default: '${darkVariant.colors.background}', - paper: '${darkVariant.colors.surface}', - }, - text: { - primary: '${darkVariant.colors.text}', - secondary: '${darkVariant.colors.textSecondary}', - }, - }, - typography: { - fontFamily: '${theme.fontFamily}', - fontSize: ${theme.fontSize.medium}, - }, - spacing: ${theme.spacing}, - shape: { - borderRadius: ${theme.borderRadius}, - }, -}); - -export const theme = lightTheme;` - } else { - themeCode += `\nexport const theme = lightTheme;` - } - - return themeCode -} - -export function generateNextJSProject( - projectName: string, - models: PrismaModel[], - components: ComponentNode[], - theme: ThemeConfig -): Record { - const files: Record = {} - - files['package.json'] = JSON.stringify( - { - name: projectName, - version: '0.1.0', - private: true, - scripts: { - dev: 'next dev', - build: 'next build', - start: 'next start', - lint: 'next lint', - }, - dependencies: { - '@mui/material': '^5.15.0', - '@emotion/react': '^11.11.0', - '@emotion/styled': '^11.11.0', - '@prisma/client': '^5.8.0', - next: '14.1.0', - react: '^18.2.0', - 'react-dom': '^18.2.0', - }, - devDependencies: { - '@types/node': '^20', - '@types/react': '^18', - '@types/react-dom': '^18', - prisma: '^5.8.0', - typescript: '^5', - }, - }, - null, - 2 - ) - - files['prisma/schema.prisma'] = generatePrismaSchema(models) - - files['src/theme.ts'] = generateMUITheme(theme) - - files['src/app/page.tsx'] = `'use client' - -import { ThemeProvider } from '@mui/material/styles' -import CssBaseline from '@mui/material/CssBaseline' -import { theme } from '@/theme' - -export default function Home() { - return ( - - -
- {/* Your components here */} -
-
- ) -}` - - files['next.config.js'] = `/** @type {import('next').NextConfig} */ -const nextConfig = {} - -module.exports = nextConfig` - - files['.env'] = `DATABASE_URL="postgresql://user:password@localhost:5432/mydb"` - - files['README.md'] = `# ${projectName} - -Generated with CodeForge - -## Getting Started - -1. Install dependencies: -\`\`\`bash -npm install -\`\`\` - -2. Set up your database in .env - -3. Run Prisma migrations: -\`\`\`bash -npx prisma migrate dev -\`\`\` - -4. Start the development server: -\`\`\`bash -npm run dev -\`\`\` - -Open [http://localhost:3000](http://localhost:3000) with your browser.` - - return files -} - -export function generatePlaywrightTests(tests: PlaywrightTest[]): string { - if (tests.length === 0) { - return `import { test, expect } from '@playwright/test' - -test('example test', async ({ page }) => { - await page.goto('/') - await expect(page).toHaveTitle(/.*/) -})` - } - - let code = `import { test, expect } from '@playwright/test'\n\n` - - tests.forEach(testSuite => { - code += `test.describe('${testSuite.name}', () => {\n` - if (testSuite.description) { - code += ` // ${testSuite.description}\n` - } - code += ` test('${testSuite.name}', async ({ page }) => {\n` - - testSuite.steps.forEach(step => { - switch (step.action) { - case 'navigate': - code += ` await page.goto('${testSuite.pageUrl}')\n` - break - case 'click': - code += ` await page.click('${step.selector}')\n` - break - case 'fill': - code += ` await page.fill('${step.selector}', '${step.value}')\n` - break - case 'expect': - code += ` await expect(page.locator('${step.selector}')).${step.assertion}\n` - break - case 'wait': - code += ` await page.waitForTimeout(${step.timeout || 1000})\n` - break - case 'select': - code += ` await page.selectOption('${step.selector}', '${step.value}')\n` - break - case 'check': - code += ` await page.check('${step.selector}')\n` - break - case 'uncheck': - code += ` await page.uncheck('${step.selector}')\n` - break - } - }) - - code += ` })\n` - code += `})\n\n` - }) - - return code -} - -export function generateStorybookStories(stories: StorybookStory[]): Record { - const fileMap: Record = {} - - stories.forEach(story => { - const key = `${story.category}/${story.componentName}` - if (!fileMap[key]) { - fileMap[key] = [] - } - fileMap[key].push(story) - }) - - const files: Record = {} - - Object.entries(fileMap).forEach(([path, storyList]) => { - const componentName = storyList[0].componentName - let code = `import type { Meta, StoryObj } from '@storybook/react'\nimport { ${componentName} } from '@/components/${componentName}'\n\n` - - code += `const meta: Meta = {\n` - code += ` title: '${path}',\n` - code += ` component: ${componentName},\n` - code += ` tags: ['autodocs'],\n` - code += `}\n\n` - code += `export default meta\n` - code += `type Story = StoryObj\n\n` - - storyList.forEach(story => { - code += `export const ${story.storyName.replace(/\s+/g, '')}: Story = {\n` - if (Object.keys(story.args).length > 0) { - code += ` args: ${JSON.stringify(story.args, null, 4).replace(/"/g, "'")},\n` - } - code += `}\n\n` - }) - - files[`src/stories/${componentName}.stories.tsx`] = code - }) - - return files -} - -export function generateUnitTests(tests: UnitTest[]): Record { - const files: Record = {} - - tests.forEach(testSuite => { - const fileName = testSuite.targetFile - ? testSuite.targetFile.replace(/\.(tsx|ts|jsx|js)$/, '.test.$1') - : `src/__tests__/${testSuite.name.replace(/\s+/g, '')}.test.tsx` - - let code = '' - - if (testSuite.testType === 'component') { - code += `import { render, screen } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n` - if (testSuite.targetFile) { - const componentName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '') - code += `import { ${componentName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` - } - } else if (testSuite.testType === 'hook') { - code += `import { renderHook } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n` - if (testSuite.targetFile) { - const hookName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '') - code += `import { ${hookName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` - } - } else { - code += `import { describe, it, expect } from 'vitest'\n` - if (testSuite.targetFile) { - code += `import * as module from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` - } - } - - code += `describe('${testSuite.name}', () => {\n` - if (testSuite.description) { - code += ` // ${testSuite.description}\n\n` - } - - testSuite.testCases.forEach(testCase => { - code += ` it('${testCase.description}', () => {\n` - - if (testCase.setup) { - code += ` ${testCase.setup}\n\n` - } - - testCase.assertions.forEach(assertion => { - code += ` ${assertion}\n` - }) - - if (testCase.teardown) { - code += `\n ${testCase.teardown}\n` - } - - code += ` })\n\n` - }) - - code += `})\n` - - files[fileName] = code - }) - - return files -} - -export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string { - let code = `from flask import Blueprint, request, jsonify\n` - code += `from typing import Dict, Any\n\n` - - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') - code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n` - - blueprint.endpoints.forEach(endpoint => { - const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_') - code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n` - code += `def ${functionName}():\n` - code += ` """\n` - code += ` ${endpoint.description || endpoint.name}\n` - - if (endpoint.queryParams && endpoint.queryParams.length > 0) { - code += ` \n Query Parameters:\n` - endpoint.queryParams.forEach(param => { - code += ` - ${param.name} (${param.type})${param.required ? ' [required]' : ''}: ${param.description || ''}\n` - }) - } - - code += ` """\n` - - if (endpoint.authentication) { - code += ` # TODO: Add authentication check\n` - code += ` # if not is_authenticated(request):\n` - code += ` # return jsonify({'error': 'Unauthorized'}), 401\n\n` - } - - if (endpoint.queryParams && endpoint.queryParams.length > 0) { - endpoint.queryParams.forEach(param => { - if (param.required) { - code += ` ${param.name} = request.args.get('${param.name}')\n` - code += ` if ${param.name} is None:\n` - code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n` - } else { - const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None') - code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n` - } - }) - code += `\n` - } - - if (endpoint.method === 'POST' || endpoint.method === 'PUT' || endpoint.method === 'PATCH') { - code += ` data = request.get_json()\n` - code += ` if not data:\n` - code += ` return jsonify({'error': 'No data provided'}), 400\n\n` - } - - code += ` # TODO: Implement ${endpoint.name} logic\n` - code += ` result = {\n` - code += ` 'message': '${endpoint.name} endpoint',\n` - code += ` 'method': '${endpoint.method}',\n` - code += ` 'path': '${endpoint.path}'\n` - code += ` }\n\n` - code += ` return jsonify(result), 200\n\n\n` - }) - - return code -} - -export function generateFlaskApp(config: FlaskConfig): Record { - const files: Record = {} - - let appCode = `from flask import Flask\n` - if (config.corsOrigins && config.corsOrigins.length > 0) { - appCode += `from flask_cors import CORS\n` - } - appCode += `\n` - - config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') - appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n` - }) - - appCode += `\ndef create_app():\n` - appCode += ` app = Flask(__name__)\n\n` - - if (config.debug !== undefined) { - appCode += ` app.config['DEBUG'] = ${config.debug ? 'True' : 'False'}\n` - } - - if (config.databaseUrl) { - appCode += ` app.config['SQLALCHEMY_DATABASE_URI'] = '${config.databaseUrl}'\n` - appCode += ` app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False\n` - } - - appCode += `\n` - - if (config.corsOrigins && config.corsOrigins.length > 0) { - appCode += ` CORS(app, resources={r"/*": {"origins": ${JSON.stringify(config.corsOrigins)}}})\n\n` - } - - config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') - appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n` - }) - - appCode += `\n @app.route('/')\n` - appCode += ` def index():\n` - appCode += ` return {'message': 'Flask API is running', 'version': '1.0.0'}\n\n` - - appCode += ` return app\n\n\n` - appCode += `if __name__ == '__main__':\n` - appCode += ` app = create_app()\n` - appCode += ` app.run(host='0.0.0.0', port=${config.port || 5000}, debug=${config.debug ? 'True' : 'False'})\n` - - files['app.py'] = appCode - - config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') - files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint) - }) - - files['blueprints/__init__.py'] = '# Flask blueprints\n' - - files['requirements.txt'] = `Flask>=3.0.0 -${config.corsOrigins && config.corsOrigins.length > 0 ? 'Flask-CORS>=4.0.0' : ''} -${config.databaseUrl ? 'Flask-SQLAlchemy>=3.0.0\npsycopg2-binary>=2.9.0' : ''} -${config.jwtSecret ? 'PyJWT>=2.8.0\nFlask-JWT-Extended>=4.5.0' : ''} -python-dotenv>=1.0.0 -` - - files['.env'] = `FLASK_APP=app.py -FLASK_ENV=${config.debug ? 'development' : 'production'} -${config.databaseUrl ? `DATABASE_URL=${config.databaseUrl}` : 'DATABASE_URL=postgresql://user:password@localhost:5432/mydb'} -${config.jwtSecret ? 'JWT_SECRET_KEY=your-secret-key-here' : ''} -` - - files['README.md'] = `# Flask API - -Generated with CodeForge - -## Getting Started - -1. Create a virtual environment: -\`\`\`bash -python -m venv venv -source venv/bin/activate # On Windows: venv\\Scripts\\activate -\`\`\` - -2. Install dependencies: -\`\`\`bash -pip install -r requirements.txt -\`\`\` - -3. Set up your environment variables in .env - -4. Run the application: -\`\`\`bash -python app.py -\`\`\` - -The API will be available at http://localhost:${config.port || 5000} - -## Blueprints - -${config.blueprints.map(bp => `- **${bp.name}**: ${bp.description || 'No description'} (${bp.urlPrefix})`).join('\n')} - -## API Documentation - -${config.enableSwagger ? 'Swagger documentation available at /docs' : 'No API documentation configured'} -` - - return files -} - diff --git a/src/lib/generators/generateComponentCode.ts b/src/lib/generators/generateComponentCode.ts new file mode 100644 index 0000000..dc19c0d --- /dev/null +++ b/src/lib/generators/generateComponentCode.ts @@ -0,0 +1,25 @@ +import { ComponentNode } from '@/types/project' + +export function generateComponentCode(node: ComponentNode, indent: number = 0): string { + const spaces = ' '.repeat(indent) + const propsStr = Object.entries(node.props) + .map(([key, value]) => { + if (typeof value === 'string') return `${key}="${value}"` + if (typeof value === 'boolean') return value ? key : '' + return `${key}={${JSON.stringify(value)}}` + }) + .filter(Boolean) + .join(' ') + + if (node.children.length === 0) { + return `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''} />` + } + + let code = `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''}>\n` + node.children.forEach((child) => { + code += generateComponentCode(child, indent + 1) + '\n' + }) + code += `${spaces}` + + return code +} diff --git a/src/lib/generators/generateFlaskApp.ts b/src/lib/generators/generateFlaskApp.ts new file mode 100644 index 0000000..09858e1 --- /dev/null +++ b/src/lib/generators/generateFlaskApp.ts @@ -0,0 +1,108 @@ +import { FlaskConfig } from '@/types/project' +import { generateFlaskBlueprint } from './generateFlaskBlueprint' + +export function generateFlaskApp(config: FlaskConfig): Record { + const files: Record = {} + + let appCode = `from flask import Flask\n` + if (config.corsOrigins && config.corsOrigins.length > 0) { + appCode += `from flask_cors import CORS\n` + } + appCode += `\n` + + config.blueprints.forEach(blueprint => { + const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n` + }) + + appCode += `\ndef create_app():\n` + appCode += ` app = Flask(__name__)\n\n` + + if (config.debug !== undefined) { + appCode += ` app.config['DEBUG'] = ${config.debug ? 'True' : 'False'}\n` + } + + if (config.databaseUrl) { + appCode += ` app.config['SQLALCHEMY_DATABASE_URI'] = '${config.databaseUrl}'\n` + appCode += ` app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False\n` + } + + appCode += `\n` + + if (config.corsOrigins && config.corsOrigins.length > 0) { + appCode += ` CORS(app, resources={r"/*": {"origins": ${JSON.stringify(config.corsOrigins)}}})\n\n` + } + + config.blueprints.forEach(blueprint => { + const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n` + }) + + appCode += `\n @app.route('/')\n` + appCode += ` def index():\n` + appCode += ` return {'message': 'Flask API is running', 'version': '1.0.0'}\n\n` + + appCode += ` return app\n\n\n` + appCode += `if __name__ == '__main__':\n` + appCode += ` app = create_app()\n` + appCode += ` app.run(host='0.0.0.0', port=${config.port || 5000}, debug=${config.debug ? 'True' : 'False'})\n` + + files['app.py'] = appCode + + config.blueprints.forEach(blueprint => { + const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint) + }) + + files['blueprints/__init__.py'] = '# Flask blueprints\n' + + files['requirements.txt'] = `Flask>=3.0.0 +${config.corsOrigins && config.corsOrigins.length > 0 ? 'Flask-CORS>=4.0.0' : ''} +${config.databaseUrl ? 'Flask-SQLAlchemy>=3.0.0\npsycopg2-binary>=2.9.0' : ''} +${config.jwtSecret ? 'PyJWT>=2.8.0\nFlask-JWT-Extended>=4.5.0' : ''} +python-dotenv>=1.0.0 +` + + files['.env'] = `FLASK_APP=app.py +FLASK_ENV=${config.debug ? 'development' : 'production'} +${config.databaseUrl ? `DATABASE_URL=${config.databaseUrl}` : 'DATABASE_URL=postgresql://user:password@localhost:5432/mydb'} +${config.jwtSecret ? 'JWT_SECRET_KEY=your-secret-key-here' : ''} +` + + files['README.md'] = `# Flask API + +Generated with CodeForge + +## Getting Started + +1. Create a virtual environment: +\`\`\`bash +python -m venv venv +source venv/bin/activate # On Windows: venv\\Scripts\\activate +\`\`\` + +2. Install dependencies: +\`\`\`bash +pip install -r requirements.txt +\`\`\` + +3. Set up your environment variables in .env + +4. Run the application: +\`\`\`bash +python app.py +\`\`\` + +The API will be available at http://localhost:${config.port || 5000} + +## Blueprints + +${config.blueprints.map(bp => `- **${bp.name}**: ${bp.description || 'No description'} (${bp.urlPrefix})`).join('\n')} + +## API Documentation + +${config.enableSwagger ? 'Swagger documentation available at /docs' : 'No API documentation configured'} +` + + return files +} diff --git a/src/lib/generators/generateFlaskBlueprint.ts b/src/lib/generators/generateFlaskBlueprint.ts new file mode 100644 index 0000000..301efae --- /dev/null +++ b/src/lib/generators/generateFlaskBlueprint.ts @@ -0,0 +1,62 @@ +import { FlaskBlueprint } from '@/types/project' + +export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string { + let code = `from flask import Blueprint, request, jsonify\n` + code += `from typing import Dict, Any\n\n` + + const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n` + + blueprint.endpoints.forEach(endpoint => { + const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_') + code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n` + code += `def ${functionName}():\n` + code += ` """\n` + code += ` ${endpoint.description || endpoint.name}\n` + + if (endpoint.queryParams && endpoint.queryParams.length > 0) { + code += ` \n Query Parameters:\n` + endpoint.queryParams.forEach(param => { + code += ` - ${param.name} (${param.type})${param.required ? ' [required]' : ''}: ${param.description || ''}\n` + }) + } + + code += ` """\n` + + if (endpoint.authentication) { + code += ` # TODO: Add authentication check\n` + code += ` # if not is_authenticated(request):\n` + code += ` # return jsonify({'error': 'Unauthorized'}), 401\n\n` + } + + if (endpoint.queryParams && endpoint.queryParams.length > 0) { + endpoint.queryParams.forEach(param => { + if (param.required) { + code += ` ${param.name} = request.args.get('${param.name}')\n` + code += ` if ${param.name} is None:\n` + code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n` + } else { + const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None') + code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n` + } + }) + code += `\n` + } + + if (endpoint.method === 'POST' || endpoint.method === 'PUT' || endpoint.method === 'PATCH') { + code += ` data = request.get_json()\n` + code += ` if not data:\n` + code += ` return jsonify({'error': 'No data provided'}), 400\n\n` + } + + code += ` # TODO: Implement ${endpoint.name} logic\n` + code += ` result = {\n` + code += ` 'message': '${endpoint.name} endpoint',\n` + code += ` 'method': '${endpoint.method}',\n` + code += ` 'path': '${endpoint.path}'\n` + code += ` }\n\n` + code += ` return jsonify(result), 200\n\n\n` + }) + + return code +} diff --git a/src/lib/generators/generateMUITheme.ts b/src/lib/generators/generateMUITheme.ts new file mode 100644 index 0000000..15d0792 --- /dev/null +++ b/src/lib/generators/generateMUITheme.ts @@ -0,0 +1,102 @@ +import { ThemeConfig } from '@/types/project' + +export function generateMUITheme(theme: ThemeConfig): string { + if (!theme.variants || theme.variants.length === 0) { + return `import { createTheme } from '@mui/material/styles'; + +export const theme = createTheme({ + palette: { + mode: 'light', + }, +});` + } + + const lightVariant = theme.variants.find((v) => v.id === 'light') || theme.variants[0] + const darkVariant = theme.variants.find((v) => v.id === 'dark') + + let themeCode = `import { createTheme } from '@mui/material/styles'; + +export const lightTheme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '${lightVariant.colors.primaryColor}', + }, + secondary: { + main: '${lightVariant.colors.secondaryColor}', + }, + error: { + main: '${lightVariant.colors.errorColor}', + }, + warning: { + main: '${lightVariant.colors.warningColor}', + }, + success: { + main: '${lightVariant.colors.successColor}', + }, + background: { + default: '${lightVariant.colors.background}', + paper: '${lightVariant.colors.surface}', + }, + text: { + primary: '${lightVariant.colors.text}', + secondary: '${lightVariant.colors.textSecondary}', + }, + }, + typography: { + fontFamily: '${theme.fontFamily}', + fontSize: ${theme.fontSize.medium}, + }, + spacing: ${theme.spacing}, + shape: { + borderRadius: ${theme.borderRadius}, + }, +}); +` + + if (darkVariant) { + themeCode += ` +export const darkTheme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '${darkVariant.colors.primaryColor}', + }, + secondary: { + main: '${darkVariant.colors.secondaryColor}', + }, + error: { + main: '${darkVariant.colors.errorColor}', + }, + warning: { + main: '${darkVariant.colors.warningColor}', + }, + success: { + main: '${darkVariant.colors.successColor}', + }, + background: { + default: '${darkVariant.colors.background}', + paper: '${darkVariant.colors.surface}', + }, + text: { + primary: '${darkVariant.colors.text}', + secondary: '${darkVariant.colors.textSecondary}', + }, + }, + typography: { + fontFamily: '${theme.fontFamily}', + fontSize: ${theme.fontSize.medium}, + }, + spacing: ${theme.spacing}, + shape: { + borderRadius: ${theme.borderRadius}, + }, +}); + +export const theme = lightTheme;` + } else { + themeCode += `\nexport const theme = lightTheme;` + } + + return themeCode +} diff --git a/src/lib/generators/generateNextJSProject.ts b/src/lib/generators/generateNextJSProject.ts new file mode 100644 index 0000000..fe3860a --- /dev/null +++ b/src/lib/generators/generateNextJSProject.ts @@ -0,0 +1,99 @@ +import { ComponentNode, PrismaModel, ThemeConfig } from '@/types/project' +import { generateMUITheme } from './generateMUITheme' +import { generatePrismaSchema } from './generatePrismaSchema' + +export function generateNextJSProject( + projectName: string, + models: PrismaModel[], + components: ComponentNode[], + theme: ThemeConfig +): Record { + const files: Record = {} + + files['package.json'] = JSON.stringify( + { + name: projectName, + version: '0.1.0', + private: true, + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + lint: 'next lint', + }, + dependencies: { + '@mui/material': '^5.15.0', + '@emotion/react': '^11.11.0', + '@emotion/styled': '^11.11.0', + '@prisma/client': '^5.8.0', + next: '14.1.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@types/node': '^20', + '@types/react': '^18', + '@types/react-dom': '^18', + prisma: '^5.8.0', + typescript: '^5', + }, + }, + null, + 2 + ) + + files['prisma/schema.prisma'] = generatePrismaSchema(models) + + files['src/theme.ts'] = generateMUITheme(theme) + + files['src/app/page.tsx'] = `'use client' + +import { ThemeProvider } from '@mui/material/styles' +import CssBaseline from '@mui/material/CssBaseline' +import { theme } from '@/theme' + +export default function Home() { + return ( + + +
+ {/* Your components here */} +
+
+ ) +}` + + files['next.config.js'] = `/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig` + + files['.env'] = `DATABASE_URL="postgresql://user:password@localhost:5432/mydb"` + + files['README.md'] = `# ${projectName} + +Generated with CodeForge + +## Getting Started + +1. Install dependencies: +\`\`\`bash +npm install +\`\`\` + +2. Set up your database in .env + +3. Run Prisma migrations: +\`\`\`bash +npx prisma migrate dev +\`\`\` + +4. Start the development server: +\`\`\`bash +npm run dev +\`\`\` + +Open [http://localhost:3000](http://localhost:3000) with your browser.` + + return files +} diff --git a/src/lib/generators/generatePlaywrightTests.ts b/src/lib/generators/generatePlaywrightTests.ts new file mode 100644 index 0000000..31d164a --- /dev/null +++ b/src/lib/generators/generatePlaywrightTests.ts @@ -0,0 +1,56 @@ +import { PlaywrightTest } from '@/types/project' + +export function generatePlaywrightTests(tests: PlaywrightTest[]): string { + if (tests.length === 0) { + return `import { test, expect } from '@playwright/test' + +test('example test', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveTitle(/.*/) +})` + } + + let code = `import { test, expect } from '@playwright/test'\n\n` + + tests.forEach(testSuite => { + code += `test.describe('${testSuite.name}', () => {\n` + if (testSuite.description) { + code += ` // ${testSuite.description}\n` + } + code += ` test('${testSuite.name}', async ({ page }) => {\n` + + testSuite.steps.forEach(step => { + switch (step.action) { + case 'navigate': + code += ` await page.goto('${testSuite.pageUrl}')\n` + break + case 'click': + code += ` await page.click('${step.selector}')\n` + break + case 'fill': + code += ` await page.fill('${step.selector}', '${step.value}')\n` + break + case 'expect': + code += ` await expect(page.locator('${step.selector}')).${step.assertion}\n` + break + case 'wait': + code += ` await page.waitForTimeout(${step.timeout || 1000})\n` + break + case 'select': + code += ` await page.selectOption('${step.selector}', '${step.value}')\n` + break + case 'check': + code += ` await page.check('${step.selector}')\n` + break + case 'uncheck': + code += ` await page.uncheck('${step.selector}')\n` + break + } + }) + + code += ` })\n` + code += `})\n\n` + }) + + return code +} diff --git a/src/lib/generators/generatePrismaSchema.ts b/src/lib/generators/generatePrismaSchema.ts new file mode 100644 index 0000000..d5bcc64 --- /dev/null +++ b/src/lib/generators/generatePrismaSchema.ts @@ -0,0 +1,22 @@ +import { PrismaModel } from '@/types/project' + +export function generatePrismaSchema(models: PrismaModel[]): string { + let schema = `generator client {\n provider = "prisma-client-js"\n}\n\n` + schema += `datasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n` + + models.forEach((model) => { + schema += `model ${model.name} {\n` + model.fields.forEach((field) => { + let fieldLine = ` ${field.name} ${field.type}` + if (field.isArray) fieldLine += '[]' + if (field.isRequired && !field.defaultValue) fieldLine += '' + else if (!field.isRequired) fieldLine += '?' + if (field.isUnique) fieldLine += ' @unique' + if (field.defaultValue) fieldLine += ` @default(${field.defaultValue})` + schema += fieldLine + '\n' + }) + schema += `}\n\n` + }) + + return schema +} diff --git a/src/lib/generators/generateStorybookStories.ts b/src/lib/generators/generateStorybookStories.ts new file mode 100644 index 0000000..37bdf92 --- /dev/null +++ b/src/lib/generators/generateStorybookStories.ts @@ -0,0 +1,40 @@ +import { StorybookStory } from '@/types/project' + +export function generateStorybookStories(stories: StorybookStory[]): Record { + const fileMap: Record = {} + + stories.forEach(story => { + const key = `${story.category}/${story.componentName}` + if (!fileMap[key]) { + fileMap[key] = [] + } + fileMap[key].push(story) + }) + + const files: Record = {} + + Object.entries(fileMap).forEach(([path, storyList]) => { + const componentName = storyList[0].componentName + let code = `import type { Meta, StoryObj } from '@storybook/react'\nimport { ${componentName} } from '@/components/${componentName}'\n\n` + + code += `const meta: Meta = {\n` + code += ` title: '${path}',\n` + code += ` component: ${componentName},\n` + code += ` tags: ['autodocs'],\n` + code += `}\n\n` + code += `export default meta\n` + code += `type Story = StoryObj\n\n` + + storyList.forEach(story => { + code += `export const ${story.storyName.replace(/\s+/g, '')}: Story = {\n` + if (Object.keys(story.args).length > 0) { + code += ` args: ${JSON.stringify(story.args, null, 4).replace(/"/g, "'")},\n` + } + code += `}\n\n` + }) + + files[`src/stories/${componentName}.stories.tsx`] = code + }) + + return files +} diff --git a/src/lib/generators/generateUnitTests.ts b/src/lib/generators/generateUnitTests.ts new file mode 100644 index 0000000..c0fcaa2 --- /dev/null +++ b/src/lib/generators/generateUnitTests.ts @@ -0,0 +1,61 @@ +import { UnitTest } from '@/types/project' + +export function generateUnitTests(tests: UnitTest[]): Record { + const files: Record = {} + + tests.forEach(testSuite => { + const fileName = testSuite.targetFile + ? testSuite.targetFile.replace(/\.(tsx|ts|jsx|js)$/, '.test.$1') + : `src/__tests__/${testSuite.name.replace(/\s+/g, '')}.test.tsx` + + let code = '' + + if (testSuite.testType === 'component') { + code += `import { render, screen } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n` + if (testSuite.targetFile) { + const componentName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '') + code += `import { ${componentName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` + } + } else if (testSuite.testType === 'hook') { + code += `import { renderHook } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n` + if (testSuite.targetFile) { + const hookName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '') + code += `import { ${hookName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` + } + } else { + code += `import { describe, it, expect } from 'vitest'\n` + if (testSuite.targetFile) { + code += `import * as module from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n` + } + } + + code += `describe('${testSuite.name}', () => {\n` + if (testSuite.description) { + code += ` // ${testSuite.description}\n\n` + } + + testSuite.testCases.forEach(testCase => { + code += ` it('${testCase.description}', () => {\n` + + if (testCase.setup) { + code += ` ${testCase.setup}\n\n` + } + + testCase.assertions.forEach(assertion => { + code += ` ${assertion}\n` + }) + + if (testCase.teardown) { + code += `\n ${testCase.teardown}\n` + } + + code += ` })\n\n` + }) + + code += `})\n` + + files[fileName] = code + }) + + return files +} diff --git a/src/lib/generators/index.ts b/src/lib/generators/index.ts new file mode 100644 index 0000000..599274b --- /dev/null +++ b/src/lib/generators/index.ts @@ -0,0 +1,9 @@ +export { generateComponentCode } from './generateComponentCode' +export { generateFlaskApp } from './generateFlaskApp' +export { generateFlaskBlueprint } from './generateFlaskBlueprint' +export { generateMUITheme } from './generateMUITheme' +export { generateNextJSProject } from './generateNextJSProject' +export { generatePlaywrightTests } from './generatePlaywrightTests' +export { generatePrismaSchema } from './generatePrismaSchema' +export { generateStorybookStories } from './generateStorybookStories' +export { generateUnitTests } from './generateUnitTests' diff --git a/src/lib/unified-storage-adapters/flask-backend-adapter.ts b/src/lib/unified-storage-adapters/flask-backend-adapter.ts new file mode 100644 index 0000000..28bfdd0 --- /dev/null +++ b/src/lib/unified-storage-adapters/flask-backend-adapter.ts @@ -0,0 +1,77 @@ +import type { StorageAdapter } from './types' + +export class FlaskBackendAdapter implements StorageAdapter { + private baseUrl: string + private readonly TIMEOUT_MS = 2000 + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || localStorage.getItem('codeforge-flask-url') || import.meta.env.VITE_FLASK_BACKEND_URL || 'http://localhost:5001' + } + + private async request(endpoint: string, options?: RequestInit): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS) + + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `HTTP ${response.status}`) + } + + return response.json() + } catch (error: any) { + clearTimeout(timeoutId) + if (error.name === 'AbortError') { + throw new Error(`Request timeout after ${this.TIMEOUT_MS}ms`) + } + throw error + } + } + + async get(key: string): Promise { + try { + const result = await this.request<{ value: T }>(`/api/storage/${encodeURIComponent(key)}`) + return result.value + } catch (error: any) { + if (error.message?.includes('404') || error.message?.includes('not found')) { + return undefined + } + throw error + } + } + + async set(key: string, value: T): Promise { + await this.request(`/api/storage/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ value }), + }) + } + + async delete(key: string): Promise { + await this.request(`/api/storage/${encodeURIComponent(key)}`, { + method: 'DELETE', + }) + } + + async keys(): Promise { + const result = await this.request<{ keys: string[] }>('/api/storage/keys') + return result.keys + } + + async clear(): Promise { + await this.request('/api/storage/clear', { + method: 'POST', + }) + } +} diff --git a/src/lib/unified-storage-adapters/index.ts b/src/lib/unified-storage-adapters/index.ts new file mode 100644 index 0000000..f4be7b8 --- /dev/null +++ b/src/lib/unified-storage-adapters/index.ts @@ -0,0 +1,5 @@ +export { FlaskBackendAdapter } from './flask-backend-adapter' +export { IndexedDBAdapter } from './indexeddb-adapter' +export { SparkKVAdapter } from './spark-kv-adapter' +export { SQLiteAdapter } from './sqlite-adapter' +export type { StorageAdapter, StorageBackend } from './types' diff --git a/src/lib/unified-storage-adapters/indexeddb-adapter.ts b/src/lib/unified-storage-adapters/indexeddb-adapter.ts new file mode 100644 index 0000000..5ee1780 --- /dev/null +++ b/src/lib/unified-storage-adapters/indexeddb-adapter.ts @@ -0,0 +1,110 @@ +import type { StorageAdapter } from './types' + +export class IndexedDBAdapter implements StorageAdapter { + private db: IDBDatabase | null = null + private readonly dbName = 'CodeForgeDB' + private readonly storeName = 'keyvalue' + private readonly version = 2 + + private async init(): Promise { + if (this.db) return + + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: 'key' }) + } + } + }) + } + + async get(key: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readonly') + const store = transaction.objectStore(this.storeName) + const request = store.get(key) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const result = request.result + resolve(result ? result.value : undefined) + } + }) + } + + async set(key: string, value: T): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite') + const store = transaction.objectStore(this.storeName) + const request = store.put({ key, value }) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + } + + async delete(key: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite') + const store = transaction.objectStore(this.storeName) + const request = store.delete(key) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + } + + async keys(): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readonly') + const store = transaction.objectStore(this.storeName) + const request = store.getAllKeys() + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result as string[]) + }) + } + + async clear(): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite') + const store = transaction.objectStore(this.storeName) + const request = store.clear() + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + } + + async close(): Promise { + if (this.db) { + this.db.close() + this.db = null + } + } +} diff --git a/src/lib/unified-storage-adapters/spark-kv-adapter.ts b/src/lib/unified-storage-adapters/spark-kv-adapter.ts new file mode 100644 index 0000000..b04eb19 --- /dev/null +++ b/src/lib/unified-storage-adapters/spark-kv-adapter.ts @@ -0,0 +1,29 @@ +import type { StorageAdapter } from './types' + +export class SparkKVAdapter implements StorageAdapter { + async get(key: string): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + return await window.spark.kv.get(key) + } + + async set(key: string, value: T): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + await window.spark.kv.set(key, value) + } + + async delete(key: string): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + await window.spark.kv.delete(key) + } + + async keys(): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + return await window.spark.kv.keys() + } + + async clear(): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + const allKeys = await window.spark.kv.keys() + await Promise.all(allKeys.map(key => window.spark.kv.delete(key))) + } +} diff --git a/src/lib/unified-storage-adapters/sqlite-adapter.ts b/src/lib/unified-storage-adapters/sqlite-adapter.ts new file mode 100644 index 0000000..552f880 --- /dev/null +++ b/src/lib/unified-storage-adapters/sqlite-adapter.ts @@ -0,0 +1,123 @@ +import type { StorageAdapter } from './types' + +export class SQLiteAdapter implements StorageAdapter { + private db: any = null + private SQL: any = null + private initPromise: Promise | null = null + + private async loadSQLiteWASM(): Promise { + const moduleName = 'sql.js' + try { + return await import(/* @vite-ignore */ moduleName) + } catch { + throw new Error(`${moduleName} not installed. Run: npm install ${moduleName}`) + } + } + + private async init(): Promise { + if (this.db) return + if (this.initPromise) return this.initPromise + + this.initPromise = (async () => { + try { + const sqlJsModule = await this.loadSQLiteWASM() + const initSqlJs = sqlJsModule.default + + this.SQL = await initSqlJs({ + locateFile: (file: string) => `https://sql.js.org/dist/${file}` + }) + + const data = localStorage.getItem('codeforge-sqlite-db') + if (data) { + const buffer = new Uint8Array(JSON.parse(data)) + this.db = new this.SQL.Database(buffer) + } else { + this.db = new this.SQL.Database() + } + + this.db.run(` + CREATE TABLE IF NOT EXISTS keyvalue ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `) + } catch (error) { + console.error('SQLite initialization failed:', error) + throw error + } + })() + + return this.initPromise + } + + private persist(): void { + if (!this.db) return + try { + const data = this.db.export() + const buffer = Array.from(data) + localStorage.setItem('codeforge-sqlite-db', JSON.stringify(buffer)) + } catch (error) { + console.error('Failed to persist SQLite database:', error) + } + } + + async get(key: string): Promise { + await this.init() + const stmt = this.db.prepare('SELECT value FROM keyvalue WHERE key = ?') + stmt.bind([key]) + + if (stmt.step()) { + const row = stmt.getAsObject() + stmt.free() + return JSON.parse(row.value as string) as T + } + + stmt.free() + return undefined + } + + async set(key: string, value: T): Promise { + await this.init() + this.db.run( + 'INSERT OR REPLACE INTO keyvalue (key, value) VALUES (?, ?)', + [key, JSON.stringify(value)] + ) + this.persist() + } + + async delete(key: string): Promise { + await this.init() + this.db.run('DELETE FROM keyvalue WHERE key = ?', [key]) + this.persist() + } + + async keys(): Promise { + await this.init() + const stmt = this.db.prepare('SELECT key FROM keyvalue') + const keys: string[] = [] + + while (stmt.step()) { + const row = stmt.getAsObject() + keys.push(row.key as string) + } + + stmt.free() + return keys + } + + async clear(): Promise { + await this.init() + this.db.run('DELETE FROM keyvalue') + this.persist() + } + + async close(): Promise { + if (this.db) { + this.persist() + this.db.close() + this.db = null + this.SQL = null + this.initPromise = null + } + } +} diff --git a/src/lib/unified-storage-adapters/types.ts b/src/lib/unified-storage-adapters/types.ts new file mode 100644 index 0000000..21c3ec3 --- /dev/null +++ b/src/lib/unified-storage-adapters/types.ts @@ -0,0 +1,10 @@ +export type StorageBackend = 'flask' | 'indexeddb' | 'sqlite' | 'sparkkv' + +export interface StorageAdapter { + get(key: string): Promise + set(key: string, value: T): Promise + delete(key: string): Promise + keys(): Promise + clear(): Promise + close?(): Promise +} diff --git a/src/lib/unified-storage.ts b/src/lib/unified-storage.ts index 4e61c41..d430886 100644 --- a/src/lib/unified-storage.ts +++ b/src/lib/unified-storage.ts @@ -1,350 +1,9 @@ /// -export type StorageBackend = 'flask' | 'indexeddb' | 'sqlite' | 'sparkkv' +import type { StorageAdapter, StorageBackend } from './unified-storage-adapters' +import { FlaskBackendAdapter, IndexedDBAdapter, SparkKVAdapter, SQLiteAdapter } from './unified-storage-adapters' -export interface StorageAdapter { - get(key: string): Promise - set(key: string, value: T): Promise - delete(key: string): Promise - keys(): Promise - clear(): Promise - close?(): Promise -} - -class FlaskBackendAdapter implements StorageAdapter { - private baseUrl: string - private readonly TIMEOUT_MS = 2000 - - constructor(baseUrl?: string) { - this.baseUrl = baseUrl || localStorage.getItem('codeforge-flask-url') || import.meta.env.VITE_FLASK_BACKEND_URL || 'http://localhost:5001' - } - - private async request(endpoint: string, options?: RequestInit): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS) - - try { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - signal: controller.signal, - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })) - throw new Error(error.error || `HTTP ${response.status}`) - } - - return response.json() - } catch (error: any) { - clearTimeout(timeoutId) - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${this.TIMEOUT_MS}ms`) - } - throw error - } - } - - async get(key: string): Promise { - try { - const result = await this.request<{ value: T }>(`/api/storage/${encodeURIComponent(key)}`) - return result.value - } catch (error: any) { - if (error.message?.includes('404') || error.message?.includes('not found')) { - return undefined - } - throw error - } - } - - async set(key: string, value: T): Promise { - await this.request(`/api/storage/${encodeURIComponent(key)}`, { - method: 'PUT', - body: JSON.stringify({ value }), - }) - } - - async delete(key: string): Promise { - await this.request(`/api/storage/${encodeURIComponent(key)}`, { - method: 'DELETE', - }) - } - - async keys(): Promise { - const result = await this.request<{ keys: string[] }>('/api/storage/keys') - return result.keys - } - - async clear(): Promise { - await this.request('/api/storage/clear', { - method: 'POST', - }) - } -} - -class IndexedDBAdapter implements StorageAdapter { - private db: IDBDatabase | null = null - private readonly dbName = 'CodeForgeDB' - private readonly storeName = 'keyvalue' - private readonly version = 2 - - private async init(): Promise { - if (this.db) return - - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, this.version) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - this.db = request.result - resolve() - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName, { keyPath: 'key' }) - } - } - }) - } - - async get(key: string): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readonly') - const store = transaction.objectStore(this.storeName) - const request = store.get(key) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - const result = request.result - resolve(result ? result.value : undefined) - } - }) - } - - async set(key: string, value: T): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readwrite') - const store = transaction.objectStore(this.storeName) - const request = store.put({ key, value }) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async delete(key: string): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readwrite') - const store = transaction.objectStore(this.storeName) - const request = store.delete(key) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async keys(): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readonly') - const store = transaction.objectStore(this.storeName) - const request = store.getAllKeys() - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result as string[]) - }) - } - - async clear(): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readwrite') - const store = transaction.objectStore(this.storeName) - const request = store.clear() - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async close(): Promise { - if (this.db) { - this.db.close() - this.db = null - } - } -} - -class SparkKVAdapter implements StorageAdapter { - async get(key: string): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - return await window.spark.kv.get(key) - } - - async set(key: string, value: T): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - await window.spark.kv.set(key, value) - } - - async delete(key: string): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - await window.spark.kv.delete(key) - } - - async keys(): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - return await window.spark.kv.keys() - } - - async clear(): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - const allKeys = await window.spark.kv.keys() - await Promise.all(allKeys.map(key => window.spark.kv.delete(key))) - } -} - -class SQLiteAdapter implements StorageAdapter { - private db: any = null - private SQL: any = null - private initPromise: Promise | null = null - - private async loadSQLiteWASM(): Promise { - const moduleName = 'sql.js' - try { - return await import(/* @vite-ignore */ moduleName) - } catch { - throw new Error(`${moduleName} not installed. Run: npm install ${moduleName}`) - } - } - - private async init(): Promise { - if (this.db) return - if (this.initPromise) return this.initPromise - - this.initPromise = (async () => { - try { - const sqlJsModule = await this.loadSQLiteWASM() - const initSqlJs = sqlJsModule.default - - this.SQL = await initSqlJs({ - locateFile: (file: string) => `https://sql.js.org/dist/${file}` - }) - - const data = localStorage.getItem('codeforge-sqlite-db') - if (data) { - const buffer = new Uint8Array(JSON.parse(data)) - this.db = new this.SQL.Database(buffer) - } else { - this.db = new this.SQL.Database() - } - - this.db.run(` - CREATE TABLE IF NOT EXISTS keyvalue ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `) - } catch (error) { - console.error('SQLite initialization failed:', error) - throw error - } - })() - - return this.initPromise - } - - private persist(): void { - if (!this.db) return - try { - const data = this.db.export() - const buffer = Array.from(data) - localStorage.setItem('codeforge-sqlite-db', JSON.stringify(buffer)) - } catch (error) { - console.error('Failed to persist SQLite database:', error) - } - } - - async get(key: string): Promise { - await this.init() - const stmt = this.db.prepare('SELECT value FROM keyvalue WHERE key = ?') - stmt.bind([key]) - - if (stmt.step()) { - const row = stmt.getAsObject() - stmt.free() - return JSON.parse(row.value as string) as T - } - - stmt.free() - return undefined - } - - async set(key: string, value: T): Promise { - await this.init() - this.db.run( - 'INSERT OR REPLACE INTO keyvalue (key, value) VALUES (?, ?)', - [key, JSON.stringify(value)] - ) - this.persist() - } - - async delete(key: string): Promise { - await this.init() - this.db.run('DELETE FROM keyvalue WHERE key = ?', [key]) - this.persist() - } - - async keys(): Promise { - await this.init() - const stmt = this.db.prepare('SELECT key FROM keyvalue') - const keys: string[] = [] - - while (stmt.step()) { - const row = stmt.getAsObject() - keys.push(row.key as string) - } - - stmt.free() - return keys - } - - async clear(): Promise { - await this.init() - this.db.run('DELETE FROM keyvalue') - this.persist() - } - - async close(): Promise { - if (this.db) { - this.persist() - this.db.close() - this.db = null - this.SQL = null - this.initPromise = null - } - } -} +export type { StorageAdapter, StorageBackend } from './unified-storage-adapters' class UnifiedStorage { private adapter: StorageAdapter | null = null