diff --git a/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx b/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx index 4672a3889..3d03603e9 100644 --- a/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx +++ b/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx @@ -1,14 +1,12 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui' +import { Badge } from '@/components/ui' import { Button } from '@/components/ui' import { Input } from '@/components/ui' import { Label } from '@/components/ui' -import { ScrollArea } from '@/components/ui' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Database } from '@/lib/database' -import { Plus, X, FloppyDisk } from '@phosphor-icons/react' -import { toast } from 'sonner' +import { useClassBuilderState } from './class-builder/hooks' +import { Preview } from './class-builder/Preview' +import { RuleEditor } from './class-builder/RuleEditor' +import { X, FloppyDisk } from '@phosphor-icons/react' interface CssClassBuilderProps { open: boolean @@ -17,119 +15,30 @@ interface CssClassBuilderProps { onSave: (classes: string) => void } -interface CssCategory { - name: string - classes: string[] -} - -// eslint-disable-next-line no-useless-escape -const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/ -const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean))) - export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: CssClassBuilderProps) { - const [selectedClasses, setSelectedClasses] = useState([]) - const [categories, setCategories] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [customClass, setCustomClass] = useState('') - const [activeTab, setActiveTab] = useState('custom') + const { + categories, + filteredCategories, + selectedClasses, + selectedClassSet, + searchQuery, + setSearchQuery, + activeTab, + setActiveTab, + customClass, + setCustomClass, + invalidCustomTokens, + duplicateCustomTokens, + unknownCustomTokens, + canAddCustom, + addCustomClass, + toggleClass, + clearSelectedClasses, + } = useClassBuilderState({ open, initialValue }) - const knownClassSet = useMemo( - () => new Set(categories.flatMap((category) => category.classes)), - [categories] - ) - const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses]) - const normalizedSearch = searchQuery.trim().toLowerCase() - const filteredCategories = useMemo(() => { - if (!normalizedSearch) { - return categories - } - - return categories - .map((category) => ({ - ...category, - classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)), - })) - .filter((category) => category.classes.length > 0) - }, [categories, normalizedSearch]) - - const customTokens = customClass.trim().split(/\s+/).filter(Boolean) - const uniqueCustomTokens = Array.from(new Set(customTokens)) - const invalidCustomTokens = uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token)) - const duplicateCustomTokens = uniqueCustomTokens.filter((token) => selectedClassSet.has(token)) - const unknownCustomTokens = uniqueCustomTokens.filter((token) => !knownClassSet.has(token)) - const canAddCustom = - uniqueCustomTokens.length > 0 && - invalidCustomTokens.length === 0 && - uniqueCustomTokens.some((token) => !selectedClassSet.has(token)) - - const loadCssClasses = useCallback(async () => { - const classes = await Database.getCssClasses() - const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name)) - setCategories(sorted) - }, []) - - useEffect(() => { - if (open) { - loadCssClasses() - setSelectedClasses(parseClassList(initialValue)) - setSearchQuery('') - setCustomClass('') - } - }, [open, initialValue, loadCssClasses]) - - useEffect(() => { - if (!open) { - return - } - - if (filteredCategories.length === 0) { - setActiveTab('custom') - return - } - - if (activeTab === 'custom') { - return - } - - const hasActiveTab = filteredCategories.some((category) => category.name === activeTab) - if (!hasActiveTab) { - setActiveTab(filteredCategories[0]?.name ?? 'custom') - } - }, [activeTab, filteredCategories, open]) - - const toggleClass = (cssClass: string) => { - setSelectedClasses(current => { - if (current.includes(cssClass)) { - return current.filter(c => c !== cssClass) - } else { - return [...current, cssClass] - } - }) - } - - const addCustomClass = () => { - if (uniqueCustomTokens.length === 0) { - return - } - - if (invalidCustomTokens.length > 0) { - toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`) - return - } - - const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token)) - if (newTokens.length === 0) { - toast.info('Those classes are already selected') - return - } - - setSelectedClasses((current) => [...current, ...newTokens]) - setCustomClass('') - } - - const clearSelectedClasses = () => { - setSelectedClasses([]) - } + const normalizedSearch = searchQuery.trim() + const hasNoCategories = filteredCategories.length === 0 && categories.length === 0 + const hasNoSearchResults = filteredCategories.length === 0 && categories.length > 0 && normalizedSearch const handleSave = () => { onSave(selectedClasses.join(' ')) @@ -176,21 +85,16 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
- {selectedClasses.map(cls => ( + {selectedClasses.map((cls) => ( {cls} - ))}
-
- {selectedClasses.join(' ')} -
+
{selectedClasses.join(' ')}
) : (
@@ -198,106 +102,34 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
)} -
- -
-
-
Preview element
-
- This sample updates as you add or remove classes. -
-
- Sample button -
-
-
-
+ - {filteredCategories.length === 0 && categories.length === 0 && ( + {hasNoCategories && (
No CSS categories available yet. Add some in the CSS Classes tab.
)} - {filteredCategories.length === 0 && categories.length > 0 && normalizedSearch && ( + {hasNoSearchResults && (
No classes match "{searchQuery}".
)} - -
- - {filteredCategories.map(category => ( - - {category.name} - - ))} - Custom - -
- - {filteredCategories.map(category => ( - - -
- {category.classes.map(cls => ( - - ))} -
-
-
- ))} - - -
-
- setCustomClass(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()} - className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`} - /> - -
- {invalidCustomTokens.length > 0 && ( -

- Invalid class names: {invalidCustomTokens.join(', ')} -

- )} - {invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && ( -

- Not in library: {unknownCustomTokens.join(', ')}. They will still be added. -

- )} - {duplicateCustomTokens.length > 0 && ( -

- Already selected: {duplicateCustomTokens.join(', ')} -

- )} -

- Add custom CSS classes that aren't in the predefined list. -

-
-
-
+ diff --git a/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx b/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx new file mode 100644 index 000000000..0c1159976 --- /dev/null +++ b/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx @@ -0,0 +1,24 @@ +import { Label } from '@/components/ui' + +interface PreviewProps { + selectedClasses: string[] +} + +export function Preview({ selectedClasses }: PreviewProps) { + return ( +
+ +
+
+
Preview element
+
+ This sample updates as you add or remove classes. +
+
+ Sample button +
+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx b/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx new file mode 100644 index 000000000..a13cd055d --- /dev/null +++ b/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx @@ -0,0 +1,109 @@ +import { Button, Input, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' +import type { CssCategory } from '@/lib/database' +import { Plus } from '@phosphor-icons/react' + +interface RuleEditorProps { + filteredCategories: CssCategory[] + activeTab: string + onTabChange: (value: string) => void + selectedClassSet: Set + toggleClass: (cssClass: string) => void + customClass: string + setCustomClass: (value: string) => void + canAddCustom: boolean + addCustomClass: () => void + invalidCustomTokens: string[] + duplicateCustomTokens: string[] + unknownCustomTokens: string[] +} + +export function RuleEditor({ + filteredCategories, + activeTab, + onTabChange, + selectedClassSet, + toggleClass, + customClass, + setCustomClass, + canAddCustom, + addCustomClass, + invalidCustomTokens, + duplicateCustomTokens, + unknownCustomTokens, +}: RuleEditorProps) { + return ( + +
+ + {filteredCategories.map((category) => ( + + {category.name} + + ))} + Custom + +
+ + {filteredCategories.map((category) => ( + + +
+ {category.classes.map((cls) => ( + + ))} +
+
+
+ ))} + + +
+
+ setCustomClass(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()} + className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`} + /> + +
+ {invalidCustomTokens.length > 0 && ( +

+ Invalid class names: {invalidCustomTokens.join(', ')} +

+ )} + {invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && ( +

+ Not in library: {unknownCustomTokens.join(', ')}. They will still be added. +

+ )} + {duplicateCustomTokens.length > 0 && ( +

+ Already selected: {duplicateCustomTokens.join(', ')} +

+ )} +

+ Add custom CSS classes that aren't in the predefined list. +

+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts b/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts new file mode 100644 index 000000000..d4a7ff8c0 --- /dev/null +++ b/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts @@ -0,0 +1,147 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import { Database, CssCategory } from '@/lib/database' +import { toast } from 'sonner' + +const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/ +const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean))) + +interface UseClassBuilderStateProps { + open: boolean + initialValue: string +} + +export function useClassBuilderState({ open, initialValue }: UseClassBuilderStateProps) { + const [selectedClasses, setSelectedClasses] = useState([]) + const [categories, setCategories] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [customClass, setCustomClass] = useState('') + const [activeTab, setActiveTab] = useState('custom') + + const knownClassSet = useMemo(() => new Set(categories.flatMap((category) => category.classes)), [categories]) + const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses]) + const normalizedSearch = searchQuery.trim().toLowerCase() + + const filteredCategories = useMemo(() => { + if (!normalizedSearch) { + return categories + } + + return categories + .map((category) => ({ + ...category, + classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)), + })) + .filter((category) => category.classes.length > 0) + }, [categories, normalizedSearch]) + + const customTokens = useMemo(() => customClass.trim().split(/\s+/).filter(Boolean), [customClass]) + const uniqueCustomTokens = useMemo(() => Array.from(new Set(customTokens)), [customTokens]) + const invalidCustomTokens = useMemo( + () => uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token)), + [uniqueCustomTokens] + ) + const duplicateCustomTokens = useMemo( + () => uniqueCustomTokens.filter((token) => selectedClassSet.has(token)), + [uniqueCustomTokens, selectedClassSet] + ) + const unknownCustomTokens = useMemo( + () => uniqueCustomTokens.filter((token) => !knownClassSet.has(token)), + [uniqueCustomTokens, knownClassSet] + ) + const canAddCustom = useMemo( + () => + uniqueCustomTokens.length > 0 && + invalidCustomTokens.length === 0 && + uniqueCustomTokens.some((token) => !selectedClassSet.has(token)), + [invalidCustomTokens.length, selectedClassSet, uniqueCustomTokens] + ) + + const loadCssClasses = useCallback(async () => { + const classes = await Database.getCssClasses() + const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name)) + setCategories(sorted) + }, []) + + useEffect(() => { + if (open) { + loadCssClasses() + setSelectedClasses(parseClassList(initialValue)) + setSearchQuery('') + setCustomClass('') + } + }, [open, initialValue, loadCssClasses]) + + useEffect(() => { + if (!open) { + return + } + + if (filteredCategories.length === 0) { + setActiveTab('custom') + return + } + + if (activeTab === 'custom') { + return + } + + const hasActiveTab = filteredCategories.some((category) => category.name === activeTab) + if (!hasActiveTab) { + setActiveTab(filteredCategories[0]?.name ?? 'custom') + } + }, [activeTab, filteredCategories, open]) + + const toggleClass = (cssClass: string) => { + setSelectedClasses((current) => { + if (current.includes(cssClass)) { + return current.filter((c) => c !== cssClass) + } + + return [...current, cssClass] + }) + } + + const addCustomClass = () => { + if (uniqueCustomTokens.length === 0) { + return + } + + if (invalidCustomTokens.length > 0) { + toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`) + return + } + + const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token)) + if (newTokens.length === 0) { + toast.info('Those classes are already selected') + return + } + + setSelectedClasses((current) => [...current, ...newTokens]) + setCustomClass('') + } + + const clearSelectedClasses = () => { + setSelectedClasses([]) + } + + return { + categories, + filteredCategories, + selectedClasses, + selectedClassSet, + searchQuery, + setSearchQuery, + activeTab, + setActiveTab, + customClass, + setCustomClass, + invalidCustomTokens, + duplicateCustomTokens, + unknownCustomTokens, + canAddCustom, + addCustomClass, + toggleClass, + clearSelectedClasses, + } +}