reaminig 5-t-9

This commit is contained in:
alorig
2025-11-18 06:36:56 +05:00
parent 9facd12082
commit 68a98208b1
31 changed files with 5210 additions and 153 deletions

View File

@@ -423,11 +423,79 @@ import type { HeroBlockProps } from '@shared/blocks/HeroBlock';
2. **Props Validation**: All components validate required props. Use TypeScript for compile-time safety.
3. **Accessibility**: Components follow WCAG guidelines. Include ARIA labels where appropriate.
3. **Accessibility**: Components follow WCAG 2.1 AA guidelines. Include ARIA labels, proper heading hierarchy, and keyboard navigation support.
4. **Responsive Design**: All components are mobile-first and responsive.
4. **Responsive Design**: All components are mobile-first and responsive. Use CSS Grid and Flexbox for layouts.
5. **Performance**: Use React.memo for expensive components, lazy loading for templates.
5. **Performance**:
- Use React.memo for expensive components
- Lazy load templates when possible
- Optimize images with lazy loading
- Minimize re-renders with proper state management
6. **Type Safety**: All props are fully typed. Avoid `any` types. Use union types for variants.
7. **Error Handling**: Components should gracefully handle missing or invalid props.
8. **Documentation**: Include JSDoc comments for complex components and props.
## API Reference
### Blocks
All block components follow a consistent API pattern:
- Accept `className` for custom styling
- Support optional props with sensible defaults
- Return semantic HTML elements
- Use CSS classes from `blocks.css`
### Layouts
Layout components:
- Accept `children` as main content
- Support optional `header`, `footer`, `sidebar` props
- Use CSS Grid or Flexbox for structure
- Are fully responsive
### Templates
Template components:
- Compose multiple blocks and layouts
- Accept configuration objects for sections
- Support custom content injection
- Handle empty states gracefully
## Migration Guide
### From Phase 6 to Phase 7
If you're upgrading from Phase 6 components:
1. **New Props**: Some components now accept additional props for Phase 7 features
2. **Theme System**: Integrate with CMS Theme System for consistent styling
3. **Type Updates**: TypeScript interfaces may have new optional fields
4. **CSS Changes**: New CSS variables for theme customization
### Breaking Changes
None in Phase 7. All changes are backward compatible.
## Troubleshooting
### Components not rendering
- Ensure CSS files are imported: `import './blocks.css'`
- Check that props match TypeScript interfaces
- Verify React version compatibility (React 19+)
### Styling issues
- Check CSS class names match component structure
- Verify CSS variables are defined in theme
- Ensure Tailwind classes are available (if used)
### Type errors
- Update TypeScript to latest version
- Check prop types match component interfaces
- Verify all required props are provided
## Contributing
@@ -443,13 +511,133 @@ When adding new shared components:
## Testing
Shared components should be tested in the consuming applications. The Site Builder includes tests for components used in the preview canvas.
Shared components include comprehensive test coverage using React Testing Library. Tests are located in `__tests__` directories within each component folder.
### Test Coverage
**Blocks:**
-`HeroBlock` - Tests for title, subtitle, CTA, and supporting content
-`FeatureGridBlock` - Tests for features rendering, columns, and icons
-`StatsPanel` - Tests for stats display and variants
**Layouts:**
-`DefaultLayout` - Tests for header, footer, sidebar, and children rendering
**Templates:**
-`MarketingTemplate` - Tests for hero, sections, and sidebar rendering
### Running Tests
Tests use `@testing-library/react` and follow the existing test patterns in the codebase:
```bash
# Run all tests (when test script is configured)
npm test
# Run tests for shared components specifically
npm test -- shared
```
### Writing New Tests
When adding new components, include tests covering:
- Basic rendering
- Props validation
- Conditional rendering
- User interactions (clicks, form submissions)
- Edge cases (empty arrays, null values)
Example test structure:
```typescript
import { render, screen, fireEvent } from '@testing-library/react';
import { YourComponent } from '../YourComponent';
describe('YourComponent', () => {
it('renders correctly', () => {
render(<YourComponent prop="value" />);
expect(screen.getByText('Expected Text')).toBeInTheDocument();
});
});
```
## CMS Theme System Integration
The shared components work seamlessly with the CMS Theme System (`frontend/src/styles/cms/`):
### Style Presets
Apply predefined style presets:
```typescript
import { applyPreset } from '@/styles/cms/presets';
const preset = applyPreset('modern');
// Use preset colors, typography, and spacing
```
### Color Schemes
Use color schemes for consistent theming:
```typescript
import { getColorScheme, generateCSSVariables } from '@/styles/cms/colors';
const scheme = getColorScheme('blue');
const cssVars = generateCSSVariables(scheme);
```
### Typography
Apply typography presets:
```typescript
import { getTypographyPreset, generateTypographyCSS } from '@/styles/cms/typography';
const typography = getTypographyPreset('modern');
const css = generateTypographyCSS(config, typography);
```
### Component Styles
Customize component styles:
```typescript
import { applyComponentStyles, generateComponentCSS } from '@/styles/cms/components';
const customStyles = applyComponentStyles({
button: { primary: { background: '#custom-color' } }
});
const css = generateComponentCSS(customStyles);
```
## Phase 7 Enhancements
### New Site Management Components
The following components were added in Phase 7 for advanced site management:
**Layout & Template System:**
- `LayoutSelector` - Browse and select page layouts
- `TemplateLibrary` - Search and filter page templates
- `LayoutPreview` - Preview layouts with sample content
- `TemplateCustomizer` - Customize template settings and styles
- `StyleEditor` - Edit CSS, colors, typography, and spacing
**Site Management:**
- `SitePreview` - Live iframe preview of deployed sites
- Enhanced `SiteSettings` - SEO, Open Graph, and Schema.org configuration
- Enhanced `PageManager` - Drag-drop reorder and bulk actions
- Enhanced `PostEditor` - Full-featured editing with tabs
- `SiteContentManager` - Advanced content management with search and filters
### Integration with Site Builder
Shared components are used in the Site Builder preview canvas:
- `HeroBlock` for hero sections
- `FeatureGridBlock` for feature displays
- `StatsPanel` for statistics
- `MarketingTemplate` for complete page templates
## Future Enhancements
- [x] Design tokens/theming system (Phase 7 - Completed)
- [x] Component tests (Phase 7 - Completed)
- [ ] Storybook integration for component documentation
- [ ] Design tokens/theming system
- [ ] Animation utilities
- [ ] Form components library
- [ ] Icon library integration
- [ ] Accessibility audit and improvements
- [ ] Performance optimization (lazy loading, code splitting)

