Spaces:
Running
Running
| import { useState, useCallback, useMemo } from 'react'; | |
| import { useEditorStore } from '../../store/editorStore'; | |
| import type { CssRule, CssProperty } from '@shared/types'; | |
| import { Plus, Trash2, Palette, FileType, Globe } from 'lucide-react'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| function collectSelectorsFromElements(elements: any[]): string[] { | |
| const selectors = new Set<string>(); | |
| const walk = (els: any[]) => { | |
| for (const el of els) { | |
| if (el.props?.id) selectors.add(`#${el.props.id}`); | |
| if (el.tagName) selectors.add(el.tagName); | |
| el.classes?.forEach((c: string) => { | |
| if (c) selectors.add(`.${c}`); | |
| }); | |
| if (el.children) walk(el.children); | |
| } | |
| }; | |
| walk(elements); | |
| return Array.from(selectors).sort(); | |
| } | |
| function rulesToCssText(rules: CssRule[]): string { | |
| return rules | |
| .filter((r: CssRule) => r.enabled) | |
| .map((r: CssRule) => { | |
| const props = r.properties | |
| .filter((p: CssProperty) => p.name && p.value) | |
| .map((p: CssProperty) => { | |
| const important = p.important ? ' !important' : ''; | |
| return ` ${p.name}: ${p.value}${important};`; | |
| }) | |
| .join('\n'); | |
| if (!props) return ''; | |
| const media = r.mediaQuery ? `\n@media ${r.mediaQuery} {\n` : ''; | |
| const closeMedia = r.mediaQuery ? '\n}' : ''; | |
| return `${media}${r.selector} {\n${props}\n}${closeMedia}`; | |
| }) | |
| .filter(Boolean) | |
| .join('\n\n'); | |
| } | |
| export default function CssRulePanel() { | |
| const { | |
| activeFileId, fileTree, elementRegistry, | |
| cssRules, setCssRules, updateFile, | |
| } = useEditorStore(); | |
| const activeFile = useMemo( | |
| () => activeFileId ? fileTree.find(f => f.id === activeFileId) : null, | |
| [activeFileId, fileTree] | |
| ); | |
| // Get elements from linked HTML files | |
| const linkedElementSelectors = useMemo(() => { | |
| if (!activeFileId) return []; | |
| // Find HTML files that link to this CSS file, or CSS files linked FROM HTML | |
| const linkingHtmlFiles = fileTree.filter( | |
| f => f.type === 'html' && (f.linkedFiles || []).includes(activeFileId) | |
| ); | |
| const selectors = new Set<string>(); | |
| for (const hf of linkingHtmlFiles) { | |
| const els = elementRegistry[hf.id] || []; | |
| collectSelectorsFromElements(els).forEach(s => selectors.add(s)); | |
| } | |
| // Also add selectors from all HTML files as fallback | |
| const allHtmlFiles = fileTree.filter(f => f.type === 'html'); | |
| for (const hf of allHtmlFiles) { | |
| const els = elementRegistry[hf.id] || []; | |
| collectSelectorsFromElements(els).forEach(s => selectors.add(s)); | |
| } | |
| return Array.from(selectors).sort(); | |
| }, [activeFileId, fileTree, elementRegistry]); | |
| const rules = useMemo(() => { | |
| if (!activeFileId) return []; | |
| return cssRules[activeFileId] || []; | |
| }, [activeFileId, cssRules]); | |
| const [expandedRule, setExpandedRule] = useState<string | null>(null); | |
| const addRule = useCallback(() => { | |
| if (!activeFileId) return; | |
| const newRule: CssRule = { | |
| id: uuidv4(), | |
| selector: linkedElementSelectors[0] || 'body', | |
| type: 'class', | |
| properties: [{ id: uuidv4(), name: 'color', value: '#333', important: false, type: 'standard', unit: '' }], | |
| enabled: true, | |
| }; | |
| setCssRules(activeFileId, [...rules, newRule]); | |
| setExpandedRule(newRule.id); | |
| }, [activeFileId, linkedElementSelectors, rules, setCssRules]); | |
| const updateRule = useCallback((ruleId: string, updates: Partial<CssRule>) => { | |
| if (!activeFileId) return; | |
| setCssRules(activeFileId, rules.map((r: CssRule) => r.id === ruleId ? { ...r, ...updates } : r)); | |
| }, [activeFileId, rules, setCssRules]); | |
| const removeRule = useCallback((ruleId: string) => { | |
| if (!activeFileId) return; | |
| setCssRules(activeFileId, rules.filter((r: CssRule) => r.id !== ruleId)); | |
| }, [activeFileId, rules, setCssRules]); | |
| const addProperty = useCallback((ruleId: string) => { | |
| const rule = rules.find((r: CssRule) => r.id === ruleId); | |
| if (!rule) return; | |
| const newProp: CssProperty = { id: uuidv4(), name: '', value: '', important: false, type: 'standard', unit: '' }; | |
| updateRule(ruleId, { properties: [...rule.properties, newProp] }); | |
| }, [rules, updateRule]); | |
| const updateProperty = useCallback((ruleId: string, propId: string, updates: Partial<CssProperty>) => { | |
| const rule = rules.find((r: CssRule) => r.id === ruleId); | |
| if (!rule) return; | |
| updateRule(ruleId, { | |
| properties: rule.properties.map((p: CssProperty) => p.id === propId ? { ...p, ...updates } : p), | |
| }); | |
| }, [rules, updateRule]); | |
| const removeProperty = useCallback((ruleId: string, propId: string) => { | |
| const rule = rules.find((r: CssRule) => r.id === ruleId); | |
| if (!rule) return; | |
| updateRule(ruleId, { properties: rule.properties.filter((p: CssProperty) => p.id !== propId) }); | |
| }, [rules, updateRule]); | |
| // Save CSS rules to file content | |
| const applyToFile = useCallback(() => { | |
| if (!activeFileId || !activeFile) return; | |
| const cssText = rulesToCssText(rules); | |
| updateFile(activeFileId, { content: cssText }); | |
| }, [activeFileId, activeFile, rules, updateFile]); | |
| if (!activeFile || activeFile.type !== 'css') { | |
| return ( | |
| <div className="flex items-center justify-center h-full text-surface-500 text-xs p-4"> | |
| Select a CSS file to manage styles | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| <div className="p-3 border-b border-surface-700"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <h3 className="text-xs font-semibold text-surface-300 uppercase tracking-wider flex items-center gap-1.5"> | |
| <FileType className="w-3.5 h-3.5 text-amber-400" /> | |
| CSS Rules | |
| </h3> | |
| <div className="flex items-center gap-1"> | |
| <button onClick={applyToFile} | |
| className="px-2 py-1 text-[10px] bg-primary-600 text-white rounded hover:bg-primary-500"> | |
| Apply to File | |
| </button> | |
| <button onClick={addRule} | |
| className="p-1 text-surface-400 hover:text-primary-400 hover:bg-surface-700 rounded"> | |
| <Plus className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| {linkedElementSelectors.length > 0 && ( | |
| <div className="flex flex-wrap gap-1 mb-2"> | |
| <span className="text-[9px] text-surface-500 mr-1 mt-0.5"> | |
| <Globe className="w-2.5 h-2.5 inline mr-0.5" /> | |
| Elements: | |
| </span> | |
| {linkedElementSelectors.slice(0, 8).map(sel => ( | |
| <button key={sel} | |
| onClick={() => setExpandedRule(sel)} | |
| className="text-[9px] px-1.5 py-0.5 bg-surface-800 text-primary-300 rounded hover:bg-primary-500/20" | |
| > | |
| {sel} | |
| </button> | |
| ))} | |
| {linkedElementSelectors.length > 8 && ( | |
| <span className="text-[9px] text-surface-500">+{linkedElementSelectors.length - 8} more</span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-2 space-y-2"> | |
| {rules.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-8 text-surface-500"> | |
| <Palette className="w-8 h-8 mb-2 opacity-30" /> | |
| <p className="text-xs">No CSS rules yet</p> | |
| <p className="text-[10px] mt-1">Click + to add a rule</p> | |
| </div> | |
| ) : ( | |
| rules.map(rule => ( | |
| <div key={rule.id} className="border border-surface-700/50 rounded-lg overflow-hidden"> | |
| {/* Rule header */} | |
| <div className="flex items-center gap-1 px-2 py-1.5 bg-surface-800/50" | |
| onClick={() => setExpandedRule(expandedRule === rule.id ? null : rule.id)}> | |
| <input type="text" value={rule.selector} | |
| onChange={(e) => updateRule(rule.id, { selector: e.target.value })} | |
| onClick={(e) => e.stopPropagation()} | |
| className="flex-1 bg-transparent text-xs font-mono text-primary-300 outline-none" | |
| placeholder=".my-class" /> | |
| <span className="text-[9px] text-surface-500">{rule.properties.filter((p: CssProperty) => p.name && p.value).length} props</span> | |
| <button onClick={(e) => { e.stopPropagation(); removeRule(rule.id); }} | |
| className="p-0.5 text-red-400 hover:text-red-300 hover:bg-surface-700 rounded"> | |
| <Trash2 className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| {/* Rule properties */} | |
| {expandedRule === rule.id && ( | |
| <div className="px-2 py-1.5 space-y-1"> | |
| <div className="flex items-center gap-1 text-[9px] text-surface-500 mb-1"> | |
| <label className="w-20">Selector</label> | |
| <div className="flex flex-wrap gap-1"> | |
| {linkedElementSelectors.map(sel => ( | |
| <button key={sel} | |
| onClick={() => updateRule(rule.id, { selector: sel })} | |
| className={`px-1 py-0.5 rounded text-[9px] transition-colors ${ | |
| rule.selector === sel ? 'bg-primary-600 text-white' : 'bg-surface-700 text-surface-300 hover:bg-surface-600' | |
| }`}> | |
| {sel} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {rule.properties.map((prop: CssProperty) => ( | |
| <div key={prop.id} className="flex items-center gap-1 group"> | |
| <input type="text" value={prop.name} | |
| onChange={(e) => updateProperty(rule.id, prop.id, { name: e.target.value })} | |
| className="w-24 bg-surface-800 text-[10px] text-surface-200 rounded px-1.5 py-0.5 border border-surface-700 outline-none font-mono" | |
| placeholder="property" /> | |
| <span className="text-surface-500 text-[9px]">:</span> | |
| <input type="text" value={prop.value} | |
| onChange={(e) => updateProperty(rule.id, prop.id, { value: e.target.value })} | |
| className="flex-1 bg-surface-800 text-[10px] text-surface-200 rounded px-1.5 py-0.5 border border-surface-700 outline-none font-mono" | |
| placeholder="value" /> | |
| <button onClick={() => updateProperty(rule.id, prop.id, { important: !prop.important })} | |
| className={`text-[9px] px-1 py-0.5 rounded ${prop.important ? 'text-amber-400 bg-amber-500/10' : 'text-surface-600'}`}> | |
| !important | |
| </button> | |
| <button onClick={() => removeProperty(rule.id, prop.id)} | |
| className="opacity-0 group-hover:opacity-100 p-0.5 text-red-400 hover:text-red-300"> | |
| <Trash2 className="w-2.5 h-2.5" /> | |
| </button> | |
| </div> | |
| ))} | |
| <button onClick={() => addProperty(rule.id)} | |
| className="w-full flex items-center gap-1 px-2 py-1 rounded text-[9px] text-surface-500 hover:text-primary-400 hover:bg-surface-800 transition-colors"> | |
| <Plus className="w-2.5 h-2.5" /> Add property | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |