diff --git a/frontends/nextjs/src/app/_components/auth-provider/auth-context.ts b/frontends/nextjs/src/app/_components/auth-provider/auth-context.ts new file mode 100644 index 000000000..c6fbd383b --- /dev/null +++ b/frontends/nextjs/src/app/_components/auth-provider/auth-context.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react' +import type { User } from '@/lib/level-types' + +export interface AuthContextType { + user: User | null + isLoading: boolean + login: (username: string, password: string) => Promise + logout: () => Promise + register: (username: string, email: string, password: string) => Promise +} + +export const AuthContext = createContext(undefined) diff --git a/frontends/nextjs/src/app/providers.tsx b/frontends/nextjs/src/app/providers.tsx index f5137234b..94833c34c 100644 --- a/frontends/nextjs/src/app/providers.tsx +++ b/frontends/nextjs/src/app/providers.tsx @@ -1,70 +1,4 @@ 'use client' -import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useState, useMemo, createContext, useContext } from 'react' -import { lightTheme, darkTheme } from '@/theme/mui-theme' - -type ThemeMode = 'light' | 'dark' | 'system' - -interface ThemeContextType { - mode: ThemeMode - setMode: (mode: ThemeMode) => void - toggleTheme: () => void -} - -const ThemeContext = createContext(undefined) - -export function useTheme() { - const context = useContext(ThemeContext) - if (!context) { - throw new Error('useTheme must be used within Providers') - } - return context -} - -export function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 60 * 1000, // 1 minute - retry: 1, - }, - }, - }) - ) - - const [mode, setMode] = useState('system') - - const theme = useMemo(() => { - if (mode === 'system') { - // Detect system preference - const isDark = typeof window !== 'undefined' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : false - return isDark ? darkTheme : lightTheme - } - return mode === 'dark' ? darkTheme : lightTheme - }, [mode]) - - const toggleTheme = () => { - setMode(current => { - if (current === 'light') return 'dark' - if (current === 'dark') return 'system' - return 'light' - }) - } - - return ( - - - - - {children} - - - - ) -} +export { Providers } from './providers/providers-component' +export { useTheme } from './providers/use-theme' diff --git a/frontends/nextjs/src/app/providers/providers-component.tsx b/frontends/nextjs/src/app/providers/providers-component.tsx new file mode 100644 index 000000000..8ef029c35 --- /dev/null +++ b/frontends/nextjs/src/app/providers/providers-component.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useMemo, useState } from 'react' +import { CssBaseline, ThemeProvider as MuiThemeProvider } from '@mui/material' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { lightTheme, darkTheme } from '@/theme/mui-theme' +import { ThemeContext, type ThemeMode } from './theme-context' + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + retry: 1, + }, + }, + }) + ) + + const [mode, setMode] = useState('system') + + const theme = useMemo(() => { + if (mode === 'system') { + // Detect system preference + const isDark = typeof window !== 'undefined' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : false + return isDark ? darkTheme : lightTheme + } + return mode === 'dark' ? darkTheme : lightTheme + }, [mode]) + + const toggleTheme = () => { + setMode(current => { + if (current === 'light') return 'dark' + if (current === 'dark') return 'system' + return 'light' + }) + } + + return ( + + + + + {children} + + + + ) +} diff --git a/frontends/nextjs/src/app/providers/theme-context.ts b/frontends/nextjs/src/app/providers/theme-context.ts new file mode 100644 index 000000000..e04ff0479 --- /dev/null +++ b/frontends/nextjs/src/app/providers/theme-context.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react' + +export type ThemeMode = 'light' | 'dark' | 'system' + +export interface ThemeContextType { + mode: ThemeMode + setMode: (mode: ThemeMode) => void + toggleTheme: () => void +} + +export const ThemeContext = createContext(undefined) diff --git a/frontends/nextjs/src/app/providers/use-theme.ts b/frontends/nextjs/src/app/providers/use-theme.ts new file mode 100644 index 000000000..4d61937d4 --- /dev/null +++ b/frontends/nextjs/src/app/providers/use-theme.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { ThemeContext } from './theme-context' + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within Providers') + } + return context +} diff --git a/frontends/nextjs/src/components/CssClassManager.tsx b/frontends/nextjs/src/components/CssClassManager.tsx index af08561fa..4663bda39 100644 --- a/frontends/nextjs/src/components/CssClassManager.tsx +++ b/frontends/nextjs/src/components/CssClassManager.tsx @@ -11,6 +11,9 @@ import { Database, CssCategory } from '@/lib/database' import { Plus, X, Pencil, Trash, FloppyDisk } from '@phosphor-icons/react' import { toast } from 'sonner' +const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.[\]()%#!,=+-]+$/ +const uniqueClasses = (classes: string[]) => Array.from(new Set(classes)) + export function CssClassManager() { const [categories, setCategories] = useState([]) const [isEditing, setIsEditing] = useState(false) @@ -18,61 +21,121 @@ export function CssClassManager() { const [categoryName, setCategoryName] = useState('') const [classes, setClasses] = useState([]) const [newClass, setNewClass] = useState('') + const [searchQuery, setSearchQuery] = useState('') + const [classSearchQuery, setClassSearchQuery] = useState('') useEffect(() => { loadCategories() }, []) + const normalizedSearch = searchQuery.trim().toLowerCase() + const filteredCategories = normalizedSearch + ? categories.filter((category) => + category.name.toLowerCase().includes(normalizedSearch) || + category.classes.some((cls) => cls.toLowerCase().includes(normalizedSearch)) + ) + : categories + const totalClassCount = categories.reduce((total, category) => total + category.classes.length, 0) + + const newClassTokens = newClass.trim().split(/\s+/).filter(Boolean) + const uniqueNewClassTokens = Array.from(new Set(newClassTokens)) + const invalidNewClassTokens = uniqueNewClassTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token)) + const duplicateNewClassTokens = uniqueNewClassTokens.filter((token) => classes.includes(token)) + const canAddClass = + uniqueNewClassTokens.length > 0 && + invalidNewClassTokens.length === 0 && + uniqueNewClassTokens.some((token) => !classes.includes(token)) + + const normalizedClassSearch = classSearchQuery.trim().toLowerCase() + const filteredEditorClasses = normalizedClassSearch + ? classes.filter((cls) => cls.toLowerCase().includes(normalizedClassSearch)) + : classes + const loadCategories = async () => { const cats = await Database.getCssClasses() - setCategories(cats) + const normalized = cats.map((category) => ({ + ...category, + classes: uniqueClasses(category.classes), + })) + const sorted = normalized.slice().sort((a, b) => a.name.localeCompare(b.name)) + setCategories(sorted) } const startEdit = (category?: CssCategory) => { if (category) { setEditingCategory(category) setCategoryName(category.name) - setClasses([...category.classes]) + setClasses(uniqueClasses(category.classes)) } else { setEditingCategory(null) setCategoryName('') setClasses([]) } + setNewClass('') + setClassSearchQuery('') setIsEditing(true) } const addClass = () => { - if (newClass.trim()) { - setClasses(current => [...current, newClass.trim()]) - setNewClass('') + if (uniqueNewClassTokens.length === 0) { + return } + + if (invalidNewClassTokens.length > 0) { + toast.error(`Invalid class name: ${invalidNewClassTokens.join(', ')}`) + return + } + + const newTokens = uniqueNewClassTokens.filter((token) => !classes.includes(token)) + if (newTokens.length === 0) { + toast.info('Those classes already exist in this category') + return + } + + setClasses((current) => uniqueClasses([...current, ...newTokens])) + setNewClass('') } - const removeClass = (index: number) => { - setClasses(current => current.filter((_, i) => i !== index)) + const removeClass = (cssClass: string) => { + setClasses(current => current.filter((cls) => cls !== cssClass)) } const handleSave = async () => { - if (!categoryName || classes.length === 0) { + const trimmedName = categoryName.trim() + const normalizedClasses = uniqueClasses(classes) + if (!trimmedName || normalizedClasses.length === 0) { toast.error('Please provide a category name and at least one class') return } + const nameConflict = categories.some((category) => + category.name.toLowerCase() === trimmedName.toLowerCase() && + category.name !== editingCategory?.name + ) + if (nameConflict) { + toast.error('A category with this name already exists') + return + } + const newCategory: CssCategory = { - name: categoryName, - classes, + name: trimmedName, + classes: normalizedClasses, } - if (editingCategory) { - await Database.updateCssCategory(categoryName, classes) - toast.success('Category updated successfully') - } else { - await Database.addCssCategory(newCategory) - toast.success('Category created successfully') - } + try { + if (editingCategory) { + await Database.updateCssCategory(editingCategory.name, newCategory) + toast.success('Category updated successfully') + } else { + await Database.addCssCategory(newCategory) + toast.success('Category created successfully') + } - setIsEditing(false) - loadCategories() + setIsEditing(false) + loadCategories() + } catch (error) { + toast.error('Failed to save category') + } } const handleDelete = async (categoryName: string) => { diff --git a/frontends/nextjs/src/lib/lua/LuaEngine.ts b/frontends/nextjs/src/lib/lua/LuaEngine.ts index 279456b85..baa1b7de6 100644 --- a/frontends/nextjs/src/lib/lua/LuaEngine.ts +++ b/frontends/nextjs/src/lib/lua/LuaEngine.ts @@ -13,9 +13,9 @@ import * as fengari from 'fengari-web' import type { LuaExecutionContext, LuaExecutionResult } from './functions/types' import { setupContextAPI } from './functions/setup/setup-context-api' -import { executeLuaCode } from './functions/execution/execute-lua-code' +import { execute } from './functions/engine/execute' +import { destroy } from './functions/engine/destroy' -const lua = fengari.lua const lauxlib = fengari.lauxlib const lualib = fengari.lualib @@ -26,8 +26,8 @@ export type { LuaExecutionContext, LuaExecutionResult } * LuaEngine class wraps individual Lua execution lambdas */ export class LuaEngine { - private L: any - private logs: string[] = [] + L: any + logs: string[] = [] constructor() { this.L = lauxlib.luaL_newstate() @@ -35,33 +35,11 @@ export class LuaEngine { setupContextAPI(this.L, this.logs) } - /** - * Execute Lua code with a context - * @param code - Lua code to execute - * @param context - Execution context - * @returns Execution result - */ - async execute(code: string, context: LuaExecutionContext = {}): Promise { - this.logs.length = 0 - return executeLuaCode(this.L, code, context, this.logs) - } + /** Execute Lua code with a context */ + execute = execute - /** - * Destroy the Lua state - */ - destroy(): void { - if (this.L) { - lua.lua_close(this.L) - } - } -} - -/** - * Factory function to create a new LuaEngine instance - * @returns New LuaEngine instance - */ -export const createLuaEngine = (): LuaEngine => { - return new LuaEngine() + /** Destroy the Lua state */ + destroy = destroy } // Re-export individual functions for direct imports diff --git a/frontends/nextjs/src/lib/lua/create-lua-engine.ts b/frontends/nextjs/src/lib/lua/create-lua-engine.ts new file mode 100644 index 000000000..439d90161 --- /dev/null +++ b/frontends/nextjs/src/lib/lua/create-lua-engine.ts @@ -0,0 +1,9 @@ +import { LuaEngine } from './LuaEngine' + +/** + * Factory function to create a new LuaEngine instance + * @returns New LuaEngine instance + */ +export const createLuaEngine = (): LuaEngine => { + return new LuaEngine() +} diff --git a/frontends/nextjs/src/lib/lua/functions/engine/destroy.ts b/frontends/nextjs/src/lib/lua/functions/engine/destroy.ts new file mode 100644 index 000000000..6ffa4da4e --- /dev/null +++ b/frontends/nextjs/src/lib/lua/functions/engine/destroy.ts @@ -0,0 +1,13 @@ +import * as fengari from 'fengari-web' +import type { LuaEngine } from '../../LuaEngine' + +const lua = fengari.lua + +/** + * Destroy the Lua state + */ +export function destroy(this: LuaEngine): void { + if (this.L) { + lua.lua_close(this.L) + } +} diff --git a/frontends/nextjs/src/lib/lua/functions/engine/execute.ts b/frontends/nextjs/src/lib/lua/functions/engine/execute.ts new file mode 100644 index 000000000..b6498c383 --- /dev/null +++ b/frontends/nextjs/src/lib/lua/functions/engine/execute.ts @@ -0,0 +1,18 @@ +import type { LuaExecutionContext, LuaExecutionResult } from '../types' +import type { LuaEngine } from '../../LuaEngine' +import { executeLuaCode } from '../execution/execute-lua-code' + +/** + * Execute Lua code with a context + * @param code - Lua code to execute + * @param context - Execution context + * @returns Execution result + */ +export async function execute( + this: LuaEngine, + code: string, + context: LuaExecutionContext = {} +): Promise { + this.logs.length = 0 + return executeLuaCode(this.L, code, context, this.logs) +} diff --git a/frontends/nextjs/src/lib/lua/lua-engine.ts b/frontends/nextjs/src/lib/lua/lua-engine.ts index 29843daf3..9325a85f6 100644 --- a/frontends/nextjs/src/lib/lua/lua-engine.ts +++ b/frontends/nextjs/src/lib/lua/lua-engine.ts @@ -1 +1,2 @@ export * from './LuaEngine' +export { createLuaEngine } from './create-lua-engine' diff --git a/frontends/nextjs/src/lib/packages/package-glue/get-package-glue.ts b/frontends/nextjs/src/lib/packages/package-glue/get-package-glue.ts new file mode 100644 index 000000000..266f4bf2b --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/get-package-glue.ts @@ -0,0 +1,3 @@ +import { packageGlue, type PackageGlue } from './package-glue' + +export const getPackageGlue = (): PackageGlue => packageGlue diff --git a/frontends/nextjs/src/lib/packages/package-glue/index.ts b/frontends/nextjs/src/lib/packages/package-glue/index.ts index 9774dfa9a..952a702b4 100644 --- a/frontends/nextjs/src/lib/packages/package-glue/index.ts +++ b/frontends/nextjs/src/lib/packages/package-glue/index.ts @@ -35,33 +35,5 @@ export { isPackageInstalled, uninstallPackage, } - -/** - * PackageGlue - Wrapper class for package registry helpers - * - * Each method delegates to a single-function module. - */ -export class PackageGlue { - buildPackageRegistry = buildPackageRegistry - getPackage = getPackage - getPackagesByCategory = getPackagesByCategory - getPackageComponents = getPackageComponents - getPackageScripts = getPackageScripts - getPackageScriptFiles = getPackageScriptFiles - getAllPackageScripts = getAllPackageScripts - getPackageExamples = getPackageExamples - checkDependencies = checkDependencies - installPackageComponents = installPackageComponents - installPackageScripts = installPackageScripts - installPackage = installPackage - uninstallPackage = uninstallPackage - getInstalledPackages = getInstalledPackages - isPackageInstalled = isPackageInstalled - exportAllPackagesForSeed = exportAllPackagesForSeed -} - -export const packageGlue = new PackageGlue() - -export function getPackageGlue(): PackageGlue { - return packageGlue -} +export { PackageGlue, packageGlue } from './package-glue' +export { getPackageGlue } from './get-package-glue' diff --git a/frontends/nextjs/src/lib/packages/package-glue/package-glue.ts b/frontends/nextjs/src/lib/packages/package-glue/package-glue.ts new file mode 100644 index 000000000..09e8dba29 --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/package-glue.ts @@ -0,0 +1,42 @@ +import { buildPackageRegistry } from './build-package-registry' +import { checkDependencies } from './check-dependencies' +import { exportAllPackagesForSeed } from './export-all-packages-for-seed' +import { getAllPackageScripts } from './get-all-package-scripts' +import { getInstalledPackages } from './get-installed-packages' +import { getPackage } from './get-package' +import { getPackageComponents } from './get-package-components' +import { getPackageExamples } from './get-package-examples' +import { getPackageScriptFiles } from './get-package-script-files' +import { getPackageScripts } from './get-package-scripts' +import { getPackagesByCategory } from './get-packages-by-category' +import { installPackage } from './install-package' +import { installPackageComponents } from './install-package-components' +import { installPackageScripts } from './install-package-scripts' +import { isPackageInstalled } from './is-package-installed' +import { uninstallPackage } from './uninstall-package' + +/** + * PackageGlue - Wrapper class for package registry helpers + * + * Each method delegates to a single-function module. + */ +export class PackageGlue { + buildPackageRegistry = buildPackageRegistry + getPackage = getPackage + getPackagesByCategory = getPackagesByCategory + getPackageComponents = getPackageComponents + getPackageScripts = getPackageScripts + getPackageScriptFiles = getPackageScriptFiles + getAllPackageScripts = getAllPackageScripts + getPackageExamples = getPackageExamples + checkDependencies = checkDependencies + installPackageComponents = installPackageComponents + installPackageScripts = installPackageScripts + installPackage = installPackage + uninstallPackage = uninstallPackage + getInstalledPackages = getInstalledPackages + isPackageInstalled = isPackageInstalled + exportAllPackagesForSeed = exportAllPackagesForSeed +} + +export const packageGlue = new PackageGlue()