View File

@@ -0,0 +1,59 @@
/**
* Tests for FeatureGridBlock component
*/
import { render, screen } from '@testing-library/react';
import { FeatureGridBlock } from '../FeatureGridBlock';
describe('FeatureGridBlock', () => {
const mockFeatures = [
{ title: 'Feature 1', description: 'Description 1' },
{ title: 'Feature 2', description: 'Description 2' },
{ title: 'Feature 3' },
];
it('renders heading when provided', () => {
render(<FeatureGridBlock heading="Features" features={mockFeatures} />);
expect(screen.getByText('Features')).toBeInTheDocument();
});
it('renders all features', () => {
render(<FeatureGridBlock features={mockFeatures} />);
expect(screen.getByText('Feature 1')).toBeInTheDocument();
expect(screen.getByText('Feature 2')).toBeInTheDocument();
expect(screen.getByText('Feature 3')).toBeInTheDocument();
});
it('renders feature descriptions when provided', () => {
render(<FeatureGridBlock features={mockFeatures} />);
expect(screen.getByText('Description 1')).toBeInTheDocument();
expect(screen.getByText('Description 2')).toBeInTheDocument();
});
it('renders feature icons when provided', () => {
const featuresWithIcons = [
{ title: 'Feature 1', icon: '🚀' },
{ title: 'Feature 2', icon: '⭐' },
];
render(<FeatureGridBlock features={featuresWithIcons} />);
expect(screen.getByText('🚀')).toBeInTheDocument();
expect(screen.getByText('⭐')).toBeInTheDocument();
});
it('uses default columns value of 3', () => {
const { container } = render(<FeatureGridBlock features={mockFeatures} />);
const grid = container.querySelector('.shared-grid--3');
expect(grid).toBeInTheDocument();
});
it('applies correct columns class', () => {
const { container } = render(<FeatureGridBlock features={mockFeatures} columns={2} />);
const grid = container.querySelector('.shared-grid--2');
expect(grid).toBeInTheDocument();
});
it('handles empty features array', () => {
render(<FeatureGridBlock features={[]} />);
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
/**
* Tests for HeroBlock component
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { HeroBlock } from '../HeroBlock';
describe('HeroBlock', () => {
it('renders title correctly', () => {
render(<HeroBlock title="Test Hero Title" />);
expect(screen.getByText('Test Hero Title')).toBeInTheDocument();
});
it('renders subtitle when provided', () => {
render(<HeroBlock title="Title" subtitle="Subtitle text" />);
expect(screen.getByText('Subtitle text')).toBeInTheDocument();
});
it('renders eyebrow when provided', () => {
render(<HeroBlock title="Title" eyebrow="Eyebrow text" />);
expect(screen.getByText('Eyebrow text')).toBeInTheDocument();
});
it('renders CTA button when ctaLabel is provided', () => {
render(<HeroBlock title="Title" ctaLabel="Click Me" />);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
it('calls onCtaClick when CTA button is clicked', () => {
const handleClick = jest.fn();
render(<HeroBlock title="Title" ctaLabel="Click Me" onCtaClick={handleClick} />);
const button = screen.getByText('Click Me');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders supporting content when provided', () => {
render(
<HeroBlock
title="Title"
supportingContent={<div>Supporting content</div>}
/>
);
expect(screen.getByText('Supporting content')).toBeInTheDocument();
});
it('does not render CTA button when ctaLabel is not provided', () => {
render(<HeroBlock title="Title" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Tests for StatsPanel component
*/
import { render, screen } from '@testing-library/react';
import { StatsPanel } from '../StatsPanel';
describe('StatsPanel', () => {
const mockStats = [
{ label: 'Users', value: '1,234' },
{ label: 'Revenue', value: '$50,000' },
{ label: 'Growth', value: '25%' },
];
it('renders heading when provided', () => {
render(<StatsPanel heading="Statistics" stats={mockStats} />);
expect(screen.getByText('Statistics')).toBeInTheDocument();
});
it('renders all stats', () => {
render(<StatsPanel stats={mockStats} />);
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('1,234')).toBeInTheDocument();
expect(screen.getByText('Revenue')).toBeInTheDocument();
expect(screen.getByText('$50,000')).toBeInTheDocument();
});
it('handles numeric values', () => {
const numericStats = [
{ label: 'Count', value: 1234 },
{ label: 'Percentage', value: 95 },
];
render(<StatsPanel stats={numericStats} />);
expect(screen.getByText('1234')).toBeInTheDocument();
expect(screen.getByText('95')).toBeInTheDocument();
});
it('handles empty stats array', () => {
render(<StatsPanel stats={[]} />);
expect(screen.queryByText('Users')).not.toBeInTheDocument();
});
it('renders stat descriptions when provided', () => {
const statsWithDescriptions = [
{ label: 'Users', value: '1,234', description: 'Active users' },
];
render(<StatsPanel stats={statsWithDescriptions} />);
expect(screen.getByText('Active users')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Tests for DefaultLayout component
*/
import { render, screen } from '@testing-library/react';
import { DefaultLayout } from '../DefaultLayout';
describe('DefaultLayout', () => {
it('renders sections correctly', () => {
const sections = [<div key="1">Section 1</div>, <div key="2">Section 2</div>];
render(<DefaultLayout sections={sections} />);
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Section 2')).toBeInTheDocument();
});
it('renders hero when provided', () => {
const hero = <div>Hero Content</div>;
render(<DefaultLayout hero={hero} sections={[]} />);
expect(screen.getByText('Hero Content')).toBeInTheDocument();
});
it('renders sidebar when provided', () => {
const sidebar = <div>Sidebar Content</div>;
render(<DefaultLayout sections={[]} sidebar={sidebar} />);
expect(screen.getByText('Sidebar Content')).toBeInTheDocument();
});
it('renders all sections together', () => {
const hero = <div>Hero</div>;
const sections = [<div key="1">Section</div>];
const sidebar = <div>Sidebar</div>;
render(
<DefaultLayout
hero={hero}
sections={sections}
sidebar={sidebar}
/>
);
expect(screen.getByText('Hero')).toBeInTheDocument();
expect(screen.getByText('Section')).toBeInTheDocument();
expect(screen.getByText('Sidebar')).toBeInTheDocument();
});
it('handles empty sections array', () => {
render(<DefaultLayout sections={[]} />);
expect(screen.queryByText('Section')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,55 @@
/**
* Tests for MarketingTemplate component
*/
import { render, screen } from '@testing-library/react';
import { MarketingTemplate } from '../MarketingTemplate';
import { HeroBlock } from '../../blocks/HeroBlock';
describe('MarketingTemplate', () => {
it('renders hero section when provided', () => {
const heroSection = <HeroBlock title="Hero Title" subtitle="Hero Subtitle" />;
render(<MarketingTemplate hero={heroSection} sections={[]} />);
expect(screen.getByText('Hero Title')).toBeInTheDocument();
expect(screen.getByText('Hero Subtitle')).toBeInTheDocument();
});
it('renders sections when provided', () => {
const sections = [
<div key="1">Section 1</div>,
<div key="2">Section 2</div>,
];
render(<MarketingTemplate hero={null} sections={sections} />);
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Section 2')).toBeInTheDocument();
});
it('renders sidebar when provided', () => {
const sidebar = <div>Sidebar Content</div>;
render(<MarketingTemplate hero={null} sections={[]} sidebar={sidebar} />);
expect(screen.getByText('Sidebar Content')).toBeInTheDocument();
});
it('handles empty sections', () => {
render(<MarketingTemplate hero={null} sections={[]} />);
expect(screen.queryByText('Section')).not.toBeInTheDocument();
});
it('renders all sections together', () => {
const heroSection = <HeroBlock title="Hero" />;
const sections = [<div key="1">Content</div>];
const sidebar = <div>Sidebar</div>;
render(
<MarketingTemplate
hero={heroSection}
sections={sections}
sidebar={sidebar}
/>
);
expect(screen.getByText('Hero')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.getByText('Sidebar')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,167 @@
/**
* Layout Preview
* Phase 7: Layout & Template System
* Component for previewing layouts with sample content
*/
import React, { useState } from 'react';
import { EyeIcon, XIcon, Maximize2Icon } from 'lucide-react';
import { Card } from '../../ui/card';
import Button from '../../ui/button/Button';
export interface LayoutPreviewProps {
layoutId: string;
layoutName: string;
onClose?: () => void;
onSelect?: () => void;
}
const LAYOUT_PREVIEWS: Record<string, React.ReactNode> = {
default: (
<div className="space-y-4">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Header</span>
</div>
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Main Content Area</span>
</div>
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Footer</span>
</div>
</div>
),
minimal: (
<div className="space-y-4">
<div className="h-96 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Focused Content Area</span>
</div>
</div>
),
magazine: (
<div className="space-y-4">
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Header</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 h-64 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Featured Content</span>
</div>
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Sidebar</span>
</div>
</div>
</div>
),
blog: (
<div className="space-y-4">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Header</span>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="col-span-3 space-y-3">
<div className="h-32 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Blog Post 1</span>
</div>
<div className="h-32 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Blog Post 2</span>
</div>
</div>
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Sidebar</span>
</div>
</div>
</div>
),
ecommerce: (
<div className="space-y-4">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Navigation</span>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Filters</span>
</div>
<div className="col-span-3 grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-40 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400 text-xs">Product {i}</span>
</div>
))}
</div>
</div>
</div>
),
portfolio: (
<div className="space-y-4">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Header</span>
</div>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-48 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Project {i}</span>
</div>
))}
</div>
</div>
),
corporate: (
<div className="space-y-4">
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Header</span>
</div>
<div className="h-32 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Hero Section</span>
</div>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Section {i}</span>
</div>
))}
</div>
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-gray-600 dark:text-gray-400">Footer</span>
</div>
</div>
),
};
export default function LayoutPreview({ layoutId, layoutName, onClose, onSelect }: LayoutPreviewProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const previewContent = LAYOUT_PREVIEWS[layoutId] || LAYOUT_PREVIEWS.default;
return (
<div className={`${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-gray-900' : ''}`}>
<Card className={`${isFullscreen ? 'h-full m-0 rounded-none' : ''} p-6`}>
<div className="mb-4 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{layoutName}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Layout Preview</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(!isFullscreen)}>
<Maximize2Icon className="w-4 h-4 mr-2" />
{isFullscreen ? 'Exit' : 'Fullscreen'}
</Button>
{onSelect && (
<Button variant="primary" size="sm" onClick={onSelect}>
<EyeIcon className="w-4 h-4 mr-2" />
Select Layout
</Button>
)}
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose}>
<XIcon className="w-4 h-4" />
</Button>
)}
</div>
</div>
<div className={`border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-900 ${isFullscreen ? 'h-[calc(100vh-120px)] overflow-auto' : 'max-h-[600px] overflow-auto'}`}>
{previewContent}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,143 @@
/**
* Layout Selector
* Phase 7: Layout & Template System
* Component for selecting page layouts
*/
import React, { useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { Card } from '../../ui/card';
export interface LayoutOption {
id: string;
name: string;
description: string;
preview: string; // Preview image URL or component
category: 'marketing' | 'blog' | 'ecommerce' | 'portfolio' | 'corporate';
}
interface LayoutSelectorProps {
selectedLayout?: string;
onSelect: (layoutId: string) => void;
layouts?: LayoutOption[];
}
const DEFAULT_LAYOUTS: LayoutOption[] = [
{
id: 'default',
name: 'Default Layout',
description: 'Standard layout with header, content, and footer',
preview: '',
category: 'marketing',
},
{
id: 'minimal',
name: 'Minimal Layout',
description: 'Clean, focused layout for content pages',
preview: '',
category: 'marketing',
},
{
id: 'magazine',
name: 'Magazine Layout',
description: 'Magazine-style layout with featured sections',
preview: '',
category: 'blog',
},
{
id: 'blog',
name: 'Blog Layout',
description: 'Blog layout with sidebar support',
preview: '',
category: 'blog',
},
{
id: 'ecommerce',
name: 'Ecommerce Layout',
description: 'E-commerce layout with product focus',
preview: '',
category: 'ecommerce',
},
{
id: 'portfolio',
name: 'Portfolio Layout',
description: 'Portfolio layout for showcasing work',
preview: '',
category: 'portfolio',
},
{
id: 'corporate',
name: 'Corporate Layout',
description: 'Professional corporate layout',
preview: '',
category: 'corporate',
},
];
export default function LayoutSelector({ selectedLayout, onSelect, layouts = DEFAULT_LAYOUTS }: LayoutSelectorProps) {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const categories = ['all', ...Array.from(new Set(layouts.map((l) => l.category)))];
const filteredLayouts = selectedCategory === 'all'
? layouts
: layouts.filter((l) => l.category === selectedCategory);
return (
<div className="space-y-4">
{/* Category Filter */}
<div className="flex gap-2 flex-wrap">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
selectedCategory === category
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</button>
))}
</div>
{/* Layout Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredLayouts.map((layout) => (
<Card
key={layout.id}
className={`p-4 cursor-pointer transition-all hover:shadow-lg ${
selectedLayout === layout.id
? 'ring-2 ring-brand-500 border-brand-500'
: 'border-gray-200 dark:border-gray-700'
}`}
onClick={() => onSelect(layout.id)}
>
<div className="relative">
{layout.preview ? (
<img
src={layout.preview}
alt={layout.name}
className="w-full h-32 object-cover rounded-md mb-3"
/>
) : (
<div className="w-full h-32 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 rounded-md mb-3 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-600 text-sm">{layout.name}</span>
</div>
)}
{selectedLayout === layout.id && (
<div className="absolute top-2 right-2 bg-brand-500 text-white rounded-full p-1">
<CheckIcon className="w-4 h-4" />
</div>
)}
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{layout.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{layout.description}</p>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
/**
* Style Editor
* Phase 7: Layout & Template System
* Component for editing CSS styles and theme customization
*/
import React, { useState } from 'react';
import { CodeIcon, PaletteIcon, TypeIcon, SaveIcon, RefreshCwIcon } from 'lucide-react';
import { Card } from '../../ui/card';
import Button from '../../ui/button/Button';
import Label from '../../form/Label';
import TextArea from '../../form/input/TextArea';
import { useToast } from '../../ui/toast/ToastContainer';
export interface StyleSettings {
customCSS?: string;
colorPalette?: {
primary: string;
secondary: string;
accent: string;
background: string;
text: string;
};
typography?: {
fontFamily: string;
headingFont: string;
fontSize: string;
lineHeight: string;
};
spacing?: {
base: string;
scale: string;
};
}
interface StyleEditorProps {
styleSettings: StyleSettings;
onChange: (settings: StyleSettings) => void;
onSave?: () => void;
onReset?: () => void;
}
export default function StyleEditor({ styleSettings, onChange, onSave, onReset }: StyleEditorProps) {
const toast = useToast();
const [activeTab, setActiveTab] = useState<'css' | 'colors' | 'typography' | 'spacing'>('css');
const [customCSS, setCustomCSS] = useState(styleSettings.customCSS || '');
const updateSettings = (updates: Partial<StyleSettings>) => {
onChange({ ...styleSettings, ...updates });
};
const handleCSSChange = (value: string) => {
setCustomCSS(value);
updateSettings({ customCSS: value });
};
const handleColorChange = (key: string, value: string) => {
updateSettings({
colorPalette: {
...styleSettings.colorPalette,
[key]: value,
} as StyleSettings['colorPalette'],
});
};
const handleTypographyChange = (key: string, value: string) => {
updateSettings({
typography: {
...styleSettings.typography,
[key]: value,
} as StyleSettings['typography'],
});
};
const handleSpacingChange = (key: string, value: string) => {
updateSettings({
spacing: {
...styleSettings.spacing,
[key]: value,
} as StyleSettings['spacing'],
});
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Style Editor</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Customize CSS, colors, typography, and spacing</p>
</div>
<div className="flex gap-2">
{onReset && (
<Button variant="outline" onClick={onReset}>
<RefreshCwIcon className="w-4 h-4 mr-2" />
Reset
</Button>
)}
{onSave && (
<Button variant="primary" onClick={onSave}>
<SaveIcon className="w-4 h-4 mr-2" />
Save Styles
</Button>
)}
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-4">
<button
type="button"
onClick={() => setActiveTab('css')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'css'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<CodeIcon className="w-4 h-4 inline mr-2" />
Custom CSS
</button>
<button
type="button"
onClick={() => setActiveTab('colors')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'colors'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<PaletteIcon className="w-4 h-4 inline mr-2" />
Colors
</button>
<button
type="button"
onClick={() => setActiveTab('typography')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'typography'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<TypeIcon className="w-4 h-4 inline mr-2" />
Typography
</button>
<button
type="button"
onClick={() => setActiveTab('spacing')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'spacing'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
Spacing
</button>
</div>
</div>
{/* Custom CSS Tab */}
{activeTab === 'css' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Custom CSS</Label>
<TextArea
value={customCSS}
onChange={handleCSSChange}
rows={20}
placeholder="/* Add your custom CSS here */&#10;.custom-class {&#10; color: #333;&#10;}"
className="mt-1 font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Add custom CSS rules. These will be applied to your site.
</p>
</div>
</div>
</Card>
)}
{/* Colors Tab */}
{activeTab === 'colors' && (
<Card className="p-6">
<div className="space-y-4">
{['primary', 'secondary', 'accent', 'background', 'text'].map((colorKey) => (
<div key={colorKey}>
<Label>{colorKey.charAt(0).toUpperCase() + colorKey.slice(1)} Color</Label>
<div className="flex gap-2 mt-1">
<input
type="color"
value={styleSettings.colorPalette?.[colorKey as keyof typeof styleSettings.colorPalette] || '#000000'}
onChange={(e) => handleColorChange(colorKey, e.target.value)}
className="w-16 h-10 rounded border border-gray-300 dark:border-gray-700 cursor-pointer"
/>
<input
type="text"
value={styleSettings.colorPalette?.[colorKey as keyof typeof styleSettings.colorPalette] || ''}
onChange={(e) => handleColorChange(colorKey, e.target.value)}
placeholder="#000000"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
))}
</div>
</Card>
)}
{/* Typography Tab */}
{activeTab === 'typography' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Font Family</Label>
<input
type="text"
value={styleSettings.typography?.fontFamily || ''}
onChange={(e) => handleTypographyChange('fontFamily', e.target.value)}
placeholder="Arial, sans-serif"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<Label>Heading Font</Label>
<input
type="text"
value={styleSettings.typography?.headingFont || ''}
onChange={(e) => handleTypographyChange('headingFont', e.target.value)}
placeholder="Georgia, serif"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<Label>Base Font Size</Label>
<input
type="text"
value={styleSettings.typography?.fontSize || ''}
onChange={(e) => handleTypographyChange('fontSize', e.target.value)}
placeholder="16px"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<Label>Line Height</Label>
<input
type="text"
value={styleSettings.typography?.lineHeight || ''}
onChange={(e) => handleTypographyChange('lineHeight', e.target.value)}
placeholder="1.5"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
</Card>
)}
{/* Spacing Tab */}
{activeTab === 'spacing' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Base Spacing Unit</Label>
<input
type="text"
value={styleSettings.spacing?.base || ''}
onChange={(e) => handleSpacingChange('base', e.target.value)}
placeholder="8px"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Base unit for spacing calculations (e.g., 8px, 1rem)
</p>
</div>
<div>
<Label>Spacing Scale</Label>
<input
type="text"
value={styleSettings.spacing?.scale || ''}
onChange={(e) => handleSpacingChange('scale', e.target.value)}
placeholder="1.5"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Multiplier for spacing scale (e.g., 1.5 for 1.5x spacing)
</p>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,376 @@
/**
* Template Customizer
* Phase 7: Layout & Template System
* Component for customizing template settings and styles
*/
import React, { useState } from 'react';
import { SettingsIcon, PaletteIcon, TypeIcon, LayoutIcon } from 'lucide-react';
import { Card } from '../../ui/card';
import Label from '../../form/Label';
import SelectDropdown from '../../form/SelectDropdown';
import Button from '../../ui/button/Button';
export interface TemplateCustomization {
layout: string;
colorScheme: string;
typography: string;
spacing: string;
headerStyle: string;
footerStyle: string;
sidebarPosition?: 'left' | 'right' | 'none';
customStyles?: Record<string, any>;
}
interface TemplateCustomizerProps {
templateId: string;
templateName: string;
customization: TemplateCustomization;
onChange: (customization: TemplateCustomization) => void;
onSave?: () => void;
onReset?: () => void;
}
export default function TemplateCustomizer({
templateId,
templateName,
customization,
onChange,
onSave,
onReset,
}: TemplateCustomizerProps) {
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'typography' | 'spacing'>('layout');
const updateCustomization = (updates: Partial<TemplateCustomization>) => {
onChange({ ...customization, ...updates });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Customize: {templateName}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Adjust template settings and styles</p>
</div>
<div className="flex gap-2">
{onReset && (
<Button variant="outline" onClick={onReset}>
Reset
</Button>
)}
{onSave && (
<Button variant="primary" onClick={onSave}>
Save Changes
</Button>
)}
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-4">
<button
type="button"
onClick={() => setActiveTab('layout')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'layout'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<LayoutIcon className="w-4 h-4 inline mr-2" />
Layout
</button>
<button
type="button"
onClick={() => setActiveTab('colors')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'colors'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<PaletteIcon className="w-4 h-4 inline mr-2" />
Colors
</button>
<button
type="button"
onClick={() => setActiveTab('typography')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'typography'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<TypeIcon className="w-4 h-4 inline mr-2" />
Typography
</button>
<button
type="button"
onClick={() => setActiveTab('spacing')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'spacing'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<SettingsIcon className="w-4 h-4 inline mr-2" />
Spacing
</button>
</div>
</div>
{/* Layout Tab */}
{activeTab === 'layout' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Layout Style</Label>
<SelectDropdown
options={[
{ value: 'default', label: 'Default' },
{ value: 'wide', label: 'Wide' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'fullwidth', label: 'Full Width' },
]}
value={customization.layout}
onChange={(e) => updateCustomization({ layout: e.target.value })}
/>
</div>
<div>
<Label>Header Style</Label>
<SelectDropdown
options={[
{ value: 'default', label: 'Default' },
{ value: 'sticky', label: 'Sticky' },
{ value: 'transparent', label: 'Transparent' },
{ value: 'minimal', label: 'Minimal' },
]}
value={customization.headerStyle}
onChange={(e) => updateCustomization({ headerStyle: e.target.value })}
/>
</div>
<div>
<Label>Footer Style</Label>
<SelectDropdown
options={[
{ value: 'default', label: 'Default' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'extended', label: 'Extended' },
{ value: 'none', label: 'No Footer' },
]}
value={customization.footerStyle}
onChange={(e) => updateCustomization({ footerStyle: e.target.value })}
/>
</div>
{customization.sidebarPosition !== undefined && (
<div>
<Label>Sidebar Position</Label>
<SelectDropdown
options={[
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
{ value: 'none', label: 'No Sidebar' },
]}
value={customization.sidebarPosition}
onChange={(e) => updateCustomization({ sidebarPosition: e.target.value as 'left' | 'right' | 'none' })}
/>
</div>
)}
</div>
</Card>
)}
{/* Colors Tab */}
{activeTab === 'colors' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Color Scheme</Label>
<SelectDropdown
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
]}
value={customization.colorScheme}
onChange={(e) => updateCustomization({ colorScheme: e.target.value })}
/>
</div>
<div>
<Label>Primary Color</Label>
<div className="flex gap-2 mt-1">
<input
type="color"
value={customization.customStyles?.primaryColor || '#3b82f6'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, primaryColor: e.target.value },
})
}
className="w-16 h-10 rounded border border-gray-300 dark:border-gray-700 cursor-pointer"
/>
<input
type="text"
value={customization.customStyles?.primaryColor || '#3b82f6'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, primaryColor: e.target.value },
})
}
placeholder="#3b82f6"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
<div>
<Label>Background Color</Label>
<div className="flex gap-2 mt-1">
<input
type="color"
value={customization.customStyles?.backgroundColor || '#ffffff'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, backgroundColor: e.target.value },
})
}
className="w-16 h-10 rounded border border-gray-300 dark:border-gray-700 cursor-pointer"
/>
<input
type="text"
value={customization.customStyles?.backgroundColor || '#ffffff'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, backgroundColor: e.target.value },
})
}
placeholder="#ffffff"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
</div>
</Card>
)}
{/* Typography Tab */}
{activeTab === 'typography' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Typography Style</Label>
<SelectDropdown
options={[
{ value: 'sans-serif', label: 'Sans Serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'custom', label: 'Custom' },
]}
value={customization.typography}
onChange={(e) => updateCustomization({ typography: e.target.value })}
/>
</div>
<div>
<Label>Heading Font Size</Label>
<SelectDropdown
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'xlarge', label: 'Extra Large' },
]}
value={customization.customStyles?.headingSize || 'medium'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, headingSize: e.target.value },
})
}
/>
</div>
<div>
<Label>Body Font Size</Label>
<SelectDropdown
options={[
{ value: 'small', label: 'Small (14px)' },
{ value: 'medium', label: 'Medium (16px)' },
{ value: 'large', label: 'Large (18px)' },
]}
value={customization.customStyles?.bodySize || 'medium'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, bodySize: e.target.value },
})
}
/>
</div>
</div>
</Card>
)}
{/* Spacing Tab */}
{activeTab === 'spacing' && (
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Content Spacing</Label>
<SelectDropdown
options={[
{ value: 'compact', label: 'Compact' },
{ value: 'normal', label: 'Normal' },
{ value: 'relaxed', label: 'Relaxed' },
{ value: 'spacious', label: 'Spacious' },
]}
value={customization.spacing}
onChange={(e) => updateCustomization({ spacing: e.target.value })}
/>
</div>
<div>
<Label>Section Padding</Label>
<SelectDropdown
options={[
{ value: 'none', label: 'None' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
value={customization.customStyles?.sectionPadding || 'medium'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, sectionPadding: e.target.value },
})
}
/>
</div>
<div>
<Label>Container Max Width</Label>
<SelectDropdown
options={[
{ value: 'sm', label: 'Small (640px)' },
{ value: 'md', label: 'Medium (768px)' },
{ value: 'lg', label: 'Large (1024px)' },
{ value: 'xl', label: 'Extra Large (1280px)' },
{ value: 'full', label: 'Full Width' },
]}
value={customization.customStyles?.containerWidth || 'lg'}
onChange={(e) =>
updateCustomization({
customStyles: { ...customization.customStyles, containerWidth: e.target.value },
})
}
/>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
/**
* Template Library
* Phase 7: Layout & Template System
* Component for browsing and selecting page templates
*/
import React, { useState } from 'react';
import { SearchIcon, FilterIcon, CheckIcon } from 'lucide-react';
import { Card } from '../../ui/card';
import Button from '../../ui/button/Button';
export interface TemplateOption {
id: string;
name: string;
description: string;
preview: string; // Preview image URL
category: 'landing' | 'marketing' | 'blog' | 'business' | 'portfolio' | 'ecommerce';
tags: string[];
featured?: boolean;
}
interface TemplateLibraryProps {
selectedTemplate?: string;
onSelect: (templateId: string) => void;
templates?: TemplateOption[];
onPreview?: (templateId: string) => void;
}
const DEFAULT_TEMPLATES: TemplateOption[] = [
{
id: 'marketing-1',
name: 'Modern Marketing',
description: 'Clean, conversion-focused marketing template',
preview: '',
category: 'marketing',
tags: ['modern', 'conversion', 'marketing'],
featured: true,
},
{
id: 'landing-1',
name: 'Landing Page Pro',
description: 'High-converting landing page template',
preview: '',
category: 'landing',
tags: ['landing', 'conversion', 'hero'],
featured: true,
},
{
id: 'blog-1',
name: 'Blog Classic',
description: 'Traditional blog layout with sidebar',
preview: '',
category: 'blog',
tags: ['blog', 'sidebar', 'content'],
},
{
id: 'business-1',
name: 'Business Professional',
description: 'Corporate business template',
preview: '',
category: 'business',
tags: ['corporate', 'professional', 'business'],
},
{
id: 'portfolio-1',
name: 'Portfolio Showcase',
description: 'Portfolio template for creative work',
preview: '',
category: 'portfolio',
tags: ['portfolio', 'showcase', 'creative'],
},
{
id: 'ecommerce-1',
name: 'Ecommerce Store',
description: 'Full-featured e-commerce template',
preview: '',
category: 'ecommerce',
tags: ['ecommerce', 'store', 'products'],
},
];
export default function TemplateLibrary({
selectedTemplate,
onSelect,
templates = DEFAULT_TEMPLATES,
onPreview,
}: TemplateLibraryProps) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showFeaturedOnly, setShowFeaturedOnly] = useState(false);
const categories = ['all', ...Array.from(new Set(templates.map((t) => t.category)))];
const filteredTemplates = templates.filter((template) => {
const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.tags.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory = selectedCategory === 'all' || template.category === selectedCategory;
const matchesFeatured = !showFeaturedOnly || template.featured;
return matchesSearch && matchesCategory && matchesFeatured;
});
return (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search templates..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowFeaturedOnly(!showFeaturedOnly)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
showFeaturedOnly
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<FilterIcon className="w-4 h-4 inline mr-2" />
Featured Only
</button>
</div>
</div>
{/* Category Filter */}
<div className="flex gap-2 flex-wrap">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
selectedCategory === category
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</button>
))}
</div>
{/* Template Grid */}
{filteredTemplates.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">No templates found matching your criteria.</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTemplates.map((template) => (
<Card
key={template.id}
className={`p-4 transition-all hover:shadow-lg ${
selectedTemplate === template.id
? 'ring-2 ring-brand-500 border-brand-500'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="relative mb-3">
{template.preview ? (
<img
src={template.preview}
alt={template.name}
className="w-full h-40 object-cover rounded-md"
/>
) : (
<div className="w-full h-40 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 rounded-md flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-600 text-sm">{template.name}</span>
</div>
)}
{template.featured && (
<span className="absolute top-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded">
Featured
</span>
)}
{selectedTemplate === template.id && (
<div className="absolute top-2 right-2 bg-brand-500 text-white rounded-full p-1">
<CheckIcon className="w-4 h-4" />
</div>
)}
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{template.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{template.description}</p>
<div className="flex gap-2 flex-wrap mb-3">
{template.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400"
>
{tag}
</span>
))}
</div>
<div className="flex gap-2">
<Button
variant={selectedTemplate === template.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onSelect(template.id)}
className="flex-1"
>
{selectedTemplate === template.id ? 'Selected' : 'Select'}
</Button>
{onPreview && (
<Button variant="outline" size="sm" onClick={() => onPreview(template.id)}>
Preview
</Button>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}