mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
import { useState } from 'react'
|
|
import { ThemeConfig, ThemeVariant } from '@/types/project'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Slider } from '@/components/ui/slider'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { PaintBrush, Sparkle, Plus, Trash, Moon, Sun, Palette } from '@phosphor-icons/react'
|
|
import { AIService } from '@/lib/ai-service'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
|
|
interface StyleDesignerProps {
|
|
theme: ThemeConfig
|
|
onThemeChange: (theme: ThemeConfig | ((current: ThemeConfig) => ThemeConfig)) => void
|
|
}
|
|
|
|
export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
|
|
const [newColorName, setNewColorName] = useState('')
|
|
const [newColorValue, setNewColorValue] = useState('#000000')
|
|
const [customColorDialogOpen, setCustomColorDialogOpen] = useState(false)
|
|
const [newVariantDialogOpen, setNewVariantDialogOpen] = useState(false)
|
|
const [newVariantName, setNewVariantName] = useState('')
|
|
|
|
const activeVariant = theme.variants.find((v) => v.id === theme.activeVariantId) || theme.variants[0]
|
|
|
|
const updateTheme = (updates: Partial<ThemeConfig>) => {
|
|
onThemeChange((current) => ({ ...current, ...updates }))
|
|
}
|
|
|
|
const updateActiveVariantColors = (colorUpdates: Partial<typeof activeVariant.colors>) => {
|
|
onThemeChange((current) => ({
|
|
...current,
|
|
variants: current.variants.map((v) =>
|
|
v.id === current.activeVariantId
|
|
? { ...v, colors: { ...v.colors, ...colorUpdates } }
|
|
: v
|
|
),
|
|
}))
|
|
}
|
|
|
|
const addCustomColor = () => {
|
|
if (!newColorName.trim()) {
|
|
toast.error('Please enter a color name')
|
|
return
|
|
}
|
|
|
|
updateActiveVariantColors({
|
|
customColors: {
|
|
...activeVariant.colors.customColors,
|
|
[newColorName]: newColorValue,
|
|
},
|
|
})
|
|
|
|
setNewColorName('')
|
|
setNewColorValue('#000000')
|
|
setCustomColorDialogOpen(false)
|
|
toast.success(`Added custom color: ${newColorName}`)
|
|
}
|
|
|
|
const removeCustomColor = (colorName: string) => {
|
|
const { [colorName]: _, ...remainingColors } = activeVariant.colors.customColors
|
|
updateActiveVariantColors({
|
|
customColors: remainingColors,
|
|
})
|
|
toast.success(`Removed custom color: ${colorName}`)
|
|
}
|
|
|
|
const addVariant = () => {
|
|
if (!newVariantName.trim()) {
|
|
toast.error('Please enter a variant name')
|
|
return
|
|
}
|
|
|
|
const newVariant: ThemeVariant = {
|
|
id: `variant-${Date.now()}`,
|
|
name: newVariantName,
|
|
colors: { ...activeVariant.colors, customColors: {} },
|
|
}
|
|
|
|
onThemeChange((current) => ({
|
|
...current,
|
|
variants: [...current.variants, newVariant],
|
|
activeVariantId: newVariant.id,
|
|
}))
|
|
|
|
setNewVariantName('')
|
|
setNewVariantDialogOpen(false)
|
|
toast.success(`Added theme variant: ${newVariantName}`)
|
|
}
|
|
|
|
const deleteVariant = (variantId: string) => {
|
|
if (theme.variants.length <= 1) {
|
|
toast.error('Cannot delete the last theme variant')
|
|
return
|
|
}
|
|
|
|
onThemeChange((current) => {
|
|
const remainingVariants = current.variants.filter((v) => v.id !== variantId)
|
|
return {
|
|
...current,
|
|
variants: remainingVariants,
|
|
activeVariantId: current.activeVariantId === variantId ? remainingVariants[0].id : current.activeVariantId,
|
|
}
|
|
})
|
|
|
|
toast.success('Theme variant deleted')
|
|
}
|
|
|
|
const duplicateVariant = (variantId: string) => {
|
|
const variantToDuplicate = theme.variants.find((v) => v.id === variantId)
|
|
if (!variantToDuplicate) return
|
|
|
|
const newVariant: ThemeVariant = {
|
|
id: `variant-${Date.now()}`,
|
|
name: `${variantToDuplicate.name} Copy`,
|
|
colors: { ...variantToDuplicate.colors, customColors: { ...variantToDuplicate.colors.customColors } },
|
|
}
|
|
|
|
onThemeChange((current) => ({
|
|
...current,
|
|
variants: [...current.variants, newVariant],
|
|
}))
|
|
|
|
toast.success('Theme variant duplicated')
|
|
}
|
|
|
|
const generateThemeWithAI = async () => {
|
|
const description = prompt('Describe the visual style you want (e.g., "modern and professional", "vibrant and playful"):')
|
|
if (!description) return
|
|
|
|
try {
|
|
toast.info('Generating theme with AI...')
|
|
const generatedTheme = await AIService.generateThemeFromDescription(description)
|
|
|
|
if (generatedTheme) {
|
|
onThemeChange((current) => ({ ...current, ...generatedTheme }))
|
|
toast.success('Theme generated successfully!')
|
|
} else {
|
|
toast.error('AI generation failed. Please try again.')
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to generate theme')
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
const renderColorInput = (label: string, colorKey: keyof typeof activeVariant.colors, excludeCustom = true) => {
|
|
if (excludeCustom && colorKey === 'customColors') return null
|
|
|
|
const value = activeVariant.colors[colorKey] as string
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label>{label}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
|
|
className="w-20 h-10 cursor-pointer"
|
|
/>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
|
|
className="flex-1 font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-auto p-6">
|
|
<div className="max-w-5xl mx-auto space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold mb-2">Theme Designer</h2>
|
|
<p className="text-muted-foreground">
|
|
Create and customize multiple theme variants with custom colors
|
|
</p>
|
|
</div>
|
|
<Button onClick={generateThemeWithAI} variant="outline">
|
|
<Sparkle size={16} className="mr-2" weight="duotone" />
|
|
Generate with AI
|
|
</Button>
|
|
</div>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
<Palette size={20} weight="duotone" />
|
|
Theme Variants
|
|
</h3>
|
|
<Button onClick={() => setNewVariantDialogOpen(true)} size="sm">
|
|
<Plus size={16} className="mr-2" />
|
|
Add Variant
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{theme.variants.map((variant) => (
|
|
<div key={variant.id} className="flex items-center gap-2 group">
|
|
<Button
|
|
variant={theme.activeVariantId === variant.id ? 'default' : 'outline'}
|
|
onClick={() => updateTheme({ activeVariantId: variant.id })}
|
|
className="gap-2"
|
|
>
|
|
{variant.name === 'Light' && <Sun size={16} weight="duotone" />}
|
|
{variant.name === 'Dark' && <Moon size={16} weight="duotone" />}
|
|
{variant.name}
|
|
</Button>
|
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => duplicateVariant(variant.id)}
|
|
title="Duplicate"
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => deleteVariant(variant.id)}
|
|
disabled={theme.variants.length <= 1}
|
|
title="Delete"
|
|
>
|
|
<Trash size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Tabs defaultValue="standard" className="w-full">
|
|
<TabsList>
|
|
<TabsTrigger value="standard">Standard Colors</TabsTrigger>
|
|
<TabsTrigger value="extended">Extended Colors</TabsTrigger>
|
|
<TabsTrigger value="custom">Custom Colors ({Object.keys(activeVariant.colors.customColors).length})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="standard" className="space-y-6 mt-6">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{renderColorInput('Primary Color', 'primaryColor')}
|
|
{renderColorInput('Secondary Color', 'secondaryColor')}
|
|
{renderColorInput('Error Color', 'errorColor')}
|
|
{renderColorInput('Warning Color', 'warningColor')}
|
|
{renderColorInput('Success Color', 'successColor')}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="extended" className="space-y-6 mt-6">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{renderColorInput('Background', 'background')}
|
|
{renderColorInput('Surface', 'surface')}
|
|
{renderColorInput('Text', 'text')}
|
|
{renderColorInput('Text Secondary', 'textSecondary')}
|
|
{renderColorInput('Border', 'border')}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="custom" className="space-y-6 mt-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Add custom colors for your specific needs
|
|
</p>
|
|
<Button onClick={() => setCustomColorDialogOpen(true)} size="sm">
|
|
<Plus size={16} className="mr-2" />
|
|
Add Custom Color
|
|
</Button>
|
|
</div>
|
|
|
|
{Object.keys(activeVariant.colors.customColors).length === 0 ? (
|
|
<div className="text-center py-12 border-2 border-dashed border-border rounded-lg">
|
|
<Palette size={48} className="mx-auto mb-4 text-muted-foreground" weight="duotone" />
|
|
<p className="text-muted-foreground mb-4">No custom colors yet</p>
|
|
<Button onClick={() => setCustomColorDialogOpen(true)} variant="outline">
|
|
<Plus size={16} className="mr-2" />
|
|
Add Your First Custom Color
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{Object.entries(activeVariant.colors.customColors).map(([name, value]) => (
|
|
<div key={name} className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="capitalize">{name}</Label>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => removeCustomColor(name)}
|
|
>
|
|
<Trash size={14} />
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) =>
|
|
updateActiveVariantColors({
|
|
customColors: {
|
|
...activeVariant.colors.customColors,
|
|
[name]: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
className="w-20 h-10 cursor-pointer"
|
|
/>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) =>
|
|
updateActiveVariantColors({
|
|
customColors: {
|
|
...activeVariant.colors.customColors,
|
|
[name]: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
className="flex-1 font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Typography</h3>
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label>Font Family</Label>
|
|
<Input
|
|
value={theme.fontFamily}
|
|
onChange={(e) => updateTheme({ fontFamily: e.target.value })}
|
|
placeholder="Roboto, Arial, sans-serif"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Label>Small Font Size</Label>
|
|
<span className="text-sm text-muted-foreground">
|
|
{theme.fontSize.small}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[theme.fontSize.small]}
|
|
onValueChange={([value]) =>
|
|
updateTheme({
|
|
fontSize: { ...theme.fontSize, small: value },
|
|
})
|
|
}
|
|
min={10}
|
|
max={20}
|
|
step={1}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Label>Medium Font Size</Label>
|
|
<span className="text-sm text-muted-foreground">
|
|
{theme.fontSize.medium}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[theme.fontSize.medium]}
|
|
onValueChange={([value]) =>
|
|
updateTheme({
|
|
fontSize: { ...theme.fontSize, medium: value },
|
|
})
|
|
}
|
|
min={12}
|
|
max={24}
|
|
step={1}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Label>Large Font Size</Label>
|
|
<span className="text-sm text-muted-foreground">
|
|
{theme.fontSize.large}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[theme.fontSize.large]}
|
|
onValueChange={([value]) =>
|
|
updateTheme({
|
|
fontSize: { ...theme.fontSize, large: value },
|
|
})
|
|
}
|
|
min={16}
|
|
max={48}
|
|
step={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Spacing & Shape</h3>
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Label>Base Spacing Unit</Label>
|
|
<span className="text-sm text-muted-foreground">
|
|
{theme.spacing}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[theme.spacing]}
|
|
onValueChange={([value]) => updateTheme({ spacing: value })}
|
|
min={4}
|
|
max={16}
|
|
step={1}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Material UI multiplies this value (e.g., spacing(2) = {theme.spacing * 2}px)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Label>Border Radius</Label>
|
|
<span className="text-sm text-muted-foreground">
|
|
{theme.borderRadius}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[theme.borderRadius]}
|
|
onValueChange={([value]) =>
|
|
updateTheme({ borderRadius: value })
|
|
}
|
|
min={0}
|
|
max={24}
|
|
step={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6" style={{ backgroundColor: activeVariant.colors.background }}>
|
|
<h3 className="text-lg font-semibold mb-4" style={{ color: activeVariant.colors.text }}>
|
|
Preview - {activeVariant.name} Mode
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<div
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
|
style={{
|
|
backgroundColor: activeVariant.colors.primaryColor,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
Primary
|
|
</div>
|
|
<div
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
|
style={{
|
|
backgroundColor: activeVariant.colors.secondaryColor,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
Secondary
|
|
</div>
|
|
<div
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
|
style={{
|
|
backgroundColor: activeVariant.colors.errorColor,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
Error
|
|
</div>
|
|
<div
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
|
style={{
|
|
backgroundColor: activeVariant.colors.warningColor,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
Warning
|
|
</div>
|
|
<div
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
|
style={{
|
|
backgroundColor: activeVariant.colors.successColor,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
Success
|
|
</div>
|
|
{Object.entries(activeVariant.colors.customColors).map(([name, color]) => (
|
|
<div
|
|
key={name}
|
|
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm capitalize"
|
|
style={{
|
|
backgroundColor: color,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
}}
|
|
>
|
|
{name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="p-6"
|
|
style={{
|
|
fontFamily: theme.fontFamily,
|
|
borderRadius: `${theme.borderRadius}px`,
|
|
backgroundColor: activeVariant.colors.surface,
|
|
color: activeVariant.colors.text,
|
|
border: `1px solid ${activeVariant.colors.border}`,
|
|
}}
|
|
>
|
|
<p style={{ fontSize: `${theme.fontSize.large}px`, marginBottom: '8px' }}>
|
|
Large Text Sample
|
|
</p>
|
|
<p style={{ fontSize: `${theme.fontSize.medium}px`, marginBottom: '8px' }}>
|
|
Medium Text Sample
|
|
</p>
|
|
<p style={{ fontSize: `${theme.fontSize.small}px`, color: activeVariant.colors.textSecondary }}>
|
|
Small Text Sample (Secondary)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Dialog open={customColorDialogOpen} onOpenChange={setCustomColorDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add Custom Color</DialogTitle>
|
|
<DialogDescription>
|
|
Create a custom color for your theme variant
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Color Name</Label>
|
|
<Input
|
|
value={newColorName}
|
|
onChange={(e) => setNewColorName(e.target.value)}
|
|
placeholder="e.g., accent, highlight, brand"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Color Value</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="color"
|
|
value={newColorValue}
|
|
onChange={(e) => setNewColorValue(e.target.value)}
|
|
className="w-20 h-10 cursor-pointer"
|
|
/>
|
|
<Input
|
|
value={newColorValue}
|
|
onChange={(e) => setNewColorValue(e.target.value)}
|
|
className="flex-1 font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCustomColorDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={addCustomColor}>Add Color</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={newVariantDialogOpen} onOpenChange={setNewVariantDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add Theme Variant</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new theme variant based on the current one
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Variant Name</Label>
|
|
<Input
|
|
value={newVariantName}
|
|
onChange={(e) => setNewVariantName(e.target.value)}
|
|
placeholder="e.g., High Contrast, Colorblind Friendly"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setNewVariantDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={addVariant}>Add Variant</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|