various changes

This commit is contained in:
2026-03-09 22:30:41 +00:00
parent 26417fd4d8
commit 862cc29457
8637 changed files with 1228196 additions and 38401 deletions
@@ -0,0 +1,77 @@
import { useState, useCallback } from 'react'
import { toast } from '@/components/ui/sonner'
import { llm, llmPrompt } from '@/lib/llm-service'
interface AIGenerationState {
loading: boolean
error: string | null
result: string | null
}
export function useAIGeneration() {
const [state, setState] = useState<AIGenerationState>({
loading: false,
error: null,
result: null,
})
const generate = useCallback(async (
prompt: string,
modelName: 'claude-sonnet' | 'claude-haiku' | 'claude-opus' = 'claude-sonnet',
jsonMode = false
) => {
setState({ loading: true, error: null, result: null })
try {
const formattedPrompt = llmPrompt`${prompt}`
const result = await llm(formattedPrompt, modelName, jsonMode)
setState({ loading: false, error: null, result })
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'AI generation failed'
setState({ loading: false, error: errorMessage, result: null })
toast.error(errorMessage)
return null
}
}, [])
const reset = useCallback(() => {
setState({ loading: false, error: null, result: null })
}, [])
return {
...state,
generate,
reset,
}
}
export function useAICodeImprovement() {
const { generate, ...state } = useAIGeneration()
const improve = useCallback(async (code: string, language: string) => {
const prompt = `Improve this ${language} code for better readability, performance, and best practices. Return only the improved code without explanations:\n\n${code}`
return await generate(prompt, 'claude-sonnet')
}, [generate])
return {
...state,
improve,
}
}
export function useAIExplanation() {
const { generate, ...state } = useAIGeneration()
const explain = useCallback(async (code: string, language: string) => {
const prompt = `Explain this ${language} code in simple terms. Break down what it does, how it works, and any important concepts:\n\n${code}`
return await generate(prompt, 'claude-haiku')
}, [generate])
return {
...state,
explain,
}
}
@@ -0,0 +1,32 @@
import { useUIState } from '@/hooks/use-ui-state'
export function useFeatureFlags() {
const [featureFlags, setFeatureFlags] = useUIState<Record<string, boolean>>(
'feature-flags',
{}
)
const isEnabled = (featureId: string) => {
return (featureFlags || {})[featureId] ?? true
}
const enable = (featureId: string) => {
setFeatureFlags((prev = {}) => ({ ...prev, [featureId]: true }))
}
const disable = (featureId: string) => {
setFeatureFlags((prev = {}) => ({ ...prev, [featureId]: false }))
}
const toggle = (featureId: string) => {
setFeatureFlags((prev = {}) => ({ ...prev, [featureId]: !prev[featureId] }))
}
return {
featureFlags,
isEnabled,
enable,
disable,
toggle,
}
}
@@ -0,0 +1,36 @@
import { useUIState } from '@/hooks/use-ui-state'
interface LayoutState {
panelSizes?: number[]
collapsed?: Record<string, boolean>
activePanel?: string
}
export function useLayoutState(pageId: string) {
const [layoutState, setLayoutState] = useUIState<LayoutState>(
`layout-state:${pageId}`,
{}
)
const setPanelSizes = (sizes: number[]) => {
setLayoutState((prev) => ({ ...prev, panelSizes: sizes }))
}
const setCollapsed = (panelId: string, collapsed: boolean) => {
setLayoutState((prev) => ({
...prev,
collapsed: { ...(prev.collapsed || {}), [panelId]: collapsed },
}))
}
const setActivePanel = (panelId: string) => {
setLayoutState((prev) => ({ ...prev, activePanel: panelId }))
}
return {
layoutState,
setPanelSizes,
setCollapsed,
setActivePanel,
}
}
@@ -0,0 +1,32 @@
import { useUIState } from '@/hooks/use-ui-state'
import { PageConfig, PageConfigSchema } from '@/config/page-schema'
import defaultPagesData from '@/config/default-pages.json'
const defaultPages = defaultPagesData as { pages: PageConfig[] }
export function usePageConfig(pageId: string) {
const defaultPage = defaultPages.pages.find((p) => p.id === pageId)
const [pageConfig, setPageConfig] = useUIState<PageConfig | null>(
`page-config:${pageId}`,
defaultPage || null,
)
return {
pageConfig: pageConfig || defaultPage || null,
setPageConfig,
}
}
export function usePageRegistry() {
const [pages, setPages] = useUIState<PageConfig[]>(
'page-registry',
defaultPages.pages,
)
return {
pages: pages || [],
setPages,
getPage: (id: string) => (pages || []).find((p) => p.id === id),
}
}
@@ -0,0 +1,31 @@
import { useCallback, useState } from 'react'
import { toast } from '@/components/ui/sonner'
export function useClipboard() {
const [copied, setCopied] = useState(false)
const copy = useCallback(async (text: string, message?: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
toast.success(message || 'Copied to clipboard')
setTimeout(() => setCopied(false), 2000)
return true
} catch (error) {
toast.error('Failed to copy to clipboard')
return false
}
}, [])
const paste = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
return text
} catch (error) {
toast.error('Failed to read from clipboard')
return null
}
}, [])
return { copy, paste, copied }
}
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react'
export function useDebouncedSave<T>(
value: T,
onSave: (value: T) => void,
delay: number = 1000
) {
const timeoutRef = useRef<number>(0)
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onSave(value)
}, delay)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [value, onSave, delay])
}
@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { loadRecharts, loadReactFlow } from '@/lib/library-loader'
type LoadState<T> = {
library: T | null
loading: boolean
error: Error | null
}
export function useRecharts() {
const [state, setState] = useState<LoadState<typeof import('recharts')>>({
library: null,
loading: true,
error: null,
})
useEffect(() => {
console.log('[HOOK] 🎨 useRecharts: Starting load')
let mounted = true
loadRecharts()
.then(recharts => {
if (mounted) {
console.log('[HOOK] ✅ useRecharts: Loaded successfully')
setState({ library: recharts, loading: false, error: null })
}
})
.catch(error => {
if (mounted) {
console.error('[HOOK] ❌ useRecharts: Load failed', error)
setState({ library: null, loading: false, error })
}
})
return () => {
mounted = false
}
}, [])
return state
}
export function useReactFlow() {
const [state, setState] = useState<LoadState<typeof import('reactflow')>>({
library: null,
loading: true,
error: null,
})
useEffect(() => {
console.log('[HOOK] 🔀 useReactFlow: Starting load')
let mounted = true
loadReactFlow()
.then(reactflow => {
if (mounted) {
console.log('[HOOK] ✅ useReactFlow: Loaded successfully')
setState({ library: reactflow, loading: false, error: null })
}
})
.catch(error => {
if (mounted) {
console.error('[HOOK] ❌ useReactFlow: Load failed', error)
setState({ library: null, loading: false, error })
}
})
return () => {
mounted = false
}
}, [])
return state
}
+16
View File
@@ -0,0 +1,16 @@
// Codegen-specific data hooks
export { useKVDataSource, useComputedDataSource, useStaticDataSource, useMultipleDataSources } from './use-data-source'
export { useCRUD } from './use-crud'
export { useSearchFilter } from './use-search-filter'
// Re-export from centralized @metabuilder/hooks package
export { useSort, usePagination, useFilter, useSearch } from '@metabuilder/hooks'
// Codegen's useSelection has a different signature - keep local version
export { useSelection } from './use-selection'
// Type exports
export type { DataSourceConfig, DataSourceType } from './use-data-source'
export type { CRUDOperations, CRUDConfig } from './use-crud'
export type { SearchFilterConfig } from './use-search-filter'
export type { SelectionConfig } from './use-selection'
@@ -0,0 +1,63 @@
import { useUIState } from '@/hooks/use-ui-state'
import { useCallback } from 'react'
export function useArray<T>(key: string, defaultValue: T[] = []) {
const [items, setItems] = useUIState<T[]>(key, defaultValue)
const safeItems = items || []
const add = useCallback((item: T) => {
setItems((current) => [...(current || []), item])
}, [setItems])
const addMany = useCallback((newItems: T[]) => {
setItems((current) => [...(current || []), ...newItems])
}, [setItems])
const remove = useCallback((predicate: (item: T) => boolean) => {
setItems((current) => (current || []).filter((item) => !predicate(item)))
}, [setItems])
const update = useCallback(
(predicate: (item: T) => boolean, updater: (item: T) => T) => {
setItems((current) =>
(current || []).map((item) => (predicate(item) ? updater(item) : item))
)
},
[setItems]
)
const replace = useCallback((newItems: T[]) => {
setItems(newItems)
}, [setItems])
const clear = useCallback(() => {
setItems([])
}, [setItems])
const find = useCallback(
(predicate: (item: T) => boolean) => {
return safeItems.find(predicate)
},
[safeItems]
)
const filter = useCallback(
(predicate: (item: T) => boolean) => {
return safeItems.filter(predicate)
},
[safeItems]
)
return {
items: safeItems,
add,
addMany,
remove,
update,
replace,
clear,
find,
filter,
count: safeItems.length,
}
}
@@ -0,0 +1,37 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { addComponent as addComponentAction, updateComponent as updateComponentAction, removeComponent, setComponents } from '@/store/slices/componentsSlice'
import { useCallback } from 'react'
import { ComponentNode } from '@/types/project'
export function useComponents() {
const dispatch = useAppDispatch()
const sliceComponents = useAppSelector((s) => s.components?.components ?? [])
const components = sliceComponents as unknown as ComponentNode[]
const addComponent = useCallback((component: ComponentNode) => {
dispatch(addComponentAction(component as any))
}, [dispatch])
const updateComponent = useCallback((componentId: string, updates: Partial<ComponentNode>) => {
const existing = components.find(c => c.id === componentId)
if (existing) {
dispatch(updateComponentAction({ ...existing, ...updates } as any))
}
}, [dispatch, components])
const deleteComponent = useCallback((componentId: string) => {
dispatch(removeComponent(componentId))
}, [dispatch])
const getComponent = useCallback((componentId: string) => {
return components.find(c => c.id === componentId)
}, [components])
return {
components,
addComponent,
updateComponent,
deleteComponent,
getComponent,
}
}
@@ -0,0 +1,51 @@
import { useCallback } from 'react'
export interface CRUDOperations<T> {
create: (item: T) => void
read: (id: string | number) => T | undefined
update: (id: string | number, updates: Partial<T>) => void
delete: (id: string | number) => void
list: () => T[]
}
export interface CRUDConfig<T> {
items: T[]
setItems: (updater: (items: T[]) => T[]) => void
idField?: keyof T
}
export function useCRUD<T extends Record<string, any>>({
items,
setItems,
idField = 'id' as keyof T,
}: CRUDConfig<T>): CRUDOperations<T> {
const create = useCallback((item: T) => {
setItems(current => [...current, item])
}, [setItems])
const read = useCallback((id: string | number) => {
return items.find(item => item[idField] === id)
}, [items, idField])
const update = useCallback((id: string | number, updates: Partial<T>) => {
setItems(current =>
current.map(item =>
item[idField] === id ? { ...item, ...updates } : item
)
)
}, [setItems, idField])
const deleteItem = useCallback((id: string | number) => {
setItems(current => current.filter(item => item[idField] !== id))
}, [setItems, idField])
const list = useCallback(() => items, [items])
return {
create,
read,
update,
delete: deleteItem,
list,
}
}
@@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from 'react'
import { DataSource } from '@/types/json-ui'
export function useDataSourceEditor(
dataSource: DataSource | null,
) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
useEffect(() => {
setEditingSource(dataSource)
}, [dataSource])
const updateField = useCallback(<K extends keyof DataSource>(field: K, value: DataSource[K]) => {
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
}, [])
return {
editingSource,
updateField,
}
}
@@ -0,0 +1,47 @@
import { useState, useCallback } from 'react'
import { DataSource, DataSourceType } from '@/types/json-ui'
export function useDataSourceManager(initialSources: DataSource[] = []) {
const [dataSources, setDataSources] = useState<DataSource[]>(initialSources)
const addDataSource = useCallback((type: DataSourceType) => {
const newSource: DataSource = {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'static' && { defaultValue: null }),
}
setDataSources(prev => [...prev, newSource])
return newSource
}, [])
const updateDataSource = useCallback((id: string, updates: Partial<DataSource>) => {
setDataSources(prev =>
prev.map(ds => ds.id === id ? { ...ds, ...updates } : ds)
)
}, [])
const deleteDataSource = useCallback((id: string) => {
setDataSources(prev => prev.filter(ds => ds.id !== id))
}, [])
const getDataSource = useCallback((id: string) => {
return dataSources.find(ds => ds.id === id)
}, [dataSources])
const getDependents = useCallback((sourceId: string) => {
return dataSources.filter(ds =>
ds.dependencies?.includes(sourceId)
)
}, [dataSources])
return {
dataSources,
addDataSource,
updateDataSource,
deleteDataSource,
getDataSource,
getDependents,
}
}
@@ -0,0 +1,26 @@
import { useUIState } from '@/hooks/use-ui-state'
export type DataSourceType = 'kv' | 'static'
export interface DataSourceConfig<T = any> {
type: DataSourceType
key?: string
defaultValue?: T
}
export function useKVDataSource<T = any>(key: string, defaultValue?: T): [T | undefined, (value: T | ((prev: T | undefined) => T | undefined)) => void, () => void] {
return useUIState(key, defaultValue)
}
export function useStaticDataSource<T = any>(defaultValue: T) {
return [defaultValue, () => {}, () => {}] as const
}
export function useComputedDataSource<T = any>(expression: string | (() => T), dependencies: string[] = []) {
const computedValue = typeof expression === 'function' ? expression() : expression
return [computedValue, () => {}, () => {}] as const
}
export function useMultipleDataSources(_sources: DataSourceConfig[]) {
return {}
}
@@ -0,0 +1,135 @@
import { useState, useCallback, useMemo } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
import { DataSource } from '@/types/json-ui'
import { setNestedValue } from '@/lib/json-ui/utils'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const [localOverrides, setLocalOverrides] = useState<Record<string, any>>({})
const kvSources = useMemo(
() => dataSources.filter(ds => ds.type === 'kv'),
[dataSources]
)
const kvState0 = useUIState(kvSources[0]?.key || 'ds-0', kvSources[0]?.defaultValue)
const kvState1 = useUIState(kvSources[1]?.key || 'ds-1', kvSources[1]?.defaultValue)
const kvState2 = useUIState(kvSources[2]?.key || 'ds-2', kvSources[2]?.defaultValue)
const kvState3 = useUIState(kvSources[3]?.key || 'ds-3', kvSources[3]?.defaultValue)
const kvState4 = useUIState(kvSources[4]?.key || 'ds-4', kvSources[4]?.defaultValue)
const kv0 = kvState0[0]
const kv1 = kvState1[0]
const kv2 = kvState2[0]
const kv3 = kvState3[0]
const kv4 = kvState4[0]
const kvValues = [kv0, kv1, kv2, kv3, kv4]
const kvSetters = useMemo(
() => [kvState0[1], kvState1[1], kvState2[1], kvState3[1], kvState4[1]],
[kvState0[1], kvState1[1], kvState2[1], kvState3[1], kvState4[1]]
)
const baseData = useMemo(() => {
const result: Record<string, any> = {}
dataSources.forEach((source) => {
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1) {
result[source.id] = kvValues[kvIndex]
}
} else if (source.type === 'static') {
result[source.id] = source.defaultValue
}
})
return result
}, [dataSources, kvSources, kv0, kv1, kv2, kv3, kv4])
const data = useMemo(
() => (Object.keys(localOverrides).length > 0
? { ...baseData, ...localOverrides }
: baseData),
[baseData, localOverrides]
)
const derivedSources = useMemo(
() => dataSources.filter(ds => ds.expression || ds.valueTemplate),
[dataSources]
)
const allData = useMemo(() => {
if (derivedSources.length === 0) return data
const result: Record<string, any> = { ...data }
derivedSources.forEach(source => {
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in result)
if (hasAllDeps) {
const evaluationContext = { data: result }
const derivedValue = source.expression
? evaluateExpression(source.expression, evaluationContext)
: source.valueTemplate
? evaluateTemplate(source.valueTemplate, evaluationContext)
: source.defaultValue
result[source.id] = derivedValue
}
})
return result
}, [data, derivedSources])
const updateData = useCallback((sourceId: string, value: any) => {
const source = dataSources.find(ds => ds.id === sourceId)
if (!source) {
console.warn(`Data source ${sourceId} not found`)
return
}
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1) {
kvSetters[kvIndex](value)
}
} else {
setLocalOverrides(prev => ({ ...prev, [sourceId]: value }))
}
}, [dataSources, kvSources, kvSetters])
const updatePath = useCallback((sourceId: string, path: string, value: any) => {
const source = dataSources.find(ds => ds.id === sourceId)
if (!source) {
console.warn(`Data source ${sourceId} not found`)
return
}
const currentData = allData[sourceId]
if (!currentData || typeof currentData !== 'object') {
return
}
const newData = Array.isArray(currentData) ? [...currentData] : { ...currentData }
setNestedValue(newData, path, value)
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1) {
kvSetters[kvIndex](newData)
}
} else {
setLocalOverrides(prev => ({ ...prev, [sourceId]: newData }))
}
}, [dataSources, kvSources, kvSetters, allData])
return {
data: allData,
updateData,
updatePath,
loading: false,
}
}
@@ -0,0 +1,45 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { addFile as addFileAction, updateFile as updateFileAction, removeFile, setFiles } from '@/store/slices/filesSlice'
import { useCallback } from 'react'
import { ProjectFile } from '@/types/project'
export function useFiles() {
const dispatch = useAppDispatch()
const sliceFiles = useAppSelector((s) => s.files?.files ?? [])
const files = sliceFiles as unknown as ProjectFile[]
const addFile = useCallback((file: ProjectFile) => {
dispatch(addFileAction(file as any))
}, [dispatch])
const updateFile = useCallback((fileId: string, updates: Partial<ProjectFile>) => {
const existing = files.find(f => f.id === fileId)
if (existing) {
dispatch(updateFileAction({ ...existing, ...updates } as any))
}
}, [dispatch, files])
const deleteFile = useCallback((fileId: string) => {
dispatch(removeFile(fileId))
}, [dispatch])
const getFile = useCallback((fileId: string) => {
return files.find(f => f.id === fileId)
}, [files])
const updateFileContent = useCallback((fileId: string, content: string) => {
const existing = files.find(f => f.id === fileId)
if (existing) {
dispatch(updateFileAction({ ...existing, content } as any))
}
}, [dispatch, files])
return {
files,
addFile,
updateFile,
deleteFile,
getFile,
updateFileContent,
}
}
@@ -0,0 +1,54 @@
import { useState, useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
export interface UseJSONDataOptions {
key: string
defaultValue: any
persist?: boolean
}
export function useJSONData(options: UseJSONDataOptions) {
const { key, defaultValue, persist = true } = options
const [kvValue, setKvValue] = useUIState(key, defaultValue)
const [localValue, setLocalValue] = useState(defaultValue)
const value = persist ? kvValue : localValue
const setValue = persist ? setKvValue : setLocalValue
const update = useCallback((updater: ((prev: any) => any) | any) => {
if (typeof updater === 'function') {
setValue(updater)
} else {
setValue(updater)
}
}, [setValue])
const updatePath = useCallback((path: string, newValue: any) => {
setValue((current: any) => {
const keys = path.split('.')
const result = { ...current }
let target: any = result
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
target[key] = { ...target[key] }
target = target[key]
}
target[keys[keys.length - 1]] = newValue
return result
})
}, [setValue])
const reset = useCallback(() => {
setValue(defaultValue)
}, [setValue, defaultValue])
return {
value,
setValue: update,
updatePath,
reset,
}
}
@@ -0,0 +1,37 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { addLambda as addLambdaAction, updateLambda as updateLambdaAction, deleteLambda as removeLambda, setLambdas } from '@/store/slices/lambdasSlice'
import { useCallback } from 'react'
import { Lambda } from '@/types/project'
export function useLambdas() {
const dispatch = useAppDispatch()
const sliceLambdas = useAppSelector((s) => s.lambdas?.lambdas ?? [])
const lambdas = sliceLambdas as unknown as Lambda[]
const addLambda = useCallback((lambda: Lambda) => {
dispatch(addLambdaAction(lambda as any))
}, [dispatch])
const updateLambda = useCallback((lambdaId: string, updates: Partial<Lambda>) => {
const existing = lambdas.find(l => l.id === lambdaId)
if (existing) {
dispatch(updateLambdaAction({ ...existing, ...updates } as any))
}
}, [dispatch, lambdas])
const deleteLambda = useCallback((lambdaId: string) => {
dispatch(removeLambda(lambdaId))
}, [dispatch])
const getLambda = useCallback((lambdaId: string) => {
return lambdas.find(l => l.id === lambdaId)
}, [lambdas])
return {
lambdas,
addLambda,
updateLambda,
deleteLambda,
getLambda,
}
}
@@ -0,0 +1,34 @@
import { useState, useCallback } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error loading localStorage key "${key}":`, error)
return initialValue
}
})
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}, [key, storedValue])
const remove = useCallback(() => {
try {
window.localStorage.removeItem(key)
setStoredValue(initialValue)
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error)
}
}, [key, initialValue])
return [storedValue, setValue, remove] as const
}
@@ -0,0 +1,37 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { addModel as addModelAction, updateModel as updateModelAction, removeModel, setModels } from '@/store/slices/modelsSlice'
import { useCallback } from 'react'
import { DbModel } from '@/types/project'
export function useModels() {
const dispatch = useAppDispatch()
const sliceModels = useAppSelector((s) => s.models?.models ?? [])
const models = sliceModels as unknown as DbModel[]
const addModel = useCallback((model: DbModel) => {
dispatch(addModelAction(model as any))
}, [dispatch])
const updateModel = useCallback((modelId: string, updates: Partial<DbModel>) => {
const existing = models.find(m => m.id === modelId)
if (existing) {
dispatch(updateModelAction({ ...existing, ...updates } as any))
}
}, [dispatch, models])
const deleteModel = useCallback((modelId: string) => {
dispatch(removeModel(modelId))
}, [dispatch])
const getModel = useCallback((modelId: string) => {
return models.find(m => m.id === modelId)
}, [models])
return {
models,
addModel,
updateModel,
deleteModel,
getModel,
}
}
@@ -0,0 +1,56 @@
import { useState, useMemo, useCallback } from 'react'
export interface SearchFilterConfig<T> {
items: T[]
searchFields?: (keyof T)[]
filterFn?: (item: T, filters: Record<string, any>) => boolean
}
export function useSearchFilter<T extends Record<string, any>>({
items,
searchFields = [],
filterFn,
}: SearchFilterConfig<T>) {
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<Record<string, any>>({})
const filtered = useMemo(() => {
let result = items
if (searchQuery && searchFields.length > 0) {
const query = searchQuery.toLowerCase()
result = result.filter(item =>
searchFields.some(field => {
const value = item[field]
return String(value).toLowerCase().includes(query)
})
)
}
if (filterFn && Object.keys(filters).length > 0) {
result = result.filter(item => filterFn(item, filters))
}
return result
}, [items, searchQuery, searchFields, filters, filterFn])
const setFilter = useCallback((key: string, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }))
}, [])
const clearFilters = useCallback(() => {
setSearchQuery('')
setFilters({})
}, [])
return {
searchQuery,
setSearchQuery,
filters,
setFilter,
clearFilters,
filtered,
count: filtered.length,
total: items.length,
}
}
@@ -0,0 +1,77 @@
import { useState, useCallback } from 'react'
export interface SelectionConfig<T> {
items: T[]
multiple?: boolean
idField?: keyof T
}
export function useSelection<T extends Record<string, any>>({
items,
multiple = false,
idField = 'id' as keyof T,
}: SelectionConfig<T>) {
const [selected, setSelected] = useState<Set<string | number>>(new Set())
const toggle = useCallback((id: string | number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
if (!multiple) {
next.clear()
}
next.add(id)
}
return next
})
}, [multiple])
const select = useCallback((id: string | number) => {
setSelected(prev => {
const next: Set<string | number> = multiple ? new Set(prev) : new Set<string | number>()
next.add(id)
return next
})
}, [multiple])
const deselect = useCallback((id: string | number) => {
setSelected(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const selectAll = useCallback(() => {
if (multiple) {
setSelected(new Set(items.map(item => item[idField])))
}
}, [items, idField, multiple])
const deselectAll = useCallback(() => {
setSelected(new Set())
}, [])
const isSelected = useCallback((id: string | number) => {
return selected.has(id)
}, [selected])
const getSelected = useCallback(() => {
return items.filter(item => selected.has(item[idField]))
}, [items, selected, idField])
return {
selected,
toggle,
select,
deselect,
selectAll,
deselectAll,
isSelected,
getSelected,
count: selected.size,
hasSelection: selected.size > 0,
}
}
@@ -0,0 +1,37 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { addWorkflow as addWorkflowAction, updateWorkflow as updateWorkflowAction, removeWorkflow, setWorkflows } from '@/store/slices/workflowsSlice'
import { useCallback } from 'react'
import { Workflow } from '@/types/project'
export function useWorkflows() {
const dispatch = useAppDispatch()
const sliceWorkflows = useAppSelector((s) => s.workflows?.workflows ?? [])
const workflows = sliceWorkflows as unknown as Workflow[]
const addWorkflow = useCallback((workflow: Workflow) => {
dispatch(addWorkflowAction(workflow as any))
}, [dispatch])
const updateWorkflow = useCallback((workflowId: string, updates: Partial<Workflow>) => {
const existing = workflows.find(w => w.id === workflowId)
if (existing) {
dispatch(updateWorkflowAction({ ...existing, ...updates } as any))
}
}, [dispatch, workflows])
const deleteWorkflow = useCallback((workflowId: string) => {
dispatch(removeWorkflow(workflowId))
}, [dispatch])
const getWorkflow = useCallback((workflowId: string) => {
return workflows.find(w => w.id === workflowId)
}, [workflows])
return {
workflows,
addWorkflow,
updateWorkflow,
deleteWorkflow,
getWorkflow,
}
}
@@ -0,0 +1,4 @@
export * from './use-feature-ideas'
export * from './use-idea-groups'
export * from './use-idea-connections'
export * from './use-node-positions'
@@ -0,0 +1,66 @@
import { useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
import seedData from '@/data/feature-idea-cloud.json'
export interface FeatureIdea {
id: string
title: string
description: string
category: string
priority: 'low' | 'medium' | 'high'
status: 'idea' | 'planned' | 'in-progress' | 'completed'
createdAt: number
parentGroup?: string
}
type SeedIdea = Omit<FeatureIdea, 'createdAt'> & { createdAtOffset: number }
const buildSeedIdeas = (): FeatureIdea[] => {
const now = Date.now()
return (seedData.ideas as SeedIdea[]).map((idea) => ({
...idea,
createdAt: now + idea.createdAtOffset,
}))
}
const SEED_IDEAS = buildSeedIdeas()
export function useFeatureIdeas() {
const [ideas, setIdeas] = useUIState<FeatureIdea[]>('feature-ideas', SEED_IDEAS)
const addIdea = useCallback((idea: FeatureIdea) => {
setIdeas((current) => [...(current || []), idea])
}, [setIdeas])
const updateIdea = useCallback((id: string, updates: Partial<FeatureIdea>) => {
setIdeas((current) =>
(current || []).map(idea =>
idea.id === id ? { ...idea, ...updates } : idea
)
)
}, [setIdeas])
const deleteIdea = useCallback((id: string) => {
setIdeas((current) => (current || []).filter(idea => idea.id !== id))
}, [setIdeas])
const saveIdea = useCallback((idea: FeatureIdea) => {
setIdeas((current) => {
const existing = (current || []).find(i => i.id === idea.id)
if (existing) {
return (current || []).map(i => i.id === idea.id ? idea : i)
} else {
return [...(current || []), idea]
}
})
}, [setIdeas])
return {
ideas: ideas || SEED_IDEAS,
addIdea,
updateIdea,
deleteIdea,
saveIdea,
setIdeas,
}
}
@@ -0,0 +1,148 @@
import { useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
import { Edge, MarkerType } from 'reactflow'
import { toast } from '@/components/ui/sonner'
export interface IdeaEdgeData {
label?: string
}
const DEFAULT_EDGES: Edge<IdeaEdgeData>[] = [
{
id: 'edge-1',
source: 'idea-1',
target: 'idea-8',
sourceHandle: 'right-0',
targetHandle: 'left-0',
type: 'default',
animated: false,
data: { label: 'requires' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a78bfa', width: 20, height: 20 },
style: { stroke: '#a78bfa', strokeWidth: 2.5 },
},
]
export const CONNECTION_STYLE = {
stroke: '#a78bfa',
strokeWidth: 2.5
}
export function useIdeaConnections() {
const [edges, setEdges] = useUIState<Edge<IdeaEdgeData>[]>('feature-idea-edges', DEFAULT_EDGES)
const validateAndRemoveConflicts = useCallback((
currentEdges: Edge<IdeaEdgeData>[],
sourceNodeId: string,
sourceHandleId: string,
targetNodeId: string,
targetHandleId: string,
excludeEdgeId?: string
): { filteredEdges: Edge<IdeaEdgeData>[], removedCount: number, conflicts: string[] } => {
const edgesToRemove: string[] = []
const conflicts: string[] = []
currentEdges.forEach(edge => {
if (excludeEdgeId && edge.id === excludeEdgeId) return
const edgeSourceHandle = edge.sourceHandle || 'default'
const edgeTargetHandle = edge.targetHandle || 'default'
const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId
const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId
if (hasSourceConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Source: ${edge.source}[${edgeSourceHandle}] was connected to ${edge.target}[${edgeTargetHandle}]`)
}
if (hasTargetConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Target: ${edge.target}[${edgeTargetHandle}] was connected from ${edge.source}[${edgeSourceHandle}]`)
}
})
const filteredEdges = currentEdges.filter(e => !edgesToRemove.includes(e.id))
return {
filteredEdges,
removedCount: edgesToRemove.length,
conflicts
}
}, [])
const createConnection = useCallback((
sourceNodeId: string,
sourceHandleId: string,
targetNodeId: string,
targetHandleId: string
) => {
setEdges((current) => {
const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts(
current || [],
sourceNodeId,
sourceHandleId,
targetNodeId,
targetHandleId
)
const newEdge: Edge<IdeaEdgeData> = {
id: `edge-${Date.now()}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle: sourceHandleId,
targetHandle: targetHandleId,
type: 'default',
data: { label: 'relates to' },
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth
},
animated: false,
}
const updatedEdges = [...filteredEdges, newEdge]
if (removedCount > 0) {
setTimeout(() => {
toast.success(`Connection remapped! (${removedCount} old connection${removedCount > 1 ? 's' : ''} removed)`, {
description: conflicts.join('\n')
})
}, 0)
} else {
setTimeout(() => {
toast.success('Ideas connected!')
}, 0)
}
return updatedEdges
})
}, [setEdges, validateAndRemoveConflicts])
const updateConnection = useCallback((edgeId: string, updates: Partial<Edge<IdeaEdgeData>>) => {
setEdges((current) =>
(current || []).map(edge =>
edge.id === edgeId ? { ...edge, ...updates } : edge
)
)
}, [setEdges])
const deleteConnection = useCallback((edgeId: string) => {
setEdges((current) => (current || []).filter(edge => edge.id !== edgeId))
toast.success('Connection removed')
}, [setEdges])
return {
edges: edges || DEFAULT_EDGES,
setEdges,
createConnection,
updateConnection,
deleteConnection,
validateAndRemoveConflicts,
}
}
@@ -0,0 +1,49 @@
import { useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
export interface IdeaGroup {
id: string
label: string
color: string
createdAt: number
}
export function useIdeaGroups() {
const [groups, setGroups] = useUIState<IdeaGroup[]>('feature-idea-groups', [])
const addGroup = useCallback((group: IdeaGroup) => {
setGroups((current) => [...(current || []), group])
}, [setGroups])
const updateGroup = useCallback((id: string, updates: Partial<IdeaGroup>) => {
setGroups((current) =>
(current || []).map(group =>
group.id === id ? { ...group, ...updates } : group
)
)
}, [setGroups])
const deleteGroup = useCallback((id: string) => {
setGroups((current) => (current || []).filter(group => group.id !== id))
}, [setGroups])
const saveGroup = useCallback((group: IdeaGroup) => {
setGroups((current) => {
const existing = (current || []).find(g => g.id === group.id)
if (existing) {
return (current || []).map(g => g.id === group.id ? group : g)
} else {
return [...(current || []), group]
}
})
}, [setGroups])
return {
groups: groups || [],
addGroup,
updateGroup,
deleteGroup,
saveGroup,
setGroups,
}
}
@@ -0,0 +1,40 @@
import { useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
export function useNodePositions() {
const [positions, setPositions] = useUIState<Record<string, { x: number; y: number }>>('feature-idea-node-positions', {})
const updatePosition = useCallback((nodeId: string, position: { x: number; y: number }) => {
setPositions((current) => ({
...(current || {}),
[nodeId]: position
}))
}, [setPositions])
const updatePositions = useCallback((updates: Record<string, { x: number; y: number }>) => {
setPositions((current) => ({
...(current || {}),
...updates
}))
}, [setPositions])
const deletePosition = useCallback((nodeId: string) => {
setPositions((current) => {
const newPositions = { ...(current || {}) }
delete newPositions[nodeId]
return newPositions
})
}, [setPositions])
const getPosition = useCallback((nodeId: string) => {
return positions?.[nodeId]
}, [positions])
return {
positions: positions || {},
updatePosition,
updatePositions,
deletePosition,
getPosition,
}
}
@@ -0,0 +1,3 @@
// Re-export from centralized @metabuilder/hooks package
export { useFormField, useForm } from '@metabuilder/hooks'
export type { ValidationRule, FieldConfig, FormConfig, UseFormFieldReturn, UseFormReturn } from '@metabuilder/hooks'
+71
View File
@@ -0,0 +1,71 @@
// Core hooks (codegen-specific)
export * from './core/use-debounced-save'
export * from './core/use-clipboard'
export * from './core/use-library-loader'
export * from './use-ui-state'
// UI hooks - re-exports from @metabuilder/hooks + codegen-specific
export * from './ui'
export * from './ui/use-selection'
export * from './ui/use-confirmation'
// Config hooks
export * from './config/use-page-config'
export * from './config/use-layout-state'
export * from './config/use-feature-flags'
// AI hooks
export * from './ai/use-ai-generation'
// Data hooks - re-exports from @metabuilder/hooks + codegen-specific
export { useKVDataSource, useComputedDataSource, useStaticDataSource, useMultipleDataSources } from './data/use-data-source'
export { useCRUD } from './data/use-crud'
export { useSearchFilter } from './data/use-search-filter'
export { useSelection as useDataSelection } from './data/use-selection'
// Re-export from centralized hooks
export { useSort, usePagination, useFilter, useSearch } from '@metabuilder/hooks'
// Form hooks - re-export from centralized package
export { useFormField, useForm } from '@metabuilder/hooks'
export * from './use-route-preload'
export * from './use-navigation-history'
export * from './use-theme-config'
// Generic UI hooks — re-exported from shared package
export {
useFocusState,
useCopyState,
usePasswordVisibility,
useImageState,
usePopoverState,
useAccordion,
useFormatValue,
useDialogState,
useMultipleDialogs,
useIsMobile,
useTabNavigation,
useLastSaved,
useActiveSelection,
} from '@metabuilder/hooks'
export * from './use-menu-state'
export * from './use-file-upload'
export * from './use-binding-editor'
export * from './use-repeat-wrapper'
export { useAppLayout } from './use-app-layout'
export { useAppRouterLayout } from './use-app-router-layout'
export { useNavigationMenu } from './use-navigation-menu'
export { useDataSourceManagerState } from './use-data-source-manager-state'
export { useConflictResolution } from './use-conflict-resolution'
export { useConflictCard } from './use-conflict-card'
export { useConflictDetailsDialog } from './use-conflict-details-dialog'
export { useConflictResolutionPage } from './use-conflict-resolution-page'
export { useConflictResolutionDemo } from './use-conflict-resolution-demo'
export { useDocumentationView } from './use-documentation-view'
export { useDockerBuildDebugger } from './use-docker-build-debugger'
export { useDataBindingDesigner } from './use-data-binding-designer'
export { useErrorPanelMain } from './use-error-panel-main'
export { usePersistenceDashboardView } from './use-persistence-dashboard-view'
export { useProjectSettingsView } from './use-project-settings-view'
export { useTranslation } from './use-translation'
export { useTranslationEditor } from './use-translation-editor'
export { useFeatureToggleSettings } from './use-feature-toggle-settings'
@@ -0,0 +1,2 @@
export { useJSONRenderer } from './use-json-renderer'
export { useDataSources } from './use-data-sources'
@@ -0,0 +1,74 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useAppDispatch, useAppSelector } from '@/store'
import { setUIState } from '@/store/slices/uiStateSlice'
import { DataSource } from '@/types/json-ui'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const dispatch = useAppDispatch()
const kvData = useAppSelector((state) => state.uiState.data)
const [data, setData] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const derivedSources = useMemo(
() => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
[dataSources]
)
useEffect(() => {
const initialData: Record<string, any> = {}
for (const ds of dataSources) {
if (ds.type === 'kv' && ds.key) {
const stored = kvData[ds.key]
initialData[ds.id] = stored !== undefined ? stored : ds.defaultValue
} else if (ds.type === 'static') {
initialData[ds.id] = ds.defaultValue
}
}
setData(initialData)
setLoading(false)
}, [dataSources, kvData])
const updateDataSource = useCallback((id: string, value: any) => {
setData((prev) => ({ ...prev, [id]: value }))
const kvSource = dataSources.find((ds) => ds.id === id && ds.type === 'kv')
if (kvSource && kvSource.key) {
dispatch(setUIState({ key: kvSource.key, value }))
}
}, [dataSources, dispatch])
const computedData = useMemo(() => {
const result: Record<string, any> = {}
derivedSources.forEach((ds) => {
const evaluationContext = { data: { ...data, ...result } }
if (ds.expression) {
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
return
}
if (ds.valueTemplate) {
result[ds.id] = evaluateTemplate(ds.valueTemplate, evaluationContext)
return
}
if (ds.defaultValue !== undefined) {
result[ds.id] = ds.defaultValue
}
})
return result
}, [derivedSources, data])
const allData = useMemo(
() => ({ ...data, ...computedData }),
[data, computedData]
)
return {
data: allData,
loading,
updateDataSource,
}
}
@@ -0,0 +1,43 @@
import { useMemo } from 'react'
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
export function useJSONRenderer() {
const resolveBinding = useMemo(() => {
return (binding: string, data: Record<string, any>): any => {
if (!binding) return undefined
return evaluateBindingExpression(binding, data, {
fallback: binding,
label: 'json renderer binding',
})
}
}, [])
const resolveValue = useMemo(() => {
return (value: any, data: Record<string, any>): any => {
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
const binding = value.slice(2, -2).trim()
return resolveBinding(binding, data)
}
return value
}
}, [resolveBinding])
const resolveProps = useMemo(() => {
return (props: Record<string, any>, data: Record<string, any>): Record<string, any> => {
const resolved: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
resolved[key] = resolveValue(value, data)
}
return resolved
}
}, [resolveValue])
return {
resolveBinding,
resolveValue,
resolveProps,
}
}
@@ -0,0 +1,2 @@
export { usePage } from './use-page'
export { useActions } from './use-actions'
@@ -0,0 +1,133 @@
import { useState, useCallback, useMemo } from 'react'
import { ActionConfig } from '@/types/page-schema'
import { toast } from '@/components/ui/sonner'
import { llm } from '@/lib/llm-service'
export function useActions(actions: ActionConfig[] = [], context: Record<string, any> = {}) {
const [isExecuting, setIsExecuting] = useState(false)
const actionHandlers = useMemo(() => {
const handlers: Record<string, (params?: any) => Promise<void>> = {}
actions.forEach(action => {
handlers[action.id] = async (params?: any) => {
setIsExecuting(true)
try {
const mergedParams = { ...action.params, ...params }
switch (action.type) {
case 'create':
await handleCreate(mergedParams, context)
break
case 'update':
await handleUpdate(mergedParams, context)
break
case 'delete':
await handleDelete(mergedParams, context)
break
case 'navigate':
await handleNavigate(mergedParams)
break
case 'ai-generate':
await handleAIGenerate(mergedParams, context)
break
case 'custom':
if (action.handler) {
const customHandler = getCustomHandler(action.handler, context)
await customHandler(mergedParams)
}
break
}
if (action.onSuccess) {
const successHandler = handlers[action.onSuccess]
if (successHandler) await successHandler()
}
} catch (error) {
console.error(`Action ${action.id} failed:`, error)
toast.error(`Action failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
if (action.onError) {
const errorHandler = handlers[action.onError]
if (errorHandler) await errorHandler({ error })
}
} finally {
setIsExecuting(false)
}
}
})
return handlers
}, [actions, context])
const execute = useCallback(async (actionId: string, params?: any) => {
const handler = actionHandlers[actionId]
if (handler) {
await handler(params)
} else {
console.warn(`Action ${actionId} not found`)
}
}, [actionHandlers])
return {
execute,
isExecuting,
handlers: actionHandlers,
}
}
async function handleCreate(params: any, context: Record<string, any>) {
const { target, data } = params
const setter = context[`set${target}`]
if (setter) {
setter((current: any[]) => [...current, data])
toast.success(`${target} created`)
}
}
async function handleUpdate(params: any, context: Record<string, any>) {
const { target, id, data } = params
const setter = context[`set${target}`]
if (setter) {
setter((current: any[]) =>
current.map((item: any) => item.id === id ? { ...item, ...data } : item)
)
toast.success(`${target} updated`)
}
}
async function handleDelete(params: any, context: Record<string, any>) {
const { target, id } = params
const setter = context[`set${target}`]
if (setter) {
setter((current: any[]) => current.filter((item: any) => item.id !== id))
toast.success(`${target} deleted`)
}
}
async function handleNavigate(params: any) {
const { to, tab } = params
if (tab) {
window.location.hash = `#${tab}`
} else if (to) {
window.location.href = to
}
}
async function handleAIGenerate(params: any, context: Record<string, any>) {
const { prompt, target } = params
const result = await llm(prompt)
if (target && context[`set${target}`]) {
context[`set${target}`](result)
}
toast.success('AI generation complete')
}
function getCustomHandler(handlerName: string, context: Record<string, any>) {
return context[handlerName] || (() => {
console.warn(`Custom handler ${handlerName} not found in context`)
})
}
@@ -0,0 +1,79 @@
import { useState, useEffect, useMemo } from 'react'
import { PageSchema } from '@/types/page-schema'
import { useFiles } from '../data/use-files'
import { useModels } from '../data/use-models'
import { useComponents } from '../data/use-components'
import { useWorkflows } from '../data/use-workflows'
import { useLambdas } from '../data/use-lambdas'
import { useActions } from './use-actions'
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function usePage(schema: PageSchema) {
const files = useFiles()
const models = useModels()
const components = useComponents()
const workflows = useWorkflows()
const lambdas = useLambdas()
const [computedData, setComputedData] = useState<Record<string, any>>({})
const dataContext = useMemo(() => {
const context: Record<string, any> = {
files: files.files,
models: models.models,
components: components.components,
workflows: workflows.workflows,
lambdas: lambdas.lambdas,
setFiles: files.addFile,
setModels: models.addModel,
setComponents: components.addComponent,
setWorkflows: workflows.addWorkflow,
setLambdas: lambdas.addLambda,
...computedData,
}
if (schema.seedData) {
Object.assign(context, schema.seedData)
}
return context
}, [files, models, components, workflows, lambdas, computedData, schema.seedData])
const { execute, isExecuting, handlers } = useActions(schema.actions, dataContext)
useEffect(() => {
if (schema.data) {
const computed: Record<string, any> = {}
schema.data.forEach(source => {
if (source.expression) {
computed[source.id] = evaluateBindingExpression(source.expression, { ...dataContext, ...computed }, {
fallback: undefined,
label: `derived data (${source.id})`,
})
return
}
if (source.valueTemplate) {
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: { ...dataContext, ...computed } })
return
}
if (source.type === 'static' && source.defaultValue !== undefined) {
computed[source.id] = source.defaultValue
}
})
setComputedData(computed)
}
}, [schema.data, dataContext])
return {
context: dataContext,
execute,
isExecuting,
handlers,
schema,
}
}
+7
View File
@@ -0,0 +1,7 @@
// Codegen-specific UI hooks
export { useDashboardMetrics } from './use-dashboard-metrics'
export { useDashboardTips } from './use-dashboard-tips'
// Re-export from centralized @metabuilder/hooks package
export { useToggle, useDialog } from '@metabuilder/hooks'
export type { UseToggleReturn as UseToggleOptions, UseDialogReturn } from '@metabuilder/hooks'
@@ -0,0 +1,281 @@
import { useCallback } from 'react'
import { toast } from '@/components/ui/sonner'
import { Action, JSONUIContext } from '@/types/json-ui'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
import { getNestedValue } from '@/lib/json-ui/utils'
export function useActionExecutor(context: JSONUIContext) {
const { data, updateData, updatePath, executeAction: contextExecute } = context
const getTargetParts = (target?: string) => {
if (!target) return null
const [sourceId, ...pathParts] = target.split('.')
const path = pathParts.join('.')
return { sourceId, path: path || undefined }
}
const resolveActionExpression = (expression: string, mergedData: Record<string, any>, event?: any) => {
let normalized = expression.trim()
if (!normalized.startsWith('data.') && !normalized.startsWith('event.')
&& !normalized.startsWith('"') && !normalized.startsWith("'")
&& !/^-?\d/.test(normalized) && normalized !== 'true' && normalized !== 'false'
&& normalized !== 'null' && normalized !== 'undefined' && normalized !== 'Date.now()') {
normalized = `data.${normalized}`
}
return evaluateExpression(normalized, { data: mergedData, event })
}
const executeAction = useCallback(async (action: Action, event?: any) => {
try {
const eventContext = event && typeof event === 'object' ? event : {}
const mergedData = { ...data, ...eventContext }
const evaluationContext = { data: mergedData, event }
const updateByPath = (sourceId: string, path: string, value: any) => {
if (updatePath) {
updatePath(sourceId, path, value)
return
}
const sourceData = data[sourceId] ?? {}
const pathParts = path.split('.')
const newData = { ...sourceData }
let current: any = newData
for (let i = 0; i < pathParts.length - 1; i++) {
const key = pathParts[i]
current[key] = typeof current[key] === 'object' && current[key] !== null ? { ...current[key] } : {}
current = current[key]
}
current[pathParts[pathParts.length - 1]] = value
updateData(sourceId, newData)
}
const resolveDialogTarget = () => {
const defaultSourceId = 'uiState'
const hasExplicitTarget = Boolean(action.target && action.path)
const sourceId = (hasExplicitTarget ? action.target : defaultSourceId) ?? defaultSourceId
const dialogId = action.path ?? action.target
if (!dialogId) return null
const dialogPath = dialogId.startsWith('dialogs.') ? dialogId : `dialogs.${dialogId}`
return { sourceId, dialogPath }
}
switch (action.type) {
case 'create': {
if (!action.target) return
const currentData = data[action.target] || []
let newValue
if (action.expression) {
newValue = resolveActionExpression(action.expression, mergedData, event)
} else if (action.valueTemplate) {
// New: JSON template with dynamic values
newValue = evaluateTemplate(action.valueTemplate, { data: mergedData, event })
} else {
// Fallback: static value
newValue = action.value
}
updateData(action.target, [...currentData, newValue])
break
}
case 'update': {
const targetParts = getTargetParts(action.target)
if (!targetParts) return
let newValue
if (action.expression) {
newValue = resolveActionExpression(action.expression, mergedData, event)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, { data: mergedData, event })
} else {
newValue = action.value
}
if (targetParts.path) {
if (updatePath) {
updatePath(targetParts.sourceId, targetParts.path, newValue)
}
} else {
updateData(targetParts.sourceId, newValue)
}
break
}
case 'delete': {
if (!action.target) return
const currentData = data[action.target] || []
let selectorValue
if (action.expression) {
selectorValue = resolveActionExpression(action.expression, mergedData, event)
} else if (action.valueTemplate) {
selectorValue = evaluateTemplate(action.valueTemplate, { data: mergedData, event })
} else {
selectorValue = action.value
}
if (selectorValue === undefined) return
const filtered = currentData.filter((item: any) => {
if (action.path) {
return getNestedValue(item, action.path) !== selectorValue
}
return item !== selectorValue
})
updateData(action.target, filtered)
break
}
case 'set-value': {
const targetParts = getTargetParts(action.target)
if (!targetParts) return
let newValue
if (action.expression) {
newValue = resolveActionExpression(action.expression, mergedData, event)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, { data: mergedData, event })
} else {
newValue = action.value
}
if (targetParts.path) {
if (updatePath) {
updatePath(targetParts.sourceId, targetParts.path, newValue)
}
} else {
updateData(targetParts.sourceId, newValue)
}
break
}
case 'toggle-value': {
const targetParts = getTargetParts(action.target)
if (!targetParts) return
const currentValue = targetParts.path
? getNestedValue(data[targetParts.sourceId], targetParts.path)
: data[targetParts.sourceId]
const nextValue = !currentValue
if (targetParts.path) {
if (updatePath) {
updatePath(targetParts.sourceId, targetParts.path, nextValue)
}
} else {
updateData(targetParts.sourceId, nextValue)
}
break
}
case 'increment': {
const targetParts = getTargetParts(action.target)
if (!targetParts) return
const currentValue = targetParts.path
? getNestedValue(data[targetParts.sourceId], targetParts.path)
: data[targetParts.sourceId]
const amount = action.value || 1
const nextValue = (currentValue || 0) + amount
if (targetParts.path) {
if (updatePath) {
updatePath(targetParts.sourceId, targetParts.path, nextValue)
}
} else {
updateData(targetParts.sourceId, nextValue)
}
break
}
case 'decrement': {
const targetParts = getTargetParts(action.target)
if (!targetParts) return
const currentValue = targetParts.path
? getNestedValue(data[targetParts.sourceId], targetParts.path)
: data[targetParts.sourceId]
const amount = action.value || 1
const nextValue = (currentValue || 0) - amount
if (targetParts.path) {
if (updatePath) {
updatePath(targetParts.sourceId, targetParts.path, nextValue)
}
} else {
updateData(targetParts.sourceId, nextValue)
}
break
}
case 'show-toast': {
const message = action.message || 'Action completed'
const variant = action.variant || 'success'
switch (variant) {
case 'success':
toast.success(message)
break
case 'error':
toast.error(message)
break
case 'info':
toast.info(message)
break
case 'warning':
toast.warning(message)
break
}
break
}
case 'navigate': {
if (action.path) {
window.location.hash = action.path
}
break
}
case 'open-dialog': {
const dialogTarget = resolveDialogTarget()
if (!dialogTarget) return
updateByPath(dialogTarget.sourceId, dialogTarget.dialogPath, true)
break
}
case 'close-dialog': {
const dialogTarget = resolveDialogTarget()
if (!dialogTarget) return
updateByPath(dialogTarget.sourceId, dialogTarget.dialogPath, false)
break
}
case 'custom': {
if (contextExecute) {
await contextExecute(action, event)
}
break
}
}
} catch (error) {
console.error('Action execution failed:', error)
toast.error('Action failed')
}
}, [data, updateData, updatePath, contextExecute])
const executeActions = useCallback(async (actions: Action[], event?: any) => {
for (const action of actions) {
await executeAction(action, event)
}
}, [executeAction])
return {
executeAction,
executeActions,
}
}
@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react'
import { toast } from '@/components/ui/sonner'
export function useClipboard(successMessage?: string) {
const [copied, setCopied] = useState(false)
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (successMessage) {
toast.success(successMessage)
}
setTimeout(() => setCopied(false), 2000)
return true
} catch (error) {
console.error('Failed to copy:', error)
toast.error('Failed to copy to clipboard')
return false
}
},
[successMessage]
)
return {
copied,
copy,
}
}
@@ -0,0 +1,55 @@
import { useState, useCallback } from 'react'
export interface ConfirmDialogOptions {
title: string
description: string
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive'
}
export interface ConfirmDialogState {
isOpen: boolean
options: ConfirmDialogOptions | null
resolve: ((value: boolean) => void) | null
}
export function useConfirmDialog() {
const [state, setState] = useState<ConfirmDialogState>({
isOpen: false,
options: null,
resolve: null,
})
const confirm = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
return new Promise((resolve) => {
setState({
isOpen: true,
options,
resolve,
})
})
}, [])
const handleConfirm = useCallback(() => {
if (state.resolve) {
state.resolve(true)
}
setState({ isOpen: false, options: null, resolve: null })
}, [state.resolve])
const handleCancel = useCallback(() => {
if (state.resolve) {
state.resolve(false)
}
setState({ isOpen: false, options: null, resolve: null })
}, [state.resolve])
return {
isOpen: state.isOpen,
options: state.options,
confirm,
handleConfirm,
handleCancel,
}
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react'
interface ConfirmationState {
open: boolean
title: string
description: string
onConfirm: () => void
onCancel?: () => void
}
export function useConfirmation() {
const [state, setState] = useState<ConfirmationState>({
open: false,
title: '',
description: '',
onConfirm: () => {},
})
const confirm = useCallback(
(
title: string,
description: string,
onConfirm: () => void,
onCancel?: () => void
) => {
setState({
open: true,
title,
description,
onConfirm,
onCancel,
})
},
[]
)
const handleConfirm = useCallback(() => {
state.onConfirm()
setState((prev) => ({ ...prev, open: false }))
}, [state])
const handleCancel = useCallback(() => {
state.onCancel?.()
setState((prev) => ({ ...prev, open: false }))
}, [state])
return {
state,
confirm,
handleConfirm,
handleCancel,
}
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react'
import { ProjectFile, DbModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig } from '@/types/project'
interface DashboardMetrics {
totalFiles: number
totalModels: number
totalComponents: number
totalThemeVariants: number
totalEndpoints: number
totalTests: number
playwrightCount: number
storybookCount: number
unitTestCount: number
blueprintCount: number
completionScore: number
completionMessage: string
isReadyToExport: boolean
}
interface UseDashboardMetricsProps {
files: ProjectFile[]
models: DbModel[]
components: ComponentNode[]
theme: ThemeConfig
playwrightTests: PlaywrightTest[]
storybookStories: StorybookStory[]
unitTests: UnitTest[]
flaskConfig: FlaskConfig
}
function calculateCompletionScore(metrics: {
files: number
models: number
components: number
tests: number
}): number {
let score = 0
if (metrics.files > 0) score += 25
if (metrics.models > 0) score += 25
if (metrics.components > 0) score += 25
if (metrics.tests > 0) score += 25
return score
}
function getCompletionMessage(score: number): string {
if (score >= 90) return 'Excellent! Your project is complete and ready for export.'
if (score >= 70) return 'Great progress! Add a few more tests to reach 100%.'
if (score >= 50) return 'Making good progress. Keep adding features and tests.'
if (score >= 25) return 'Good start! Continue building your application.'
return 'Just getting started. Create files, models, and components to begin.'
}
export function useDashboardMetrics({
files,
models,
components,
theme,
playwrightTests,
storybookStories,
unitTests,
flaskConfig,
}: UseDashboardMetricsProps): DashboardMetrics {
return useMemo(() => {
const totalFiles = files.length
const totalModels = models.length
const totalComponents = components.length
const totalThemeVariants = theme?.variants?.length || 0
const totalEndpoints = flaskConfig.blueprints.reduce((acc, bp) => acc + bp.endpoints.length, 0)
const totalTests = playwrightTests.length + storybookStories.length + unitTests.length
const completionScore = calculateCompletionScore({
files: totalFiles,
models: totalModels,
components: totalComponents,
tests: totalTests,
})
return {
totalFiles,
totalModels,
totalComponents,
totalThemeVariants,
totalEndpoints,
totalTests,
playwrightCount: playwrightTests.length,
storybookCount: storybookStories.length,
unitTestCount: unitTests.length,
blueprintCount: flaskConfig.blueprints.length,
completionScore,
completionMessage: getCompletionMessage(completionScore),
isReadyToExport: completionScore >= 70,
}
}, [files, models, components, theme, playwrightTests, storybookStories, unitTests, flaskConfig])
}
@@ -0,0 +1,49 @@
import { useMemo } from 'react'
interface DashboardTip {
message: string
show: boolean
}
interface UseDashboardTipsProps {
totalFiles: number
totalModels: number
totalComponents: number
totalThemeVariants: number
totalTests: number
}
export function useDashboardTips({
totalFiles,
totalModels,
totalComponents,
totalThemeVariants,
totalTests,
}: UseDashboardTipsProps): DashboardTip[] {
return useMemo(() => {
const tips: DashboardTip[] = [
{
message: 'Start by creating some code files in the Code Editor tab',
show: totalFiles === 0,
},
{
message: 'Define your data models in the Models tab to set up your database',
show: totalModels === 0,
},
{
message: 'Build your UI structure in the Components tab',
show: totalComponents === 0,
},
{
message: 'Create additional theme variants (dark mode) in the Styling tab',
show: totalThemeVariants <= 1,
},
{
message: 'Add tests for better code quality and reliability',
show: totalTests === 0,
},
]
return tips.filter(tip => tip.show)
}, [totalFiles, totalModels, totalComponents, totalThemeVariants, totalTests])
}
@@ -0,0 +1,86 @@
import { useState, useCallback, useRef } from 'react'
export interface DragItem {
id: string
type: string
data: any
}
export interface DropPosition {
targetId: string
position: 'before' | 'after' | 'inside'
}
export function useDragDrop() {
const [draggedItem, setDraggedItem] = useState<DragItem | null>(null)
const [dropTarget, setDropTarget] = useState<string | null>(null)
const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null)
const dragStartPos = useRef<{ x: number; y: number } | null>(null)
const handleDragStart = useCallback((item: DragItem, e: React.DragEvent) => {
setDraggedItem(item)
dragStartPos.current = { x: e.clientX, y: e.clientY }
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify(item))
}, [])
const handleDragEnd = useCallback(() => {
setDraggedItem(null)
setDropTarget(null)
setDropPosition(null)
dragStartPos.current = null
}, [])
const handleDragOver = useCallback((targetId: string, e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const y = e.clientY - rect.top
const height = rect.height
let position: 'before' | 'after' | 'inside' = 'inside'
if (y < height * 0.25) {
position = 'before'
} else if (y > height * 0.75) {
position = 'after'
}
setDropTarget(targetId)
setDropPosition(position)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement
if (!related || !e.currentTarget.contains(related)) {
setDropTarget(null)
setDropPosition(null)
}
}, [])
const handleDrop = useCallback((targetId: string, e: React.DragEvent, onDrop?: (item: DragItem, target: DropPosition) => void) => {
e.preventDefault()
e.stopPropagation()
if (draggedItem && onDrop) {
onDrop(draggedItem, {
targetId,
position: dropPosition || 'inside',
})
}
handleDragEnd()
}, [draggedItem, dropPosition, handleDragEnd])
return {
draggedItem,
dropTarget,
dropPosition,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDragLeave,
handleDrop,
}
}
@@ -0,0 +1,131 @@
import { useState, useCallback } from 'react'
export interface FormFieldConfig<T = any> {
name: string
defaultValue: T
validate?: (value: T) => string | null
required?: boolean
}
export interface FormState<T extends Record<string, any>> {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
isValid: boolean
isDirty: boolean
}
export function useFormState<T extends Record<string, any>>(
fields: FormFieldConfig[],
initialValues?: Partial<T>
) {
const defaultValues: any = fields.reduce((acc: any, field) => {
acc[field.name] = initialValues?.[field.name] ?? field.defaultValue
return acc
}, {})
const [state, setState] = useState<FormState<T>>({
values: defaultValues,
errors: {},
touched: {},
isValid: true,
isDirty: false,
})
const validateField = useCallback(
(name: keyof T, value: any): string | null => {
const field = fields.find((f) => f.name === name)
if (!field) return null
if (field.required && !value) {
return 'This field is required'
}
if (field.validate) {
return field.validate(value)
}
return null
},
[fields]
)
const validateAll = useCallback((): boolean => {
const newErrors: Partial<Record<keyof T, string>> = {}
let isValid = true
fields.forEach((field) => {
const error = validateField(field.name as keyof T, state.values[field.name])
if (error) {
newErrors[field.name as keyof T] = error
isValid = false
}
})
setState((prev) => ({ ...prev, errors: newErrors, isValid }))
return isValid
}, [fields, state.values, validateField])
const setValue = useCallback(
(name: keyof T, value: any) => {
setState((prev) => {
const newValues = { ...prev.values, [name]: value }
const error = validateField(name, value)
const newErrors = { ...prev.errors }
if (error) {
newErrors[name] = error
} else {
delete newErrors[name]
}
return {
...prev,
values: newValues,
errors: newErrors,
isDirty: true,
isValid: Object.keys(newErrors).length === 0,
}
})
},
[validateField]
)
const setTouched = useCallback((name: keyof T) => {
setState((prev) => ({
...prev,
touched: { ...prev.touched, [name]: true },
}))
}, [])
const reset = useCallback(() => {
setState({
values: defaultValues,
errors: {},
touched: {},
isValid: true,
isDirty: false,
})
}, [defaultValues])
const setValues = useCallback((newValues: Partial<T>) => {
setState((prev) => ({
...prev,
values: { ...prev.values, ...newValues },
isDirty: true,
}))
}, [])
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isValid: state.isValid,
isDirty: state.isDirty,
setValue,
setTouched,
setValues,
reset,
validateAll,
}
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, useCallback } from 'react'
export interface FormField {
value: any
error?: string
touched: boolean
}
export interface UseFormOptions<T> {
initialValues: T
validate?: (values: T) => Partial<Record<keyof T, string>>
onSubmit?: (values: T) => void | Promise<void>
}
export function useForm<T extends Record<string, any>>(options: UseFormOptions<T>) {
const { initialValues, validate, onSubmit } = options
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const setValue = useCallback((field: keyof T, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const setFieldError = useCallback((field: keyof T, error: string) => {
setErrors(prev => ({ ...prev, [field]: error }))
}, [])
const setFieldTouched = useCallback((field: keyof T, isTouched: boolean = true) => {
setTouched(prev => ({ ...prev, [field]: isTouched }))
}, [])
const handleChange = useCallback((field: keyof T) => (event: any) => {
const value = event.target?.value ?? event
setValue(field, value)
}, [setValue])
const handleBlur = useCallback((field: keyof T) => () => {
setFieldTouched(field, true)
if (validate) {
const validationErrors = validate(values)
if (validationErrors[field]) {
setFieldError(field, validationErrors[field]!)
} else {
setErrors(prev => {
const next = { ...prev }
delete next[field]
return next
})
}
}
}, [values, validate, setFieldTouched, setFieldError])
const handleSubmit = useCallback(async (event?: any) => {
event?.preventDefault?.()
setIsSubmitting(true)
const allTouched = Object.keys(initialValues).reduce((acc, key) => ({
...acc,
[key]: true,
}), {})
setTouched(allTouched)
if (validate) {
const validationErrors = validate(values)
setErrors(validationErrors)
if (Object.keys(validationErrors).length > 0) {
setIsSubmitting(false)
return
}
}
if (onSubmit) {
try {
await onSubmit(values)
} catch (error) {
console.error('Form submission error:', error)
}
}
setIsSubmitting(false)
}, [values, initialValues, validate, onSubmit])
const reset = useCallback(() => {
setValues(initialValues)
setErrors({})
setTouched({})
setIsSubmitting(false)
}, [initialValues])
const getFieldProps = useCallback((field: keyof T) => ({
value: values[field],
onChange: handleChange(field),
onBlur: handleBlur(field),
error: touched[field] ? errors[field] : undefined,
}), [values, touched, errors, handleChange, handleBlur])
return {
values,
errors,
touched,
isSubmitting,
setValue,
setFieldError,
setFieldTouched,
handleChange,
handleBlur,
handleSubmit,
reset,
getFieldProps,
isValid: Object.keys(errors).length === 0,
isDirty: JSON.stringify(values) !== JSON.stringify(initialValues),
}
}
@@ -0,0 +1,59 @@
import { useCallback } from 'react'
import { toast } from '@/components/ui/sonner'
export function useJsonExport() {
const exportToJson = useCallback((data: any, filename: string = 'schema.json') => {
try {
const jsonString = JSON.stringify(data, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success('Schema exported successfully')
} catch (error) {
toast.error('Failed to export schema')
console.error('Export error:', error)
}
}, [])
const copyToClipboard = useCallback((data: any) => {
try {
const jsonString = JSON.stringify(data, null, 2)
navigator.clipboard.writeText(jsonString)
toast.success('Copied to clipboard')
} catch (error) {
toast.error('Failed to copy to clipboard')
console.error('Copy error:', error)
}
}, [])
const importFromJson = useCallback((file: File, onImport: (data: any) => void) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string)
onImport(data)
toast.success('Schema imported successfully')
} catch (error) {
toast.error('Invalid JSON file')
console.error('Import error:', error)
}
}
reader.readAsText(file)
}, [])
return {
exportToJson,
copyToClipboard,
importFromJson,
}
}
@@ -0,0 +1,155 @@
import { useState, useCallback } from 'react'
export interface ListOperationsOptions<T> {
initialItems?: T[]
getId?: (item: T) => string | number
onItemsChange?: (items: T[]) => void
}
export function useListOperations<T>({
initialItems = [],
getId = (item: any) => item.id,
onItemsChange,
}: ListOperationsOptions<T> = {}) {
const [items, setItemsState] = useState<T[]>(initialItems)
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set())
const setItems = useCallback(
(newItems: T[] | ((prev: T[]) => T[])) => {
setItemsState((prev) => {
const updated = typeof newItems === 'function' ? newItems(prev) : newItems
onItemsChange?.(updated)
return updated
})
},
[onItemsChange]
)
const addItem = useCallback(
(item: T, position?: number) => {
setItems((prev) => {
if (position !== undefined && position >= 0 && position <= prev.length) {
const newItems = [...prev]
newItems.splice(position, 0, item)
return newItems
}
return [...prev, item]
})
},
[setItems]
)
const updateItem = useCallback(
(id: string | number, updates: Partial<T> | ((item: T) => T)) => {
setItems((prev) =>
prev.map((item) => {
if (getId(item) === id) {
return typeof updates === 'function' ? updates(item) : { ...item, ...updates }
}
return item
})
)
},
[getId, setItems]
)
const removeItem = useCallback(
(id: string | number) => {
setItems((prev) => prev.filter((item) => getId(item) !== id))
setSelectedIds((prev) => {
const newSet = new Set(prev)
newSet.delete(id)
return newSet
})
},
[getId, setItems]
)
const removeItems = useCallback(
(ids: (string | number)[]) => {
const idSet = new Set(ids)
setItems((prev) => prev.filter((item) => !idSet.has(getId(item))))
setSelectedIds((prev) => {
const newSet = new Set(prev)
ids.forEach((id) => newSet.delete(id))
return newSet
})
},
[getId, setItems]
)
const moveItem = useCallback(
(fromIndex: number, toIndex: number) => {
setItems((prev) => {
if (
fromIndex < 0 ||
fromIndex >= prev.length ||
toIndex < 0 ||
toIndex >= prev.length
) {
return prev
}
const newItems = [...prev]
const [movedItem] = newItems.splice(fromIndex, 1)
newItems.splice(toIndex, 0, movedItem)
return newItems
})
},
[setItems]
)
const toggleSelection = useCallback((id: string | number) => {
setSelectedIds((prev) => {
const newSet = new Set(prev)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
return newSet
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(items.map(getId)))
}, [items, getId])
const clearSelection = useCallback(() => {
setSelectedIds(new Set())
}, [])
const removeSelected = useCallback(() => {
removeItems(Array.from(selectedIds))
}, [selectedIds, removeItems])
const findById = useCallback(
(id: string | number) => {
return items.find((item) => getId(item) === id)
},
[items, getId]
)
const clear = useCallback(() => {
setItems([])
setSelectedIds(new Set())
}, [setItems])
return {
items,
selectedIds: Array.from(selectedIds),
selectedCount: selectedIds.size,
isEmpty: items.length === 0,
setItems,
addItem,
updateItem,
removeItem,
removeItems,
moveItem,
toggleSelection,
selectAll,
clearSelection,
removeSelected,
findById,
clear,
}
}
@@ -0,0 +1,197 @@
import { useState, useCallback } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
import { toast } from '@/components/ui/sonner'
import { UIComponent } from '@/types/json-ui'
export interface SchemaEditorState {
components: UIComponent[]
selectedId: string | null
hoveredId: string | null
}
export function useSchemaEditor() {
const [components, setComponents, deleteComponents] = useUIState<UIComponent[]>('schema-editor-components', [])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
const findComponentById = useCallback((id: string, comps: UIComponent[] = components): UIComponent | null => {
for (const comp of comps) {
if (comp.id === id) return comp
if (Array.isArray(comp.children)) {
const found = findComponentById(id, comp.children)
if (found) return found
}
}
return null
}, [components])
const findParentComponent = useCallback((id: string, comps: UIComponent[] = components, parent: UIComponent | null = null): UIComponent | null => {
for (const comp of comps) {
if (comp.id === id) return parent
if (Array.isArray(comp.children)) {
const found = findParentComponent(id, comp.children, comp)
if (found !== null) return found
}
}
return null
}, [components])
const addComponent = useCallback((component: UIComponent, targetId?: string, position: 'before' | 'after' | 'inside' = 'inside') => {
setComponents((current) => {
const newComps = [...current]
if (!targetId) {
newComps.push(component)
return newComps
}
const insertComponent = (comps: UIComponent[]): boolean => {
for (let i = 0; i < comps.length; i++) {
const comp = comps[i]
if (comp.id === targetId) {
if (position === 'inside') {
if (!Array.isArray(comp.children)) {
comp.children = []
}
comp.children.push(component)
} else if (position === 'before') {
comps.splice(i, 0, component)
} else if (position === 'after') {
comps.splice(i + 1, 0, component)
}
return true
}
if (Array.isArray(comp.children)) {
if (insertComponent(comp.children)) {
return true
}
}
}
return false
}
insertComponent(newComps)
return newComps
})
setSelectedId(component.id)
toast.success('Component added')
}, [setComponents])
const updateComponent = useCallback((id: string, updates: Partial<UIComponent>) => {
setComponents((current) => {
const updateInTree = (comps: UIComponent[]): UIComponent[] => {
return comps.map(comp => {
if (comp.id === id) {
return { ...comp, ...updates }
}
if (Array.isArray(comp.children)) {
return {
...comp,
children: updateInTree(comp.children)
}
}
return comp
})
}
return updateInTree(current)
})
}, [setComponents])
const deleteComponent = useCallback((id: string) => {
setComponents((current) => {
const deleteFromTree = (comps: UIComponent[]): UIComponent[] => {
return comps.filter(comp => {
if (comp.id === id) return false
if (Array.isArray(comp.children)) {
comp.children = deleteFromTree(comp.children)
}
return true
})
}
return deleteFromTree(current)
})
if (selectedId === id) {
setSelectedId(null)
}
toast.success('Component deleted')
}, [selectedId, setComponents])
const moveComponent = useCallback((sourceId: string, targetId: string, position: 'before' | 'after' | 'inside') => {
setComponents((current) => {
const component = findComponentById(sourceId, current)
if (!component) return current
const newComps = [...current]
const removeFromTree = (comps: UIComponent[]): UIComponent[] => {
return comps.filter(comp => {
if (comp.id === sourceId) return false
if (Array.isArray(comp.children)) {
comp.children = removeFromTree(comp.children)
}
return true
})
}
const cleanedComps = removeFromTree(newComps)
const insertComponent = (comps: UIComponent[]): boolean => {
for (let i = 0; i < comps.length; i++) {
const comp = comps[i]
if (comp.id === targetId) {
if (position === 'inside') {
if (!Array.isArray(comp.children)) {
comp.children = []
}
comp.children.push(component)
} else if (position === 'before') {
comps.splice(i, 0, component)
} else if (position === 'after') {
comps.splice(i + 1, 0, component)
}
return true
}
if (Array.isArray(comp.children)) {
if (insertComponent(comp.children)) {
return true
}
}
}
return false
}
insertComponent(cleanedComps)
return cleanedComps
})
}, [findComponentById, setComponents])
const clearAll = useCallback(() => {
deleteComponents()
setSelectedId(null)
setHoveredId(null)
toast.success('Canvas cleared')
}, [deleteComponents])
return {
components,
selectedId,
hoveredId,
setSelectedId,
setHoveredId,
findComponentById,
findParentComponent,
addComponent,
updateComponent,
deleteComponent,
moveComponent,
clearAll,
}
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react'
export function useSelection<T extends { id: string }>() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const select = useCallback((id: string) => {
setSelectedIds((prev) => new Set(prev).add(id))
}, [])
const deselect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const toggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const selectAll = useCallback((items: T[]) => {
setSelectedIds(new Set(items.map((item) => item.id)))
}, [])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
const isSelected = useCallback(
(id: string) => selectedIds.has(id),
[selectedIds]
)
return {
selectedIds: Array.from(selectedIds),
select,
deselect,
toggle,
selectAll,
deselectAll,
isSelected,
count: selectedIds.size,
}
}
@@ -0,0 +1,21 @@
import { useState, useCallback } from 'react'
export function useTabs<T extends string>(defaultTab: T) {
const [activeTab, setActiveTab] = useState<T>(defaultTab)
const switchTab = useCallback((tab: T) => {
setActiveTab(tab)
}, [])
const isActive = useCallback(
(tab: T) => activeTab === tab,
[activeTab]
)
return {
activeTab,
setActiveTab,
switchTab,
isActive,
}
}
@@ -0,0 +1,72 @@
import { useState } from 'react'
import { AIService } from '@/lib/ai-service'
import { toast } from '@/components/ui/sonner'
export function useAIOperations() {
const [isProcessing, setIsProcessing] = useState(false)
const improveCode = async (content: string, instruction: string) => {
try {
setIsProcessing(true)
toast.info('Improving code with AI...')
const improvedCode = await AIService.improveCode(content, instruction)
if (improvedCode) {
toast.success('Code improved successfully!')
return improvedCode
} else {
toast.error('AI improvement failed. Please try again.')
return null
}
} catch (error) {
toast.error('Failed to improve code')
console.error(error)
return null
} finally {
setIsProcessing(false)
}
}
const explainCode = async (content: string) => {
try {
setIsProcessing(true)
const codeExplanation = await AIService.explainCode(content)
return codeExplanation || 'Failed to generate explanation. Please try again.'
} catch (error) {
console.error(error)
return 'Error generating explanation.'
} finally {
setIsProcessing(false)
}
}
const generateCompleteApp = async (description: string) => {
try {
setIsProcessing(true)
toast.info('Generating application with AI...')
const result = await AIService.generateCompleteApp(description)
if (result) {
toast.success('Application generated successfully!')
return result
} else {
toast.error('AI generation failed. Please try again.')
return null
}
} catch (error) {
toast.error('AI generation failed')
console.error(error)
return null
} finally {
setIsProcessing(false)
}
}
return {
isProcessing,
improveCode,
explainCode,
generateCompleteApp,
}
}
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useComponentTreeLoader } from '@/hooks/use-component-tree-loader'
import { preloadCriticalComponents } from '@/lib/component-registry'
type AppBootstrapOptions = {
loadComponentTrees?: boolean
}
export function useAppBootstrap({ loadComponentTrees = false }: AppBootstrapOptions = {}) {
const { loadComponentTrees: loadTrees } = useComponentTreeLoader({ autoLoad: false })
const [appReady, setAppReady] = useState(false)
useEffect(() => {
let isMounted = true
const timer = setTimeout(() => {
if (isMounted) {
setAppReady(true)
}
}, 100)
const runBootstrap = async () => {
try {
if (loadComponentTrees) {
await loadTrees()
}
} catch (err) {
console.error('[APP_BOOTSTRAP] Bootstrap loading failed:', err)
} finally {
if (!isMounted) {
return
}
clearTimeout(timer)
setAppReady(true)
preloadCriticalComponents()
}
}
runBootstrap()
return () => {
isMounted = false
clearTimeout(timer)
}
}, [loadTrees, loadComponentTrees])
return { appReady }
}
@@ -0,0 +1,72 @@
import { useState } from 'react'
import useAppNavigation from './use-app-navigation'
import useAppProject from './use-app-project'
import useAppShortcuts from './use-app-shortcuts'
export function useAppLayout() {
const { currentPage, navigateToPage } = useAppNavigation()
const {
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
featureToggles,
fileOps,
currentProject,
handleProjectLoad,
stateContext,
actionContext,
} = useAppProject()
const { searchOpen, setSearchOpen, shortcutsOpen, setShortcutsOpen, previewOpen, setPreviewOpen } =
useAppShortcuts({ featureToggles, navigateToPage })
const [lastSaved] = useState<number | null>(() => Date.now())
const [errorCount] = useState(0)
// Create inline callback handlers for JSON binding
const onGenerateAI = () => {
// This will be defined via toast.info from appStrings
}
const onExport = () => {
// This will be defined via toast.info from appStrings
}
const onFileSelect = (fileId: string) => {
fileOps.setActiveFileId(fileId)
navigateToPage('code')
}
return {
currentPage,
navigateToPage,
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
featureToggles,
fileOps,
currentProject,
handleProjectLoad,
stateContext,
actionContext,
searchOpen,
setSearchOpen,
shortcutsOpen,
setShortcutsOpen,
previewOpen,
setPreviewOpen,
lastSaved,
errorCount,
onGenerateAI,
onExport,
onFileSelect,
}
}
@@ -0,0 +1,5 @@
import { useRouterNavigation } from '@/hooks/use-router-navigation'
export default function useAppNavigation() {
return useRouterNavigation()
}
@@ -0,0 +1,147 @@
import { useMemo } from 'react'
import { toast } from '@/components/ui/sonner'
import appStrings from '@/data/app-shortcuts.json'
import { useFileOperations } from '@/hooks/use-file-operations'
import { useProjectState } from '@/hooks/use-project-state'
import type { Project } from '@/types/project'
export default function useAppProject() {
const projectState = useProjectState()
const {
files,
models,
components,
componentTrees,
workflows,
lambdas,
theme,
playwrightTests,
storybookStories,
unitTests,
flaskConfig,
nextjsConfig,
npmSettings,
featureToggles,
setFiles,
setModels,
setComponents,
setComponentTrees,
setWorkflows,
setLambdas,
setTheme,
setPlaywrightTests,
setStorybookStories,
setUnitTests,
setFlaskConfig,
setNextjsConfig,
setNpmSettings,
setFeatureToggles,
} = projectState
const fileOps = useFileOperations(files, setFiles)
const currentProject = useMemo<Project>(
() => ({
name: nextjsConfig.appName,
files,
models,
components,
componentTrees,
workflows,
lambdas,
theme,
playwrightTests,
storybookStories,
unitTests,
flaskConfig,
nextjsConfig,
npmSettings,
featureToggles,
}),
[
componentTrees,
components,
featureToggles,
files,
flaskConfig,
lambdas,
models,
nextjsConfig,
npmSettings,
playwrightTests,
storybookStories,
theme,
unitTests,
workflows,
]
)
const handleProjectLoad = (project: Project) => {
if (project.files) setFiles(project.files)
if (project.models) setModels(project.models)
if (project.components) setComponents(project.components)
if (project.componentTrees) setComponentTrees(project.componentTrees)
if (project.workflows) setWorkflows(project.workflows)
if (project.lambdas) setLambdas(project.lambdas)
if (project.theme) setTheme(project.theme)
if (project.playwrightTests) setPlaywrightTests(project.playwrightTests)
if (project.storybookStories) setStorybookStories(project.storybookStories)
if (project.unitTests) setUnitTests(project.unitTests)
if (project.flaskConfig) setFlaskConfig(project.flaskConfig)
if (project.nextjsConfig) setNextjsConfig(project.nextjsConfig)
if (project.npmSettings) setNpmSettings(project.npmSettings)
if (project.featureToggles) setFeatureToggles(project.featureToggles)
toast.success(appStrings.messages.projectLoaded)
}
const stateContext = {
files,
models,
components,
componentTrees,
workflows,
lambdas,
theme,
playwrightTests,
storybookStories,
unitTests,
flaskConfig,
nextjsConfig,
npmSettings,
featureToggles,
activeFileId: fileOps.activeFileId,
}
const actionContext = {
handleFileChange: fileOps.handleFileChange,
setActiveFileId: fileOps.setActiveFileId,
handleFileClose: fileOps.handleFileClose,
handleFileAdd: fileOps.handleFileAdd,
setModels,
setComponents,
setComponentTrees,
setWorkflows,
setLambdas,
setTheme,
setPlaywrightTests,
setStorybookStories,
setUnitTests,
setFlaskConfig,
setNextjsConfig,
setNpmSettings,
setFeatureToggles,
}
return {
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
featureToggles,
fileOps,
currentProject,
handleProjectLoad,
stateContext,
actionContext,
}
}
@@ -0,0 +1,72 @@
import { useState } from 'react'
import useAppNavigation from './use-app-navigation'
import useAppProject from './use-app-project'
import useAppShortcuts from './use-app-shortcuts'
export function useAppRouterLayout() {
const { currentPage, navigateToPage } = useAppNavigation()
const {
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
featureToggles,
fileOps,
currentProject,
handleProjectLoad,
stateContext,
actionContext,
} = useAppProject()
const { searchOpen, setSearchOpen, shortcutsOpen, setShortcutsOpen, previewOpen, setPreviewOpen } =
useAppShortcuts({ featureToggles, navigateToPage })
const [lastSaved] = useState<number | null>(() => Date.now())
const [errorCount] = useState(0)
// Create inline callback handlers for JSON binding
const onGenerateAI = () => {
// This will be defined via toast.info from appStrings
}
const onExport = () => {
// This will be defined via toast.info from appStrings
}
const onFileSelect = (fileId: string) => {
fileOps.setActiveFileId(fileId)
navigateToPage('code')
}
return {
currentPage,
navigateToPage,
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
featureToggles,
fileOps,
currentProject,
handleProjectLoad,
stateContext,
actionContext,
searchOpen,
setSearchOpen,
shortcutsOpen,
setShortcutsOpen,
previewOpen,
setPreviewOpen,
lastSaved,
errorCount,
onGenerateAI,
onExport,
onFileSelect,
}
}
@@ -0,0 +1,71 @@
import { useState } from 'react'
import appStrings from '@/data/app-shortcuts.json'
import { getPageShortcuts } from '@/config/page-loader'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
import type { FeatureToggles } from '@/types/project'
interface UseAppShortcutsParams {
featureToggles: FeatureToggles
navigateToPage: (page: string) => void
}
export default function useAppShortcuts({
featureToggles,
navigateToPage,
}: UseAppShortcutsParams) {
const [searchOpen, setSearchOpen] = useState(false)
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const shortcuts = getPageShortcuts(featureToggles)
useKeyboardShortcuts([
...shortcuts.map(s => ({
key: s.key,
ctrl: s.ctrl,
shift: s.shift,
description: s.description,
action: () => {
console.log('[APP_ROUTER] ⌨️ Shortcut triggered, navigating to:', s.action)
navigateToPage(s.action)
},
})),
{
key: 'k',
ctrl: true,
description: appStrings.shortcuts.search,
action: () => {
console.log('[APP_ROUTER] ⌨️ Search shortcut triggered')
setSearchOpen(true)
},
},
{
key: '/',
ctrl: true,
description: appStrings.shortcuts.shortcuts,
action: () => {
console.log('[APP_ROUTER] ⌨️ Shortcuts dialog triggered')
setShortcutsOpen(true)
},
},
{
key: 'p',
ctrl: true,
description: appStrings.shortcuts.preview,
action: () => {
console.log('[APP_ROUTER] ⌨️ Preview shortcut triggered')
setPreviewOpen(true)
},
},
])
return {
searchOpen,
setSearchOpen,
shortcutsOpen,
setShortcutsOpen,
previewOpen,
setPreviewOpen,
}
}
@@ -0,0 +1,28 @@
import { useState } from 'react'
import data from '@/data/atomic-library-showcase.json'
export function useAtomicLibraryShowcase() {
const [switchChecked, setSwitchChecked] = useState(false)
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined)
const [rangeValue, setRangeValue] = useState<[number, number]>([20, 80])
const [filterValue, setFilterValue] = useState('')
const [rating, setRating] = useState(3)
const [numberValue, setNumberValue] = useState(10)
return {
switchChecked,
setSwitchChecked,
selectedDate,
setSelectedDate,
rangeValue,
setRangeValue,
filterValue,
setFilterValue,
rating,
setRating,
numberValue,
setNumberValue,
pageHeader: data.pageHeader,
sections: data.sections,
}
}
@@ -0,0 +1,52 @@
import { useState, useCallback } from 'react'
import { ProjectFile } from '@/types/project'
import { CodeError } from '@/types/errors'
import { ErrorRepairService } from '@/lib/error-repair-service'
import { scanRateLimiter } from '@/lib/rate-limiter'
export function useAutoRepair(
files: ProjectFile[],
enabled: boolean = false
) {
const [errors, setErrors] = useState<CodeError[]>([])
const [isScanning, setIsScanning] = useState(false)
const scanFiles = useCallback(async () => {
if (!enabled || !files || files.length === 0) return
setIsScanning(true)
try {
const result = await scanRateLimiter.throttle(
'error-scan',
async () => {
const allErrors: CodeError[] = []
for (const file of files) {
if (file && file.content) {
const fileErrors = await ErrorRepairService.detectErrors(file)
if (Array.isArray(fileErrors)) {
allErrors.push(...fileErrors)
}
}
}
return allErrors
},
'low'
)
setErrors(result || [])
} catch (error) {
console.error('Auto-scan failed:', error)
setErrors([])
} finally {
setIsScanning(false)
}
}, [files, enabled])
return {
errors: Array.isArray(errors) ? errors : [],
isScanning,
scanFiles,
}
}
@@ -0,0 +1,45 @@
import { useState } from 'react'
import type { Binding } from '@/types/json-ui'
export function useBindingEditor(
bindings: Record<string, Binding>,
onChange: (bindings: Record<string, Binding>) => void
) {
const [selectedProp, setSelectedProp] = useState('')
const [selectedSource, setSelectedSource] = useState('')
const [path, setPath] = useState('')
const addBinding = () => {
if (!selectedProp || !selectedSource) return
const newBindings = {
...bindings,
[selectedProp]: {
source: selectedSource,
...(path && { path }),
},
}
onChange(newBindings)
setSelectedProp('')
setSelectedSource('')
setPath('')
}
const removeBinding = (prop: string) => {
const newBindings = { ...bindings }
delete newBindings[prop]
onChange(newBindings)
}
return {
selectedProp,
setSelectedProp,
selectedSource,
setSelectedSource,
path,
setPath,
addBinding,
removeBinding,
}
}
@@ -0,0 +1,101 @@
import { useCallback, useMemo } from 'react'
interface CodeFile {
id: string
name: string
path: string
content: string
language: string
}
const languageMap: Record<string, string> = {
typescript: 'typescript',
javascript: 'javascript',
tsx: 'typescriptreact',
jsx: 'javascriptreact',
css: 'css',
scss: 'scss',
html: 'html',
json: 'json',
python: 'python',
yaml: 'yaml',
markdown: 'markdown',
}
const extMap: Record<string, string> = {
ts: 'typescript', tsx: 'typescriptreact',
js: 'javascript', jsx: 'javascriptreact',
css: 'css', scss: 'scss', html: 'html',
json: 'json', py: 'python', yml: 'yaml', yaml: 'yaml', md: 'markdown',
}
function getLanguageFromFile(file: CodeFile): string {
if (file.language && languageMap[file.language]) return languageMap[file.language]
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
return extMap[ext] ?? 'plaintext'
}
interface UseCodeEditorArgs {
files: CodeFile[]
activeFileId?: string
onFileChange?: (fileId: string, content: string) => void
onFileSelect?: (fileId: string) => void
onFileClose?: (fileId: string) => void
}
export function useCodeEditor({ files = [], activeFileId, onFileChange, onFileSelect, onFileClose }: UseCodeEditorArgs) {
const activeFile = useMemo(
() => files.find(f => f.id === activeFileId) ?? files[0],
[files, activeFileId]
)
const language = useMemo(
() => activeFile ? getLanguageFromFile(activeFile) : 'plaintext',
[activeFile]
)
const handleEditorChange = useCallback(
(value: string | undefined) => {
if (activeFile && onFileChange && value !== undefined) {
onFileChange(activeFile.id, value)
}
},
[activeFile, onFileChange]
)
const hasFiles = files.length > 0
const tabs = useMemo(
() => files.map(file => ({
id: file.id,
name: file.name,
isActive: file.id === activeFile?.id,
className: file.id === activeFile?.id
? 'flex items-center gap-1.5 px-3 py-1.5 text-xs border-none cursor-pointer whitespace-nowrap bg-background border-b-2 border-b-primary text-primary'
: 'flex items-center gap-1.5 px-3 py-1.5 text-xs border-none cursor-pointer whitespace-nowrap bg-transparent border-b-2 border-b-transparent text-muted-foreground hover:text-foreground',
onSelect: () => onFileSelect?.(file.id),
onClose: onFileClose ? () => onFileClose(file.id) : undefined,
})),
[files, activeFile, onFileSelect, onFileClose]
)
return {
activeFile,
language,
hasFiles,
tabs,
handleEditorChange,
editorValue: activeFile?.content ?? '',
editorOptions: {
minimap: { enabled: true },
fontSize: 13,
lineNumbers: 'on' as const,
wordWrap: 'on' as const,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
renderLineHighlight: 'all' as const,
bracketPairColorization: { enabled: true },
},
}
}
@@ -0,0 +1,31 @@
import { useState } from 'react'
import { useAIOperations } from './use-ai-operations'
export function useCodeExplanation() {
const [explanation, setExplanation] = useState('')
const [isExplaining, setIsExplaining] = useState(false)
const { explainCode } = useAIOperations()
const explain = async (code: string) => {
try {
setIsExplaining(true)
setExplanation('Analyzing code...')
const result = await explainCode(code)
setExplanation(result)
} finally {
setIsExplaining(false)
}
}
const reset = () => {
setExplanation('')
setIsExplaining(false)
}
return {
explanation,
isExplaining,
explain,
reset,
}
}
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react'
import { UIComponent } from '@/types/json-ui'
interface UseComponentBindingDialogOptions {
component: UIComponent | null
open: boolean
onOpenChange: (open: boolean) => void
onSave: (component: UIComponent) => void
}
export function useComponentBindingDialog({
component,
open,
onOpenChange,
onSave,
}: UseComponentBindingDialogOptions) {
const [editingComponent, setEditingComponent] = useState<UIComponent | null>(component)
useEffect(() => {
if (open) {
setEditingComponent(component)
}
}, [component, open])
const updateBindings = useCallback((bindings: Record<string, any>) => {
setEditingComponent(prev => (prev ? { ...prev, bindings } : prev))
}, [])
const handleSave = useCallback(() => {
if (!editingComponent) return
onSave(editingComponent)
onOpenChange(false)
}, [editingComponent, onOpenChange, onSave])
return {
editingComponent,
handleSave,
updateBindings,
}
}
@@ -0,0 +1,118 @@
import { useCallback, useMemo, useState } from 'react'
import { ComponentNode } from '@/types/project'
import { AIService } from '@/lib/ai-service'
import { toast } from '@/components/ui/sonner'
import componentTreeBuilderData from '@/data/component-tree-builder.json'
import {
addChildNode,
createComponentNode,
deleteNodeFromTree,
findNodeById,
updateNodeInTree,
} from '@/components/component-tree-builder/tree-utils'
type ComponentTreeBuilderOptions = {
components: ComponentNode[]
onComponentsChange: (components: ComponentNode[]) => void
}
const { prompts } = componentTreeBuilderData
export function useComponentTreeBuilder({
components,
onComponentsChange,
}: ComponentTreeBuilderOptions) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const selectedNode = useMemo(
() => (selectedNodeId ? findNodeById(components, selectedNodeId) : null),
[components, selectedNodeId]
)
const selectNode = useCallback((nodeId: string | null) => {
setSelectedNodeId(nodeId)
}, [])
const addRootComponent = useCallback(() => {
const newNode = createComponentNode({
name: `Component${components.length + 1}`,
})
onComponentsChange([...components, newNode])
setSelectedNodeId(newNode.id)
}, [components, onComponentsChange])
const addChildComponent = useCallback(
(parentId: string) => {
const newNode = createComponentNode()
onComponentsChange(addChildNode(components, parentId, newNode))
setExpandedNodes(prevExpanded => new Set([...prevExpanded, parentId]))
setSelectedNodeId(newNode.id)
},
[components, onComponentsChange]
)
const deleteNode = useCallback(
(nodeId: string) => {
onComponentsChange(deleteNodeFromTree(components, nodeId))
if (selectedNodeId === nodeId) {
setSelectedNodeId(null)
}
},
[components, onComponentsChange, selectedNodeId]
)
const updateNode = useCallback(
(nodeId: string, updates: Partial<ComponentNode>) => {
onComponentsChange(updateNodeInTree(components, nodeId, updates))
},
[components, onComponentsChange]
)
const toggleExpand = useCallback((nodeId: string) => {
setExpandedNodes(prevExpanded => {
const newExpanded = new Set(prevExpanded)
if (newExpanded.has(nodeId)) {
newExpanded.delete(nodeId)
} else {
newExpanded.add(nodeId)
}
return newExpanded
})
}, [])
const generateComponentWithAI = useCallback(async () => {
const description = prompt(prompts.generateComponentDescription)
if (!description) return
try {
toast.info('Generating component with AI...')
const component = await AIService.generateComponent(description)
if (component) {
onComponentsChange([...components, component])
setSelectedNodeId(component.id)
setExpandedNodes(prevExpanded => new Set([...prevExpanded, component.id]))
toast.success(`Component "${component.name}" created successfully!`)
} else {
toast.error('AI generation failed. Please try again.')
}
} catch (error) {
toast.error('Failed to generate component')
console.error(error)
}
}, [components, onComponentsChange])
return {
selectedNode,
selectedNodeId,
expandedNodes,
selectNode,
addRootComponent,
addChildComponent,
deleteNode,
updateNode,
toggleExpand,
generateComponentWithAI,
}
}
@@ -0,0 +1,58 @@
import { useCallback, useMemo, useState } from 'react'
import { UIComponent } from '@/types/json-ui'
interface ComponentTreeExpansionState {
expandedIds: Set<string>
handleExpandAll: () => void
handleCollapseAll: () => void
toggleExpand: (id: string) => void
}
const getExpandableIds = (components: UIComponent[]): string[] => {
const ids: string[] = []
const traverse = (nodes: UIComponent[]) => {
nodes.forEach((component) => {
if (Array.isArray(component.children) && component.children.length > 0) {
ids.push(component.id)
traverse(component.children)
}
})
}
traverse(components)
return ids
}
export function useComponentTreeExpansion(
components: UIComponent[],
): ComponentTreeExpansionState {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const expandableIds = useMemo(() => getExpandableIds(components), [components])
const handleExpandAll = useCallback(() => {
setExpandedIds(new Set(expandableIds))
}, [expandableIds])
const handleCollapseAll = useCallback(() => {
setExpandedIds(new Set())
}, [])
const toggleExpand = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return {
expandedIds,
handleExpandAll,
handleCollapseAll,
toggleExpand,
}
}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ComponentTree } from '@/types/project'
import componentTreesData from '@/config/component-trees'
import { useAppDispatch, useAppSelector } from '@/store'
import { setUIState } from '@/store/slices/uiStateSlice'
type ComponentTreeLoaderOptions = {
autoLoad?: boolean
}
export function useComponentTreeLoader({ autoLoad = true }: ComponentTreeLoaderOptions = {}) {
const dispatch = useAppDispatch()
const storedTrees = useAppSelector((state) => state.uiState.data['project-component-trees']) as ComponentTree[] | undefined
const [isLoaded, setIsLoaded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const loadedRef = useRef(false)
const loadComponentTrees = useCallback(async () => {
if (loadedRef.current) return
loadedRef.current = true
setIsLoading(true)
const existingTrees = storedTrees
if (!existingTrees || !Array.isArray(existingTrees) || existingTrees.length === 0) {
dispatch(setUIState({ key: 'project-component-trees', value: componentTreesData.all }))
} else {
const newTrees = componentTreesData.all.filter(
newTree => !existingTrees.some(existingTree => existingTree.id === newTree.id)
)
if (newTrees.length > 0) {
const mergedTrees = [...existingTrees, ...newTrees]
dispatch(setUIState({ key: 'project-component-trees', value: mergedTrees }))
}
}
setIsLoaded(true)
setIsLoading(false)
}, [dispatch, storedTrees])
const getComponentTrees = useCallback((): ComponentTree[] => {
return (storedTrees && Array.isArray(storedTrees)) ? storedTrees : componentTreesData.all
}, [storedTrees])
const getComponentTreesByCategory = useCallback((category: 'molecule' | 'organism'): ComponentTree[] => {
const trees = getComponentTrees()
return trees.filter(tree => (tree as any).category === category)
}, [getComponentTrees])
const getComponentTreeById = useCallback((id: string): ComponentTree | undefined => {
const trees = getComponentTrees()
return trees.find(tree => tree.id === id)
}, [getComponentTrees])
const getComponentTreeByName = useCallback((name: string): ComponentTree | undefined => {
const trees = getComponentTrees()
return trees.find(tree => tree.name === name)
}, [getComponentTrees])
const reloadFromJSON = useCallback(() => {
setIsLoading(true)
dispatch(setUIState({ key: 'project-component-trees', value: componentTreesData.all }))
loadedRef.current = true
setIsLoaded(true)
setIsLoading(false)
}, [dispatch])
useEffect(() => {
if (autoLoad) {
loadComponentTrees()
}
}, [autoLoad, loadComponentTrees])
return {
isLoaded,
isLoading,
error,
loadComponentTrees,
getComponentTrees,
getComponentTreesByCategory,
getComponentTreeById,
getComponentTreeByName,
reloadFromJSON,
moleculeTrees: componentTreesData.molecules,
organismTrees: componentTreesData.organisms,
allTrees: componentTreesData.all,
}
}
@@ -0,0 +1,46 @@
/**
* Hook for flattening and rendering component tree structure
* Converts recursive component tree to flat list with depth information
*/
import type { UIComponent } from '@/types/json-ui'
export interface TreeNode {
component: UIComponent
depth: number
hasChildren: boolean
isSelected: boolean
paddingLeft: string
}
export function useComponentTree(
components: UIComponent[],
selectedId: string | null
): TreeNode[] {
const flattenTree = (
items: UIComponent[],
depth: number = 0
): TreeNode[] => {
const result: TreeNode[] = []
for (const component of items) {
const hasChildren = Array.isArray(component.children) && component.children.length > 0
const isSelected = selectedId === component.id
result.push({
component,
depth,
hasChildren,
isSelected,
paddingLeft: `${depth * 16 + 8}px`
})
if (hasChildren) {
result.push(...flattenTree(component.children as UIComponent[], depth + 1))
}
}
return result
}
return flattenTree(components)
}
@@ -0,0 +1,22 @@
import { useState, useMemo } from 'react'
import { ConflictItem } from '@/types/conflicts'
export function useConflictCard(conflict: ConflictItem) {
const [expanded, setExpanded] = useState(false)
const isLocalNewer = conflict.localTimestamp > conflict.remoteTimestamp
const timeDiff = Math.abs(conflict.localTimestamp - conflict.remoteTimestamp)
const timeDiffMinutes = Math.round(timeDiff / 1000 / 60)
const state = useMemo(
() => ({
expanded,
setExpanded,
isLocalNewer,
timeDiffMinutes,
}),
[expanded, isLocalNewer, timeDiffMinutes]
)
return state
}
@@ -0,0 +1,72 @@
import { useState, useMemo } from 'react'
import { ConflictItem } from '@/types/conflicts'
type ConflictTab = 'local' | 'remote' | 'diff'
type ConflictDiffItem = {
key: string
localValue: unknown
remoteValue: unknown
isDifferent: boolean
onlyInLocal: boolean
onlyInRemote: boolean
}
function getConflictDiff(conflict: ConflictItem): ConflictDiffItem[] {
const localKeys = Object.keys(conflict.localVersion)
const remoteKeys = Object.keys(conflict.remoteVersion)
const allKeys = Array.from(new Set([...localKeys, ...remoteKeys]))
return allKeys.map((key) => {
const localValue = conflict.localVersion[key]
const remoteValue = conflict.remoteVersion[key]
const isDifferent = JSON.stringify(localValue) !== JSON.stringify(remoteValue)
const onlyInLocal = !(key in conflict.remoteVersion)
const onlyInRemote = !(key in conflict.localVersion)
return {
key,
localValue,
remoteValue,
isDifferent,
onlyInLocal,
onlyInRemote,
}
})
}
export function useConflictDetailsDialog(conflict: ConflictItem | null) {
const [activeTab, setActiveTab] = useState<ConflictTab>('diff')
const dialogState = useMemo(() => {
if (!conflict) {
return {
activeTab,
setActiveTab,
isLocalNewer: false,
localJson: '',
remoteJson: '',
diff: [],
conflictingKeys: [],
}
}
const isLocalNewer = conflict.localTimestamp > conflict.remoteTimestamp
const localJson = JSON.stringify(conflict.localVersion, null, 2)
const remoteJson = JSON.stringify(conflict.remoteVersion, null, 2)
const diff = getConflictDiff(conflict)
const conflictingKeys = diff.filter((item) => item.isDifferent)
return {
activeTab,
setActiveTab,
isLocalNewer,
localJson,
remoteJson,
diff,
conflictingKeys,
}
}, [conflict, activeTab])
return dialogState
}
@@ -0,0 +1,63 @@
import { useState, useMemo, useCallback } from 'react'
import { useConflictResolution } from './use-conflict-resolution'
import { useAppDispatch } from '@/store'
import { addFile } from '@/store/slices/filesSlice'
import { toast } from '@/components/ui/sonner'
import conflictCopy from '@/data/conflict-resolution-copy.json'
const demoCopy = conflictCopy.demo
export function useConflictResolutionDemo() {
const { hasConflicts, stats, detect, resolveAll } = useConflictResolution()
const dispatch = useAppDispatch()
const [simulatingConflict, setSimulatingConflict] = useState(false)
const conflictSummary = useMemo(() => {
const count = stats.totalConflicts
const label = count === 1 ? demoCopy.conflictSingular : demoCopy.conflictPlural
return `${count} ${label} ${demoCopy.detectedSuffix}`
}, [stats.totalConflicts])
const simulateConflict = useCallback(async () => {
setSimulatingConflict(true)
try {
const testFile = {
id: 'demo-conflict-file',
name: 'example.ts',
path: '/src/example.ts',
content: 'const local = "This is the local version"',
language: 'typescript',
updatedAt: Date.now(),
}
dispatch(addFile(testFile))
toast.info(demoCopy.toastLocalCreated)
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success(demoCopy.toastSimulationComplete)
} catch (err: any) {
toast.error(err.message || demoCopy.toastSimulationError)
} finally {
setSimulatingConflict(false)
}
}, [dispatch])
const handleQuickResolveAll = useCallback(async () => {
try {
await resolveAll('local')
toast.success(demoCopy.toastResolveAllSuccess)
} catch (err: any) {
toast.error(err.message || demoCopy.toastResolveAllError)
}
}, [resolveAll])
return {
hasConflicts,
stats,
simulatingConflict,
conflictSummary,
simulateConflict,
detect,
handleQuickResolveAll,
}
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { toast } from '@/components/ui/sonner'
import { useConflictResolution } from '@/hooks/use-conflict-resolution'
import { ConflictItem, ConflictResolutionStrategy } from '@/types/conflicts'
import type {
ConflictResolutionCopy,
ConflictResolutionFilters as ConflictResolutionFilterType,
} from '@/components/conflict-resolution/types'
export function useConflictResolutionPage(copy: ConflictResolutionCopy) {
const {
conflicts,
stats,
autoResolveStrategy,
detectingConflicts,
resolvingConflict,
error,
hasConflicts,
detect,
resolve,
resolveAll,
clear,
setAutoResolve,
} = useConflictResolution()
const [selectedConflict, setSelectedConflict] = useState<ConflictItem | null>(null)
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false)
const [filterType, setFilterType] = useState<ConflictResolutionFilterType>('all')
useEffect(() => {
detect().catch(() => {})
}, [detect])
const handleDetect = async () => {
try {
const detected = await detect()
if (detected.length === 0) {
toast.success(copy.toasts.noConflictsDetected)
} else {
const label =
detected.length === 1 ? copy.labels.conflictSingular : copy.labels.conflictPlural
toast.info(
copy.toasts.foundConflicts
.replace('{count}', String(detected.length))
.replace('{label}', label)
)
}
} catch (err: any) {
toast.error(err.message || copy.toasts.detectFailed)
}
}
const handleResolve = async (conflictId: string, strategy: ConflictResolutionStrategy) => {
try {
await resolve(conflictId, strategy)
toast.success(copy.toasts.resolved.replace('{strategy}', strategy))
} catch (err: any) {
toast.error(err.message || copy.toasts.resolveFailed)
}
}
const handleResolveAll = async (strategy: ConflictResolutionStrategy) => {
try {
await resolveAll(strategy)
toast.success(copy.toasts.resolvedAll.replace('{strategy}', strategy))
} catch (err: any) {
toast.error(err.message || copy.toasts.resolveAllFailed)
}
}
const handleViewDetails = (conflict: ConflictItem) => {
setSelectedConflict(conflict)
setDetailsDialogOpen(true)
}
return {
conflicts,
stats,
autoResolveStrategy,
detectingConflicts,
resolvingConflict,
error,
hasConflicts,
clear,
setAutoResolve,
filterType,
setFilterType,
selectedConflict,
detailsDialogOpen,
setDetailsDialogOpen,
handleDetect,
handleResolve,
handleResolveAll,
handleViewDetails,
}
}
@@ -0,0 +1,89 @@
import { useAppDispatch, useAppSelector } from '@/store'
import {
detectConflicts,
resolveConflict,
resolveAllConflicts,
clearConflicts,
setAutoResolveStrategy,
removeConflict,
} from '@/store/slices/conflictsSlice'
import { ConflictResolutionStrategy, ConflictStats } from '@/types/conflicts'
import { useCallback, useMemo } from 'react'
export function useConflictResolution() {
const dispatch = useAppDispatch()
const conflicts = useAppSelector((state) => state.conflicts.conflicts)
const autoResolveStrategy = useAppSelector((state) => state.conflicts.autoResolveStrategy)
const detectingConflicts = useAppSelector((state) => state.conflicts.detectingConflicts)
const resolvingConflict = useAppSelector((state) => state.conflicts.resolvingConflict)
const error = useAppSelector((state) => state.conflicts.error)
const stats: ConflictStats = useMemo(() => {
const conflictsByType = conflicts.reduce((acc, conflict) => {
acc[conflict.entityType] = (acc[conflict.entityType] || 0) + 1
return acc
}, {} as Record<string, number>)
return {
totalConflicts: conflicts.length,
resolvedConflicts: 0,
pendingConflicts: conflicts.length,
conflictsByType: conflictsByType as any,
}
}, [conflicts])
const detect = useCallback(() => {
return dispatch(detectConflicts()).unwrap()
}, [dispatch])
const resolve = useCallback(
(conflictId: string, strategy: ConflictResolutionStrategy, customVersion?: any) => {
return dispatch(resolveConflict({ conflictId, strategy, customVersion })).unwrap()
},
[dispatch]
)
const resolveAll = useCallback(
(strategy: ConflictResolutionStrategy) => {
return dispatch(resolveAllConflicts(strategy)).unwrap()
},
[dispatch]
)
const clear = useCallback(() => {
dispatch(clearConflicts())
}, [dispatch])
const setAutoResolve = useCallback(
(strategy: ConflictResolutionStrategy | null) => {
dispatch(setAutoResolveStrategy(strategy))
},
[dispatch]
)
const remove = useCallback(
(conflictId: string) => {
dispatch(removeConflict(conflictId))
},
[dispatch]
)
const hasConflicts = conflicts.length > 0
return {
conflicts,
stats,
autoResolveStrategy,
detectingConflicts,
resolvingConflict,
error,
hasConflicts,
detect,
resolve,
resolveAll,
clear,
setAutoResolve,
remove,
}
}
@@ -0,0 +1,72 @@
/**
* Hook for calculating D3 bar chart dimensions and positions
*/
export interface BarChartData {
label: string
value: number
}
export interface BarPosition {
x: number
y: number
width: number
height: number
label: string
value: number
labelX: number
labelY: number
valueX: number
valueY: number
}
export interface ChartDimensions {
innerWidth: number
innerHeight: number
margin: { top: number; right: number; bottom: number; left: number }
translateX: number
translateY: number
bars: BarPosition[]
}
export function useD3BarChart(
data: BarChartData[],
width: number = 600,
height: number = 300
): ChartDimensions {
const margin = { top: 20, right: 20, bottom: 30, left: 40 }
const innerWidth = Math.max(width - margin.left - margin.right, 0)
const innerHeight = Math.max(height - margin.top - margin.bottom, 0)
const maxValue = Math.max(...data.map((item) => item.value), 0)
const barGap = 8
const barCount = data.length
const totalGap = barCount > 1 ? barGap * (barCount - 1) : 0
const barWidth = barCount > 0 ? Math.max((innerWidth - totalGap) / barCount, 0) : 0
const bars: BarPosition[] = data.map((item, index) => {
const barHeight = maxValue ? (item.value / maxValue) * innerHeight : 0
const x = index * (barWidth + barGap)
const y = innerHeight - barHeight
return {
x,
y,
width: barWidth,
height: barHeight,
label: item.label,
value: item.value,
labelX: x + barWidth / 2,
labelY: innerHeight + 16,
valueX: x + barWidth / 2,
valueY: Math.max(y - 6, 0)
}
})
return {
innerWidth,
innerHeight,
margin,
translateX: margin.left,
translateY: margin.top,
bars
}
}
@@ -0,0 +1,36 @@
import { useState } from 'react'
import { DataSource, UIComponent } from '@/types/json-ui'
import dataBindingCopy from '@/data/data-binding-designer.json'
export function useDataBindingDesigner() {
const [dataSources, setDataSources] = useState<DataSource[]>(
dataBindingCopy.seed.dataSources as DataSource[],
)
const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components as unknown as UIComponent[])
const [selectedComponent, setSelectedComponent] = useState<UIComponent | null>(null)
const [bindingDialogOpen, setBindingDialogOpen] = useState(false)
const handleEditBinding = (component: UIComponent) => {
setSelectedComponent(component)
setBindingDialogOpen(true)
}
const handleSaveBinding = (updatedComponent: UIComponent) => {
console.log('Updated component bindings:', updatedComponent)
}
return {
dataSources,
setDataSources,
mockComponents,
selectedComponent,
setSelectedComponent,
bindingDialogOpen,
setBindingDialogOpen,
handleEditBinding,
handleSaveBinding,
dataBindingCopy,
}
}
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from 'react'
import { DataSource } from '@/types/json-ui'
interface UseDataSourceEditorParams {
dataSource: DataSource | null
onSave: (dataSource: DataSource) => void
onOpenChange: (open: boolean) => void
}
export function useDataSourceEditor({
dataSource,
onSave,
onOpenChange,
}: UseDataSourceEditorParams) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
useEffect(() => {
setEditingSource(dataSource)
}, [dataSource])
const updateField = useCallback(<K extends keyof DataSource>(field: K, value: DataSource[K]) => {
setEditingSource((prev) => {
if (!prev) return prev
return { ...prev, [field]: value }
})
}, [])
const handleSave = useCallback(() => {
if (!editingSource) return
onSave(editingSource)
onOpenChange(false)
}, [editingSource, onOpenChange, onSave])
return {
editingSource,
updateField,
handleSave,
}
}
@@ -0,0 +1,95 @@
import { useState, useCallback } from 'react'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { toast } from '@/components/ui/sonner'
import dataSourceManagerCopy from '@/data/data-source-manager.json'
interface UseDataSourceManagerStateReturn {
localSources: DataSource[]
editingSource: DataSource | null
dialogOpen: boolean
groupedSources: {
kv: DataSource[]
static: DataSource[]
}
addDataSource: (type: DataSourceType) => void
updateDataSource: (id: string, updates: Partial<DataSource>) => void
deleteDataSource: (id: string) => void
getDependents: (sourceId: string) => DataSource[]
setEditingSource: (source: DataSource | null) => void
setDialogOpen: (open: boolean) => void
}
export function useDataSourceManagerState(
dataSources: DataSource[],
onChange: (dataSources: DataSource[]) => void
): UseDataSourceManagerStateReturn {
const [localSources, setLocalSources] = useState<DataSource[]>(dataSources)
const [editingSource, setEditingSource] = useState<DataSource | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const getDependents = useCallback((sourceId: string) => {
return localSources.filter(ds =>
ds.dependencies?.includes(sourceId)
)
}, [localSources])
const addDataSource = useCallback((type: DataSourceType) => {
const newSource: DataSource = {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'static' && { defaultValue: null }),
}
setLocalSources(prev => [...prev, newSource])
setEditingSource(newSource)
setDialogOpen(true)
}, [])
const updateDataSource = useCallback((id: string, updates: Partial<DataSource>) => {
const updated = localSources.map(ds =>
ds.id === id ? { ...ds, ...updates } : ds
)
setLocalSources(updated)
onChange(updated)
toast.success(dataSourceManagerCopy.toasts.updated)
}, [localSources, onChange])
const deleteDataSource = useCallback((id: string) => {
const dependents = localSources.filter(ds =>
ds.dependencies?.includes(id)
)
if (dependents.length > 0) {
const noun = dependents.length === 1 ? 'source' : 'sources'
toast.error(dataSourceManagerCopy.toasts.deleteBlockedTitle, {
description: dataSourceManagerCopy.toasts.deleteBlockedDescription
.replace('{count}', String(dependents.length))
.replace('{noun}', noun),
})
return
}
const updated = localSources.filter(ds => ds.id !== id)
setLocalSources(updated)
onChange(updated)
toast.success(dataSourceManagerCopy.toasts.deleted)
}, [localSources, onChange])
const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'),
static: localSources.filter(ds => ds.type === 'static'),
}
return {
localSources,
editingSource,
dialogOpen,
groupedSources,
addDataSource,
updateDataSource,
deleteDataSource,
getDependents,
setEditingSource,
setDialogOpen,
}
}
@@ -0,0 +1,78 @@
/**
* useDBALSearchInput — state for the inline DBAL search input in the header
*
* Wraps useDBALSearch with input state and navigation mapping.
*/
import { useState, useCallback, ChangeEvent } from 'react'
import { useDBALSearch, type DBALSearchResult } from '@/hooks/use-dbal-search'
const SLICE_TO_PAGE: Record<string, string> = {
files: 'code',
models: 'models',
components: 'components',
componentTrees: 'component-trees',
workflows: 'workflows',
lambdas: 'lambdas',
}
interface UseDBALSearchInputArgs {
onNavigate: (page: string) => void
}
export function useDBALSearchInput({ onNavigate }: UseDBALSearchInputArgs) {
const [query, setQueryState] = useState('')
const [dropdownOpen, setDropdownOpen] = useState(false)
const { results, loading, error } = useDBALSearch(query)
const handleSelect = useCallback(
(result: DBALSearchResult) => {
const page = SLICE_TO_PAGE[result.sliceName] || 'dashboard'
onNavigate(page)
setQueryState('')
setDropdownOpen(false)
},
[onNavigate]
)
/** Directly set the search query string */
const setQuery = useCallback((value: string) => {
setQueryState(value)
setDropdownOpen(value.length >= 2)
}, [])
/** Handle input change events from an <input onChange> handler */
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}, [setQuery])
const handleBlur = useCallback(() => {
// Delay to allow click on dropdown items
setTimeout(() => setDropdownOpen(false), 200)
}, [])
const handleFocus = useCallback(() => {
if (query.length >= 2 && results.length > 0) {
setDropdownOpen(true)
}
}, [query, results])
// Derived booleans for JSON condition bindings
const showResults = dropdownOpen && results && results.length > 0
const showEmpty = dropdownOpen && query.length >= 2 && (!results || results.length === 0) && !loading
return {
query,
setQuery,
handleInputChange,
results,
loading,
error,
dropdownOpen,
showResults,
showEmpty,
handleSelect,
handleBlur,
handleFocus,
}
}
@@ -0,0 +1,107 @@
/**
* useDBALSearch — debounced search across DBAL entity types
*
* Queries listFromDBAL() for files, models, components, workflows, lambdas
* in parallel and normalises results into a flat array with entity badges.
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { listFromDBAL, ENTITY_MAP } from '@/store/middleware/dbalSync'
export interface DBALSearchResult {
id: string
title: string
subtitle: string
entityType: string
sliceName: string
}
const SEARCHABLE_SLICES = ['files', 'models', 'components', 'workflows', 'lambdas'] as const
/** Extract a display name from a raw DBAL record */
function extractTitle(record: Record<string, unknown>): string {
return String(
record.name ?? record.title ?? record.storyName ?? record.id ?? 'Untitled'
)
}
function extractSubtitle(record: Record<string, unknown>, sliceName: string): string {
if (record.path) return String(record.path)
if (record.description) return String(record.description)
if (record.type) return String(record.type)
return ENTITY_MAP[sliceName]?.entity ?? sliceName
}
export function useDBALSearch(query: string, debounceMs = 300) {
const [results, setResults] = useState<DBALSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef(0)
const search = useCallback(async (q: string, gen: number) => {
if (q.length < 2) {
setResults([])
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const settled = await Promise.allSettled(
SEARCHABLE_SLICES.map((slice) =>
listFromDBAL(slice, { q, limit: '10' })
)
)
if (abortRef.current !== gen) return
const merged: DBALSearchResult[] = []
const qLower = q.toLowerCase()
for (let i = 0; i < SEARCHABLE_SLICES.length; i++) {
const sliceName = SEARCHABLE_SLICES[i]
const outcome = settled[i]
if (outcome.status !== 'fulfilled') continue
for (const record of outcome.value) {
const r = record as Record<string, unknown>
const title = extractTitle(r)
if (!title.toLowerCase().includes(qLower)) continue
merged.push({
id: `dbal-${sliceName}-${r.id ?? i}`,
title,
subtitle: extractSubtitle(r, sliceName),
entityType: ENTITY_MAP[sliceName]?.entity ?? sliceName,
sliceName,
})
}
}
setResults(merged.slice(0, 20))
} catch (err) {
if (abortRef.current === gen) {
setError(err instanceof Error ? err.message : 'DBAL search failed')
setResults([])
}
} finally {
if (abortRef.current === gen) setLoading(false)
}
}, [])
useEffect(() => {
const gen = ++abortRef.current
if (!query.trim() || query.length < 2) {
setResults([])
setLoading(false)
return
}
const timer = setTimeout(() => search(query.trim(), gen), debounceMs)
return () => clearTimeout(timer)
}, [query, debounceMs, search])
return { results, loading, error }
}
@@ -0,0 +1,46 @@
import { useCallback, useState } from 'react'
import { useUIState } from '@/hooks/use-ui-state'
import { DockerError } from '@/types/docker'
import { parseDockerLog } from '@/lib/docker-parser'
import { toast } from '@/components/ui/sonner'
import dockerBuildDebuggerText from '@/data/docker-build-debugger.json'
export function useDockerBuildDebugger() {
const [logInput, setLogInput] = useUIState<string>('docker-log-input', '')
const [parsedErrors, setParsedErrors] = useState<DockerError[]>([])
const handleParse = useCallback(() => {
if (!logInput.trim()) {
toast.error(dockerBuildDebuggerText.analyzer.emptyLogError)
return
}
const errors = parseDockerLog(logInput)
if (errors.length === 0) {
toast.info(dockerBuildDebuggerText.analyzer.noErrorsToast)
} else {
setParsedErrors(errors)
toast.success(
dockerBuildDebuggerText.analyzer.errorsFoundToast
.replace('{{count}}', String(errors.length))
.replace('{{plural}}', errors.length > 1 ? 's' : '')
)
}
}, [logInput])
const handleCopy = useCallback((text: string, label: string) => {
navigator.clipboard.writeText(text)
toast.success(dockerBuildDebuggerText.errors.copiedToast.replace('{{label}}', label))
}, [])
return {
logInput,
setLogInput,
parsedErrors,
setParsedErrors,
handleParse,
handleCopy,
dockerBuildDebuggerText,
}
}
@@ -0,0 +1,97 @@
import { useCallback, useState } from 'react'
import type { ChangeEvent } from 'react'
interface TabDoc {
title: string
sections: string[]
}
const tabContent: Record<string, TabDoc> = {
readme: {
title: 'CodeForge IDE',
sections: [
'CodeForge is a low-code React application builder where every UI — pages, dialogs, data tables, charts — is defined as JSON and rendered by a generic engine. The 95% data, 5% code philosophy means business logic lives in declarative config, not hand-written TSX.',
'Components are registered in a central registry and composed via JSON definitions. Stateful behaviour is handled by custom hooks wired through createJsonComponentWithHooks, keeping the JSON pure data and the hooks pure logic.',
'The DBAL layer provides a unified REST API over 14 database backends (SQLite, PostgreSQL, MongoDB, Redis, Elasticsearch, and more). The frontend accesses data through redux-persist (IndexedDB) first, falling back to live DBAL calls when the backend is reachable.',
'Multi-tenancy is enforced at every level: every DBAL query is filtered by tenantId, every Redux slice scopes state to the active tenant, and every API route validates the tenant header before processing.',
],
},
roadmap: {
title: 'Roadmap',
sections: [
'Universal Platform is the next major milestone. It introduces a State Machine runtime, a Command Bus for cross-component messaging, an Event Stream for audit trails, a Virtual File System for project assets, and a Frontend Bus for inter-panel communication.',
'100% JSON coverage for application-layer components is the parallel migration goal. The remaining TSX organisms (DataSourceManager, NavigationMenu, TreeListPanel) will be converted using the createJsonComponentWithHooks pattern.',
'Workflow expansion will add 30+ new plugin nodes across Python, TypeScript, Go, Rust, and Mojo runtimes. The visual workflow editor will gain sub-workflow support and real-time execution telemetry.',
'Additional DBAL backends planned: ClickHouse for analytics queries, DynamoDB for AWS deployments, and a generic HTTP adapter for arbitrary REST APIs. Each adapter follows the 14-method CRUD + bulk + query + metadata interface.',
],
},
agents: {
title: 'AI Agents',
sections: [
'AI agents in MetaBuilder are workflow nodes that receive typed input, call an LLM or tool, and emit typed output. Each agent is defined in JSON with a model ID, system prompt, tool bindings, and an output schema validated by Zod.',
'Agents run inside the DAG workflow engine and can fan-out to parallel sub-agents, aggregate results, or gate execution on confidence thresholds. The workflow runtime handles retries, timeouts, and dead-letter routing automatically.',
'Tool bindings connect agents to DBAL entities, external REST APIs, or other workflow nodes. A binding declares the input mapping, the HTTP method, and the output path — all in JSON, no glue code required.',
'Agent outputs feed directly into the UI via redux-persist. A React component subscribes to the relevant slice; when the agent completes, the store updates and the UI re-renders with the new data without any manual wiring.',
],
},
pwa: {
title: 'Progressive Web App',
sections: [
'CodeForge ships as a PWA installable on desktop and mobile. The service worker pre-caches all static assets at install time, ensuring the shell loads offline even when the backend is unreachable.',
'Client-side state is persisted in IndexedDB via redux-persist. Every Redux slice that needs offline durability declares a persistConfig with a storage key and a whitelist of fields to serialize.',
'The DBAL layer degrades gracefully: listFromDBAL returns an empty array and warns (not errors) when the daemon is offline. Components render cached IndexedDB state immediately and rehydrate from the live API once connectivity resumes.',
'Background sync queues mutations made offline and replays them against the DBAL API once the connection is restored. Conflict resolution uses last-write-wins by default, with a pluggable strategy for domain-specific merge logic.',
],
},
sass: {
title: 'Sass & Theming',
sections: [
'All styling uses SCSS modules with Material Design 3 CSS custom properties (--mat-sys-*). Components never hard-code colour values — they reference semantic tokens like --mat-sys-primary, --mat-sys-surface, and --mat-sys-on-surface-variant.',
'Design tokens are defined per-package in packages/*/styles/tokens.json. The token compiler transforms JSON to SCSS variable maps and injects them into the global theme layer, enabling per-tenant theming at runtime by swapping a single CSS class on the root element.',
'FakeMUI is the in-house Material UI clone that consumes these tokens. It provides 145 core components and 22 email-specific components across 11 categories, all backed by SCSS modules with no runtime CSS-in-JS overhead.',
'Dark mode is supported via a CSS class toggle on the document root. The MD3 token system provides dark-mode variants for every semantic token automatically — no per-component dark-mode overrides are needed.',
],
},
cicd: {
title: 'CI/CD',
sections: [
'GitHub Actions runs four pipelines: build-and-test (Node 20, Vitest + Playwright), docker-build (multi-stage images with BuildKit cache), schema-validate (JSON Schema + YAML linting), and release (npm publish + Docker Hub push on tag).',
'Docker images use multi-stage builds. The builder stage runs npm ci and next build; the runner stage copies only the .next/standalone output into a node:20-alpine image, keeping runtime images under 500 MB.',
'Playwright e2e tests run against the full Docker stack (nginx, codegen, DBAL, PostgreSQL) spun up in the CI environment via docker compose. Tests are parallelised across 4 workers with a 5-minute timeout per suite.',
'The build matrix covers Node 20 (frontend), C++17 with Conan (DBAL daemon), and Python 3.9+ (workflow plugins). All three are built in the same workflow run so a single PR failing any target blocks the merge.',
],
},
}
const tabs = [
{ value: 'readme', label: 'README', icon: 'BookOpen' },
{ value: 'roadmap', label: 'Roadmap', icon: 'MapPin' },
{ value: 'agents', label: 'Agents', icon: 'Sparkle' },
{ value: 'pwa', label: 'PWA', icon: 'Rocket' },
{ value: 'sass', label: 'Sass', icon: 'PaintBrush' },
{ value: 'cicd', label: 'CI/CD', icon: 'GitBranch' },
]
export function useDocumentationView() {
const [activeTab, setActiveTab] = useState('readme')
const [searchQuery, setSearchQuery] = useState('')
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value)
}, [])
const doc = tabContent[activeTab] ?? { title: '', sections: [] }
return {
activeTab,
searchQuery,
handleSearchChange,
activeTabTitle: doc.title,
activeTabSections: doc.sections.map((text, i) => ({ id: `${activeTab}-p${i}`, text })),
tabsData: tabs.map(tab => ({
...tab,
isSelected: tab.value === activeTab,
onSelect: () => setActiveTab(tab.value),
})),
}
}
@@ -0,0 +1,67 @@
import { useMemo, useState, useEffect } from 'react'
import { CodeError } from '@/types/errors'
import { ProjectFile } from '@/types/project'
import { createScanForErrors } from '@/components/error-panel/error-panel-scan'
import { createRepairHandlers } from '@/components/error-panel/error-panel-repair'
import errorPanelCopy from '@/data/error-panel.json'
interface UseErrorPanelMainParams {
files: ProjectFile[]
onFileChange: (fileId: string, content: string) => void
onFileSelect: (fileId: string) => void
}
export function useErrorPanelMain({ files, onFileChange, onFileSelect }: UseErrorPanelMainParams) {
const [errors, setErrors] = useState<CodeError[]>([])
const [isScanning, setIsScanning] = useState(false)
const [isRepairing, setIsRepairing] = useState(false)
const scanForErrors = useMemo(
() => createScanForErrors({ files, setErrors, setIsScanning }),
[files]
)
const repairHandlers = useMemo(
() =>
createRepairHandlers({
files,
errors,
onFileChange,
scanForErrors,
setErrors,
setIsRepairing,
}),
[errors, files, onFileChange, scanForErrors]
)
const errorsByFile = errors.reduce((acc, error) => {
if (!acc[error.fileId]) {
acc[error.fileId] = []
}
acc[error.fileId].push(error)
return acc
}, {} as Record<string, CodeError[]>)
const errorCount = errors.filter((error) => error.severity === 'error').length
const warningCount = errors.filter((error) => error.severity === 'warning').length
useEffect(() => {
if (files.length > 0 && errors.length === 0) {
scanForErrors()
}
}, [])
return {
errors,
errorsByFile,
errorCount,
warningCount,
isScanning,
isRepairing,
scanForErrors,
repairAllErrors: repairHandlers.repairAllErrors,
repairFileWithContext: repairHandlers.repairFileWithContext,
repairSingleError: repairHandlers.repairSingleError,
errorPanelCopy,
}
}
@@ -0,0 +1,15 @@
import { useState, useCallback } from 'react'
export function useFeatureIdeaCloud() {
const [ideas, setIdeas] = useState<string[]>([])
const addIdea = useCallback((idea: string) => {
setIdeas(prev => [...prev, idea])
}, [])
const removeIdea = useCallback((index: number) => {
setIdeas(prev => prev.filter((_, i) => i !== index))
}, [])
return { ideas, addIdea, removeIdea }
}
@@ -0,0 +1,66 @@
import type { FeatureToggles } from '@/types/project'
const featureLabels: Record<string, string> = {
codeEditor: 'Code Editor',
models: 'Data Models',
components: 'Components',
componentTrees: 'Component Trees',
workflows: 'Workflows',
lambdas: 'Lambda Functions',
styling: 'Styling',
flaskApi: 'Flask API',
playwright: 'Playwright Tests',
storybook: 'Storybook',
unitTests: 'Unit Tests',
errorRepair: 'Error Repair',
documentation: 'Documentation',
sassStyles: 'Sass Styles',
faviconDesigner: 'Favicon Designer',
ideaCloud: 'Idea Cloud',
schemaEditor: 'Schema Editor',
dataBinding: 'Data Binding',
}
const featureDescriptions: Record<string, string> = {
codeEditor: 'Monaco-based code editor with syntax highlighting and IntelliSense',
models: 'Data model designer',
components: 'Visual component builder and JSON definition editor',
componentTrees: 'Hierarchical component tree manager and inspector',
workflows: 'DAG workflow editor for multi-step automations',
lambdas: 'Serverless function builder and deployment manager',
styling: 'SCSS and design token editor',
flaskApi: 'Python Flask API scaffold and endpoint manager',
playwright: 'E2E test recorder and runner',
storybook: 'Component story builder and visual test suite',
unitTests: 'Vitest unit test editor and runner',
errorRepair: 'AI-assisted error detection and repair suggestions',
documentation: 'In-app documentation viewer',
sassStyles: 'Sass stylesheet manager and variable editor',
faviconDesigner: 'SVG favicon creator and exporter',
ideaCloud: 'Feature idea brainstorming board',
schemaEditor: 'JSON Schema visual editor',
dataBinding: 'Data binding designer and expression editor',
}
export function useFeatureToggleSettings(
features: FeatureToggles | undefined,
onFeaturesChange: ((features: FeatureToggles) => void) | undefined,
) {
const safeFeatures = features ?? {} as FeatureToggles
const items = Object.entries(safeFeatures).map(([key, enabled]) => ({
key,
enabled: Boolean(enabled),
label: featureLabels[key] ?? key,
description: featureDescriptions[key] ?? '',
onToggle: () => onFeaturesChange?.({
...safeFeatures,
[key]: !safeFeatures[key as keyof FeatureToggles],
}),
}))
const enabledCount = items.filter(i => i.enabled).length
const totalCount = items.length
return { items, enabledCount, totalCount }
}
@@ -0,0 +1,27 @@
import { ProjectFile } from '@/types/project'
export function useFileFilters(files: ProjectFile[]) {
const getOpenFiles = (activeFileId: string | null, maxOpen = 5) => {
return files.filter((f) => f.id === activeFileId || files.length < maxOpen)
}
const findFileById = (fileId: string | null) => {
if (!fileId) return null
return files.find((f) => f.id === fileId) || null
}
const getFilesByLanguage = (language: string) => {
return files.filter((f) => f.language === language)
}
const getFilesByPath = (pathPrefix: string) => {
return files.filter((f) => f.path.startsWith(pathPrefix))
}
return {
getOpenFiles,
findFileById,
getFilesByLanguage,
getFilesByPath,
}
}
@@ -0,0 +1,44 @@
import { useState } from 'react'
import { ProjectFile } from '@/types/project'
export function useFileOperations(
files: ProjectFile[],
setFiles: (updater: (files: ProjectFile[]) => ProjectFile[]) => void
) {
const [activeFileId, setActiveFileId] = useState<string | null>(null)
const handleFileChange = (fileId: string, content: string) => {
setFiles((currentFiles) =>
currentFiles.map((f) => (f.id === fileId ? { ...f, content } : f))
)
}
const handleFileAdd = (file: ProjectFile) => {
setFiles((currentFiles) => [...currentFiles, file])
setActiveFileId(file.id)
}
const handleFileClose = (fileId: string) => {
if (activeFileId === fileId) {
const currentIndex = files.findIndex((f) => f.id === fileId)
const nextFile = files[currentIndex + 1] || files[currentIndex - 1]
setActiveFileId(nextFile?.id || null)
}
}
const handleFileDelete = (fileId: string) => {
setFiles((currentFiles) => currentFiles.filter((f) => f.id !== fileId))
if (activeFileId === fileId) {
setActiveFileId(null)
}
}
return {
activeFileId,
setActiveFileId,
handleFileChange,
handleFileAdd,
handleFileClose,
handleFileDelete,
}
}
@@ -0,0 +1,60 @@
import { useState } from 'react'
export function useFileUpload(
onFilesSelected: (files: File[]) => void,
maxSize?: number,
disabled?: boolean
) {
const [isDragging, setIsDragging] = useState(false)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const handleFiles = (files: FileList | null) => {
if (!files) return
const fileArray = Array.from(files)
const validFiles = fileArray.filter(file => {
if (maxSize && file.size > maxSize) {
return false
}
return true
})
setSelectedFiles(validFiles)
onFilesSelected(validFiles)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled) {
handleFiles(e.dataTransfer.files)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = () => {
setIsDragging(false)
}
const removeFile = (index: number) => {
const newFiles = selectedFiles.filter((_, i) => i !== index)
setSelectedFiles(newFiles)
onFilesSelected(newFiles)
}
return {
isDragging,
selectedFiles,
handleFiles,
handleDrop,
handleDragOver,
handleDragLeave,
removeFile,
}
}
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from '@/components/ui/sonner'
import copy from '@/data/github-build-status.json'
export interface WorkflowRun {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
html_url: string
head_branch: string
head_sha: string
event: string
workflow_id: number
path: string
}
export interface Workflow {
id: number
name: string
path: string
state: string
badge_url: string
}
interface UseGithubBuildStatusArgs {
owner: string
repo: string
defaultBranch?: string
}
export const useGithubBuildStatus = ({
owner,
repo,
defaultBranch = 'main',
}: UseGithubBuildStatusArgs) => {
const [workflows, setWorkflows] = useState<WorkflowRun[]>([])
const [allWorkflows, setAllWorkflows] = useState<Workflow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedBadge, setCopiedBadge] = useState<string | null>(null)
const formatWithCount = useCallback((template: string, count: number) => {
return template.replace('{count}', count.toString())
}, [])
const fetchData = useCallback(async () => {
try {
setLoading(true)
setError(null)
const [runsResponse, workflowsResponse] = await Promise.all([
fetch(`https://api.github.com/repos/${owner}/${repo}/actions/runs?per_page=5`, {
headers: { Accept: 'application/vnd.github.v3+json' },
}),
fetch(`https://api.github.com/repos/${owner}/${repo}/actions/workflows`, {
headers: { Accept: 'application/vnd.github.v3+json' },
}),
])
if (!runsResponse.ok || !workflowsResponse.ok) {
throw new Error(`GitHub API error: ${runsResponse.status}`)
}
const runsData = await runsResponse.json()
const workflowsData = await workflowsResponse.json()
setWorkflows(runsData.workflow_runs || [])
setAllWorkflows(workflowsData.workflows || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch workflows')
} finally {
setLoading(false)
}
}, [owner, repo])
useEffect(() => {
fetchData()
}, [fetchData])
const getBadgeUrl = useCallback(
(workflowPath: string, branch = defaultBranch) => {
const workflowFile = workflowPath.split('/').pop()
if (branch) {
return `https://github.com/${owner}/${repo}/actions/workflows/${workflowFile}/badge.svg?branch=${branch}`
}
return `https://github.com/${owner}/${repo}/actions/workflows/${workflowFile}/badge.svg`
},
[defaultBranch, owner, repo],
)
const getBadgeMarkdown = useCallback(
(workflowPath: string, workflowName: string, branch?: string) => {
const badgeUrl = getBadgeUrl(workflowPath, branch)
const actionUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflowPath.split('/').pop()}`
return `[![${workflowName}](${badgeUrl})](${actionUrl})`
},
[getBadgeUrl, owner, repo],
)
const copyBadgeMarkdown = useCallback(
(workflowPath: string, workflowName: string, branch?: string) => {
const markdown = getBadgeMarkdown(workflowPath, workflowName, branch)
navigator.clipboard.writeText(markdown)
const key = `${workflowPath}-${branch || defaultBranch}`
setCopiedBadge(key)
toast.success(copy.toast.badgeCopied)
setTimeout(() => setCopiedBadge(null), 2000)
},
[defaultBranch, getBadgeMarkdown],
)
const formatTime = useCallback(
(dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return formatWithCount(copy.time.daysAgo, days)
if (hours > 0) return formatWithCount(copy.time.hoursAgo, hours)
if (minutes > 0) return formatWithCount(copy.time.minutesAgo, minutes)
return copy.time.justNow
},
[formatWithCount],
)
const actions = useMemo(
() => ({
refresh: fetchData,
copyBadgeMarkdown,
getBadgeUrl,
getBadgeMarkdown,
formatTime,
}),
[copyBadgeMarkdown, fetchData, formatTime, getBadgeMarkdown, getBadgeUrl],
)
return {
loading,
error,
workflows,
allWorkflows,
copiedBadge,
actions,
}
}
@@ -0,0 +1,55 @@
import { useEffect } from 'react'
interface KeyboardShortcut {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
action: () => void
description: string
}
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
useEffect(() => {
if (typeof window === 'undefined') return
const handleKeyDown = (event: KeyboardEvent) => {
try {
for (const shortcut of shortcuts) {
const ctrlMatch = shortcut.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey
const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey
const altMatch = shortcut.alt ? event.altKey : !event.altKey
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
event.preventDefault()
shortcut.action()
break
}
}
} catch (error) {
console.error('[Keyboard Shortcuts] Error handling keydown:', error)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [shortcuts])
}
export function getShortcutDisplay(shortcut: Omit<KeyboardShortcut, 'action'>): string {
const parts: string[] = []
if (shortcut.ctrl) {
parts.push(typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl')
}
if (shortcut.shift) {
parts.push('Shift')
}
if (shortcut.alt) {
parts.push('Alt')
}
parts.push(shortcut.key.toUpperCase())
return parts.join(' + ')
}
@@ -0,0 +1,18 @@
import { usePopoverState } from '@metabuilder/hooks'
import type { MenuItem } from '@/lib/json-ui/interfaces/menu'
export function useMenuState() {
const popoverState = usePopoverState()
const handleItemClick = (item: MenuItem) => {
if (!item.disabled && item.onClick) {
item.onClick()
popoverState.close()
}
}
return {
...popoverState,
handleItemClick,
}
}
@@ -0,0 +1,59 @@
import { useUIState } from '@/hooks/use-ui-state'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
export interface NavigationHistoryItem {
path: string
title: string
timestamp: number
}
const MAX_HISTORY_LENGTH = 10
export function useNavigationHistory() {
const [history, setHistory] = useUIState<NavigationHistoryItem[]>('navigation-history', [])
const pathname = usePathname()
useEffect(() => {
const pathSegments = pathname.replace(/^\/codegen\/?/, '').split('/').filter(Boolean)
const pageName = pathSegments[0] || 'dashboard'
const title = pageName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
const newItem: NavigationHistoryItem = {
path: pathname,
title,
timestamp: Date.now(),
}
setHistory((currentHistory) => {
const existingIndex = currentHistory.findIndex(item => item.path === newItem.path)
if (existingIndex === 0) {
return currentHistory
}
let updatedHistory = [...currentHistory]
if (existingIndex > 0) {
updatedHistory.splice(existingIndex, 1)
}
updatedHistory = [newItem, ...updatedHistory]
return updatedHistory.slice(0, MAX_HISTORY_LENGTH)
})
}, [pathname, setHistory])
const clearHistory = () => {
setHistory([])
}
return {
history,
clearHistory,
}
}
@@ -0,0 +1,112 @@
import { useState, useMemo, useCallback } from 'react'
import { navigationGroups, NavigationItemData } from '@/lib/navigation-config'
import { FeatureToggles } from '@/types/project'
import { useRoutePreload } from './use-route-preload'
export interface RenderedNavItem {
id: string
label: string
icon: React.ReactNode
value: string
isActive: boolean
className: string
badge: number | undefined
badgeVariant: string
showBadge: boolean
onClick: () => void
onMouseEnter: () => void
onMouseLeave: () => void
}
export interface RenderedNavGroup {
id: string
label: string
isExpanded: boolean
visibleCount: number
onToggle: () => void
triggerClassName: string
caretClassName: string
items: RenderedNavItem[]
}
export function useNavigationMenu(
featureToggles: FeatureToggles,
errorCount: number = 0,
activeTab?: string,
onTabChange?: (tab: string) => void
) {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(['overview', 'development', 'automation', 'design', 'backend', 'testing', 'tools'])
)
const { preloadRoute, cancelPreload } = useRoutePreload({ delay: 100 })
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
return next
})
}, [])
const expandAll = useCallback(() => {
const allIds = navigationGroups
.filter((g) => g.items.some((item) => !item.featureKey || featureToggles[item.featureKey]))
.map((g) => g.id)
setExpandedGroups(new Set(allIds))
}, [featureToggles])
const collapseAll = useCallback(() => {
setExpandedGroups(new Set())
}, [])
const renderedGroups: RenderedNavGroup[] = useMemo(() => {
return navigationGroups.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const visibleItems = group.items.filter(
(item: NavigationItemData) => !item.featureKey || featureToggles[item.featureKey]
)
return {
id: group.id,
label: group.label,
isExpanded,
visibleCount: visibleItems.length,
onToggle: () => toggleGroup(group.id),
triggerClassName: 'w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-muted transition-colors',
caretClassName: isExpanded
? 'text-muted-foreground transition-transform rotate-0'
: 'text-muted-foreground transition-transform -rotate-90',
items: visibleItems.map((item: NavigationItemData) => {
const isActive = activeTab === item.value
const badge = item.id === 'errors' ? errorCount : item.badge
const showBadge = item.id === 'errors' ? errorCount > 0 : (badge !== undefined && badge > 0)
return {
id: item.id,
label: item.label,
icon: item.icon,
value: item.value,
isActive,
className: isActive
? 'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors bg-primary text-primary-foreground'
: 'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors hover:bg-muted text-foreground',
badge,
badgeVariant: isActive ? 'secondary' : 'destructive',
showBadge,
onClick: () => onTabChange?.(item.value),
onMouseEnter: () => preloadRoute(item.value),
onMouseLeave: () => cancelPreload(item.value),
}
}),
}
}).filter((g) => g.visibleCount > 0)
}, [expandedGroups, featureToggles, activeTab, errorCount, onTabChange, toggleGroup, preloadRoute, cancelPreload])
return {
renderedGroups,
expandAll,
collapseAll,
}
}
@@ -0,0 +1,82 @@
import { usePersistenceDashboard } from './use-persistence-dashboard'
import { usePersistence } from './use-persistence'
import copy from '@/data/persistence-dashboard.json'
type PersistenceStatus = ReturnType<typeof usePersistence>['status']
type PersistenceMetrics = ReturnType<typeof usePersistence>['metrics']
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
const getStatusColor = (status: PersistenceStatus): string => {
if (!status.dbalConnected) return 'bg-destructive'
if (status.syncStatus === 'syncing') return 'bg-amber-500'
if (status.syncStatus === 'success') return 'bg-accent'
if (status.syncStatus === 'error') return 'bg-destructive'
return 'bg-muted'
}
const getStatusText = (status: PersistenceStatus): string => {
if (!status.dbalConnected) return copy.status.disconnected
if (status.syncStatus === 'syncing') return copy.status.syncing
if (status.syncStatus === 'success') return copy.status.synced
if (status.syncStatus === 'error') return copy.status.error
return copy.status.idle
}
const formatTime = (timestamp: number | null): string => {
if (!timestamp) return copy.format.never
const date = new Date(timestamp)
return date.toLocaleTimeString()
}
const getSuccessRate = (metrics: PersistenceMetrics): number => {
if (metrics.totalOperations === 0) return 0
return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100)
}
export function usePersistenceDashboardView() {
const { status, metrics, autoSyncStatus, autoSyncEnabled, flags, handlers } =
usePersistenceDashboard()
const statusColor = getStatusColor(status)
const statusText = getStatusText(status)
const lastSyncFormatted = formatTime(status.lastSyncTime)
const successRate = getSuccessRate(metrics)
const avgDurationFormatted = formatDuration(metrics.averageOperationTime)
const nextSyncFormatted =
autoSyncStatus.nextSyncIn !== null
? formatDuration(autoSyncStatus.nextSyncIn)
: copy.cards.autoSync.nextSyncNotAvailable
const remoteStorageText = status.dbalConnected
? copy.cards.connection.remoteStorageConnected
: copy.cards.connection.remoteStorageDisconnected
const autoSyncStatusText = autoSyncStatus.enabled
? copy.cards.autoSync.statusEnabled
: copy.cards.autoSync.statusDisabled
const statusBadgeClassName = `${statusColor} text-white`
return {
status,
metrics,
autoSyncStatus,
autoSyncEnabled,
flags,
handlers,
statusColor,
statusText,
statusBadgeClassName,
lastSyncFormatted,
successRate,
avgDurationFormatted,
nextSyncFormatted,
remoteStorageText,
autoSyncStatusText,
copy,
}
}
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { toast } from '@/components/ui/sonner'
import { usePersistence } from '@/hooks/use-persistence'
import { useAppDispatch } from '@/store'
import {
syncToDBALBulk,
syncFromDBALBulk,
checkDBALConnection,
} from '@/store/slices/dbalSlice'
import copy from '@/data/persistence-dashboard.json'
const useDBALConnectionPolling = (dispatch: ReturnType<typeof useAppDispatch>) => {
useEffect(() => {
;(dispatch as any)(checkDBALConnection())
const interval = setInterval(() => {
;(dispatch as any)(checkDBALConnection())
}, 10000)
return () => clearInterval(interval)
}, [dispatch])
}
export function usePersistenceDashboard() {
const dispatch = useAppDispatch()
const { status, metrics, autoSyncStatus, syncNow, configureAutoSync } = usePersistence()
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false)
const [syncing, setSyncing] = useState(false)
useDBALConnectionPolling(dispatch)
const handleSyncToDBAL = async () => {
setSyncing(true)
try {
await (dispatch as any)(syncToDBALBulk()).unwrap()
toast.success(copy.toasts.syncToSuccess)
} catch (error: any) {
toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error)))
} finally {
setSyncing(false)
}
}
const handleSyncFromDBAL = async () => {
setSyncing(true)
try {
await (dispatch as any)(syncFromDBALBulk()).unwrap()
toast.success(copy.toasts.syncFromSuccess)
} catch (error: any) {
toast.error(copy.toasts.syncFailed.replace('{{error}}', String(error)))
} finally {
setSyncing(false)
}
}
const handleAutoSyncToggle = (enabled: boolean) => {
setAutoSyncEnabled(enabled)
configureAutoSync({ enabled, syncOnChange: true })
toast.info(enabled ? copy.toasts.autoSyncEnabled : copy.toasts.autoSyncDisabled)
}
const handleManualSync = async () => {
try {
await syncNow()
toast.success(copy.toasts.manualSyncSuccess)
} catch (error: any) {
toast.error(copy.toasts.manualSyncFailed.replace('{{error}}', String(error)))
}
}
const handleCheckConnection = () => {
;(dispatch as any)(checkDBALConnection())
}
const flags = {
isConnected: status.dbalConnected,
isSyncing: syncing,
hasError: Boolean(status.error),
canSyncToDBAL: status.dbalConnected && !syncing,
canSyncFromDBAL: status.dbalConnected && !syncing,
canTriggerManualSync: autoSyncStatus.enabled && !syncing,
}
const handlers = {
handleSyncToDBAL,
handleSyncFromDBAL,
handleManualSync,
handleAutoSyncToggle,
handleCheckConnection,
}
return {
status,
metrics,
autoSyncStatus,
autoSyncEnabled,
flags,
handlers,
}
}
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { useAppDispatch, useAppSelector } from '@/store'
import { saveFile, deleteFile, type FileItem } from '@/store/slices/filesSlice'
import { toast } from '@/components/ui/sonner'
import copy from '@/data/persistence-example.json'
export function usePersistenceExample() {
const dispatch = useAppDispatch()
const files = useAppSelector((state) => state.files.files)
const [fileName, setFileName] = useState('')
const [fileContent, setFileContent] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const handleSave = async () => {
if (!fileName.trim()) {
toast.error(copy.toasts.fileNameRequired)
return
}
const fileItem: FileItem = {
id: editingId || `file-${Date.now()}`,
name: fileName,
content: fileContent,
language: 'javascript',
path: `/src/${fileName}`,
updatedAt: Date.now(),
}
try {
await (dispatch as any)(saveFile(fileItem)).unwrap()
toast.success(copy.toasts.saveSuccess.replace('{{name}}', fileName), {
description: copy.toasts.saveDescription,
})
setFileName('')
setFileContent('')
setEditingId(null)
} catch (error: unknown) {
toast.error(copy.toasts.saveErrorTitle, {
description: String(error),
})
}
}
const handleEdit = (file: FileItem) => {
setEditingId(file.id)
setFileName(file.name)
setFileContent(file.content)
}
const handleDelete = async (fileId: string, name: string) => {
try {
await (dispatch as any)(deleteFile(fileId)).unwrap()
toast.success(copy.toasts.deleteSuccess.replace('{{name}}', name), {
description: copy.toasts.deleteDescription,
})
} catch (error: unknown) {
toast.error(copy.toasts.deleteErrorTitle, {
description: String(error),
})
}
}
const handleCancel = () => {
setFileName('')
setFileContent('')
setEditingId(null)
}
const handleFileNameChange = (value: string) => {
setFileName(value)
}
const handleFileContentChange = (value: string) => {
setFileContent(value)
}
const editorTitle = editingId ? copy.editor.titleEdit : copy.editor.titleCreate
const saveButtonText = editingId ? copy.editor.updateButton : copy.editor.saveButton
const fileCount = `${files.length} ${copy.files.countLabel}`
const formattedFiles = files.map((file) => ({
...file,
contentPreview: file.content
? file.content.length > 100
? file.content.substring(0, 100) + '...'
: file.content
: null,
updatedAtFormatted: new Date(file.updatedAt).toLocaleTimeString(),
}))
return {
files,
formattedFiles,
fileName,
fileContent,
editingId,
editorTitle,
saveButtonText,
fileCount,
handlers: {
handleSave,
handleEdit,
handleDelete,
handleCancel,
handleFileNameChange,
handleFileContentChange,
},
copy,
}
}
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react'
import { useAppSelector } from '@/store'
import {
getSyncMetrics,
resetSyncMetrics,
subscribeSyncMetrics
} from '@/store/middleware/syncMonitorMiddleware'
import {
configureAutoSync,
getAutoSyncStatus,
triggerAutoSync
} from '@/store/middleware/autoSyncMiddleware'
interface PersistenceStatus {
enabled: boolean
lastSyncTime: number | null
syncStatus: 'idle' | 'syncing' | 'success' | 'error'
error: string | null
dbalConnected: boolean
}
interface SyncMetrics {
totalOperations: number
successfulOperations: number
failedOperations: number
lastOperationTime: number
averageOperationTime: number
}
interface AutoSyncStatus {
enabled: boolean
lastSyncTime: number
changeCounter: number
nextSyncIn: number | null
}
export function usePersistence() {
const syncState = useAppSelector((state) => state.dbal)
const [metrics, setMetrics] = useState<SyncMetrics>(getSyncMetrics())
const [autoSyncStatus, setAutoSyncStatus] = useState<AutoSyncStatus>(getAutoSyncStatus())
useEffect(() => {
const unsubscribe = subscribeSyncMetrics((newMetrics) => {
setMetrics(newMetrics)
})
const statusTimer = setInterval(() => {
setAutoSyncStatus(getAutoSyncStatus())
}, 1000)
return () => {
unsubscribe()
clearInterval(statusTimer)
}
}, [])
const status: PersistenceStatus = {
enabled: true,
lastSyncTime: syncState.lastSyncedAt,
syncStatus: syncState.status,
error: syncState.error,
dbalConnected: syncState.dbalConnected,
}
const resetMetrics = () => {
resetSyncMetrics()
}
const configureAutoSyncSettings = (config: any) => {
configureAutoSync(config)
}
const syncNow = async () => {
await triggerAutoSync()
}
return {
status,
metrics,
autoSyncStatus,
resetMetrics,
configureAutoSync: configureAutoSyncSettings,
syncNow,
}
}
@@ -0,0 +1,119 @@
import { useState, useCallback } from 'react'
import { PlaywrightTest, PlaywrightStep } from '@/types/project'
import { toast } from '@/components/ui/sonner'
import { llm } from '@/lib/llm-service'
import copy from '@/data/playwright-designer.json'
interface UsePlaywrightDesignerReturn {
selectedTestId: string | null
setSelectedTestId: (id: string | null) => void
selectedTest: PlaywrightTest | undefined
tests: PlaywrightTest[]
handleAddTest: () => void
handleDeleteTest: (testId: string) => void
handleUpdateTest: (testId: string, updates: Partial<PlaywrightTest>) => void
handleUpdateTestPartial: (updates: Partial<PlaywrightTest>) => void
handleAddStep: () => void
handleUpdateStep: (stepId: string, updates: Partial<PlaywrightStep>) => void
handleDeleteStep: (stepId: string) => void
handleGenerateWithAI: () => void
copy: typeof copy
}
export function usePlaywrightDesigner(
tests: PlaywrightTest[],
onTestsChange: (tests: PlaywrightTest[]) => void
): UsePlaywrightDesignerReturn {
const [selectedTestId, setSelectedTestId] = useState<string | null>(tests[0]?.id || null)
const selectedTest = tests.find(t => t.id === selectedTestId)
const handleAddTest = useCallback(() => {
const newTest: PlaywrightTest = {
id: `test-${Date.now()}`,
name: copy.defaults.newTestName,
description: '',
pageUrl: '/',
steps: []
}
onTestsChange([...tests, newTest])
setSelectedTestId(newTest.id)
}, [tests, onTestsChange])
const handleDeleteTest = useCallback((testId: string) => {
const remaining = tests.filter(test => test.id !== testId)
onTestsChange(remaining)
if (selectedTestId === testId) {
setSelectedTestId(remaining[0]?.id || null)
}
}, [tests, onTestsChange, selectedTestId])
const handleUpdateTest = useCallback((testId: string, updates: Partial<PlaywrightTest>) => {
onTestsChange(tests.map(test => (test.id === testId ? { ...test, ...updates } : test)))
}, [tests, onTestsChange])
const handleUpdateTestPartial = useCallback((updates: Partial<PlaywrightTest>) => {
if (!selectedTest) return
onTestsChange(tests.map(test => (test.id === selectedTest.id ? { ...test, ...updates } : test)))
}, [tests, onTestsChange, selectedTest])
const handleAddStep = useCallback(() => {
if (!selectedTest) return
const newStep: PlaywrightStep = {
id: `step-${Date.now()}`,
action: 'click',
selector: '',
value: ''
}
handleUpdateTest(selectedTest.id, {
steps: [...selectedTest.steps, newStep]
})
}, [selectedTest, handleUpdateTest])
const handleUpdateStep = useCallback((stepId: string, updates: Partial<PlaywrightStep>) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.map(step => (step.id === stepId ? { ...step, ...updates } : step))
})
}, [selectedTest, handleUpdateTest])
const handleDeleteStep = useCallback((stepId: string) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.filter(step => step.id !== stepId)
})
}, [selectedTest, handleUpdateTest])
const handleGenerateWithAI = useCallback(async () => {
const description = prompt(copy.prompts.describeTest)
if (!description) return
try {
toast.info(copy.messages.generating)
const promptText = copy.prompts.template.replace('{description}', description)
const response = await llm(promptText, 'claude-sonnet', true)
const parsed = JSON.parse(response)
onTestsChange([...tests, parsed.test])
setSelectedTestId(parsed.test.id)
toast.success(copy.messages.generated)
} catch (error) {
console.error(error)
toast.error(copy.messages.failed)
}
}, [tests, onTestsChange])
return {
selectedTestId,
setSelectedTestId,
selectedTest,
tests,
handleAddTest,
handleDeleteTest,
handleUpdateTest,
handleUpdateTestPartial,
handleAddStep,
handleUpdateStep,
handleDeleteStep,
handleGenerateWithAI,
copy,
}
}
@@ -0,0 +1,169 @@
import { useState } from 'react'
import { toast } from '@/components/ui/sonner'
import JSZip from 'jszip'
import {
ProjectFile,
DbModel,
ComponentNode,
ThemeConfig,
PlaywrightTest,
StorybookStory,
UnitTest,
FlaskConfig,
NextJsConfig,
NpmSettings,
} from '@/types/project'
import {
generateNextJSProject,
generatePrismaSchema,
generateMUITheme,
generatePlaywrightTests,
generateStorybookStories,
generateUnitTests,
generateFlaskApp,
} from '@/lib/generators'
export function useProjectExport(
files: ProjectFile[],
models: DbModel[],
components: ComponentNode[],
theme: ThemeConfig,
playwrightTests: PlaywrightTest[],
storybookStories: StorybookStory[],
unitTests: UnitTest[],
flaskConfig: FlaskConfig,
nextjsConfig: NextJsConfig,
npmSettings: NpmSettings
) {
const [generatedCode, setGeneratedCode] = useState<Record<string, string>>({})
const [exportDialogOpen, setExportDialogOpen] = useState(false)
const handleExportProject = () => {
const projectFiles = generateNextJSProject(nextjsConfig.appName, models, components, theme)
const prismaSchema = generatePrismaSchema(models)
const themeCode = generateMUITheme(theme)
const playwrightTestCode = generatePlaywrightTests(playwrightTests)
const storybookFiles = generateStorybookStories(storybookStories)
const unitTestFiles = generateUnitTests(unitTests)
const flaskFiles = generateFlaskApp(flaskConfig)
const packageJson = {
name: nextjsConfig.appName,
version: '0.1.0',
private: true,
scripts: npmSettings.scripts,
dependencies: npmSettings.packages
.filter(pkg => !pkg.isDev)
.reduce((acc, pkg) => {
acc[pkg.name] = pkg.version
return acc
}, {} as Record<string, string>),
devDependencies: npmSettings.packages
.filter(pkg => pkg.isDev)
.reduce((acc, pkg) => {
acc[pkg.name] = pkg.version
return acc
}, {} as Record<string, string>),
}
const allFiles: Record<string, string> = {
...projectFiles,
'package.json': JSON.stringify(packageJson, null, 2),
'prisma/schema.prisma': prismaSchema,
'src/theme.ts': themeCode,
'e2e/tests.spec.ts': playwrightTestCode,
...storybookFiles,
...unitTestFiles,
}
Object.entries(flaskFiles).forEach(([path, content]) => {
allFiles[`backend/${path}`] = content
})
files.forEach(file => {
allFiles[file.path] = file.content
})
setGeneratedCode(allFiles)
setExportDialogOpen(true)
toast.success('Project files generated!')
}
const handleDownloadZip = async () => {
try {
toast.info('Creating ZIP file...')
const zip = new JSZip()
Object.entries(generatedCode).forEach(([path, content]) => {
const cleanPath = path.startsWith('/') ? path.slice(1) : path
zip.file(cleanPath, content)
})
zip.file('README.md', `# ${nextjsConfig.appName}
Generated with CodeForge
## Getting Started
1. Install dependencies:
\`\`\`bash
npm install
\`\`\`
2. Set up Prisma (if using database):
\`\`\`bash
npx prisma generate
npx prisma db push
\`\`\`
3. Run the development server:
\`\`\`bash
npm run dev
\`\`\`
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Testing
Run E2E tests:
\`\`\`bash
npm run test:e2e
\`\`\`
Run unit tests:
\`\`\`bash
npm run test
\`\`\`
## Flask Backend (Optional)
Navigate to the backend directory and follow the setup instructions.
`)
const blob = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${nextjsConfig.appName}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Project downloaded successfully!')
} catch (error) {
console.error('Failed to create ZIP:', error)
toast.error('Failed to create ZIP file')
}
}
return {
generatedCode,
exportDialogOpen,
setExportDialogOpen,
handleExportProject,
handleDownloadZip,
}
}
@@ -0,0 +1,37 @@
import { Project } from '@/types/project'
export function useProjectLoader(
setFiles: (updater: any) => void,
setModels: (updater: any) => void,
setComponents: (updater: any) => void,
setComponentTrees: (updater: any) => void,
setWorkflows: (updater: any) => void,
setLambdas: (updater: any) => void,
setTheme: (updater: any) => void,
setPlaywrightTests: (updater: any) => void,
setStorybookStories: (updater: any) => void,
setUnitTests: (updater: any) => void,
setFlaskConfig: (updater: any) => void,
setNextjsConfig: (updater: any) => void,
setNpmSettings: (updater: any) => void,
setFeatureToggles: (updater: any) => void
) {
const loadProject = (project: Project) => {
if (project.files) setFiles(project.files)
if (project.models) setModels(project.models)
if (project.components) setComponents(project.components)
if (project.componentTrees) setComponentTrees(project.componentTrees)
if (project.workflows) setWorkflows(project.workflows)
if (project.lambdas) setLambdas(project.lambdas)
if (project.theme) setTheme(project.theme)
if (project.playwrightTests) setPlaywrightTests(project.playwrightTests)
if (project.storybookStories) setStorybookStories(project.storybookStories)
if (project.unitTests) setUnitTests(project.unitTests)
if (project.flaskConfig) setFlaskConfig(project.flaskConfig)
if (project.nextjsConfig) setNextjsConfig(project.nextjsConfig)
if (project.npmSettings) setNpmSettings(project.npmSettings)
if (project.featureToggles) setFeatureToggles(project.featureToggles)
}
return { loadProject }
}
@@ -0,0 +1,183 @@
import { useCallback, useState } from 'react'
import { toast } from '@/components/ui/sonner'
import { useProjectService, SavedProject } from '@/lib/project-service'
import { Project } from '@/types/project'
interface UseProjectManagerDialogsOptions {
currentProject: Project
onProjectLoad: (project: Project) => void
loadProjectsList: () => Promise<void>
}
export function useProjectManagerDialogs({
currentProject,
onProjectLoad,
loadProjectsList,
}: UseProjectManagerDialogsOptions) {
const projectService = useProjectService()
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [loadDialogOpen, setLoadDialogOpen] = useState(false)
const [newProjectDialogOpen, setNewProjectDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<string | null>(null)
const [projectName, setProjectName] = useState('')
const [projectDescription, setProjectDescription] = useState('')
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null)
const [importJson, setImportJson] = useState('')
const handleSaveProject = useCallback(async () => {
if (!projectName.trim()) {
toast.error('Please enter a project name')
return
}
try {
const id = currentProjectId || projectService.generateProjectId()
await projectService.saveProject(
id,
projectName,
currentProject,
projectDescription
)
setCurrentProjectId(id)
toast.success('Project saved successfully!')
setSaveDialogOpen(false)
await loadProjectsList()
} catch (error) {
console.error('Failed to save project:', error)
toast.error('Failed to save project')
}
}, [currentProject, currentProjectId, loadProjectsList, projectDescription, projectName, projectService])
const handleLoadProject = useCallback(async (project: SavedProject) => {
try {
onProjectLoad(project.data)
setCurrentProjectId(project.id)
setProjectName(project.name)
setProjectDescription(project.description || '')
setLoadDialogOpen(false)
toast.success(`Loaded project: ${project.name}`)
} catch (error) {
console.error('Failed to load project:', error)
toast.error('Failed to load project')
}
}, [onProjectLoad])
const handleDeleteProject = useCallback(async () => {
if (!projectToDelete) return
try {
await projectService.deleteProject(projectToDelete)
toast.success('Project deleted successfully')
setDeleteDialogOpen(false)
setProjectToDelete(null)
if (currentProjectId === projectToDelete) {
setCurrentProjectId(null)
setProjectName('')
setProjectDescription('')
}
await loadProjectsList()
} catch (error) {
console.error('Failed to delete project:', error)
toast.error('Failed to delete project')
}
}, [currentProjectId, loadProjectsList, projectToDelete, projectService])
const handleDuplicateProject = useCallback(async (id: string, name: string) => {
try {
const duplicated = await projectService.duplicateProject(id, `${name} (Copy)`)
if (duplicated) {
toast.success('Project duplicated successfully')
await loadProjectsList()
}
} catch (error) {
console.error('Failed to duplicate project:', error)
toast.error('Failed to duplicate project')
}
}, [loadProjectsList, projectService])
const handleExportProject = useCallback(async (id: string, name: string) => {
try {
const json = await projectService.exportProjectAsJSON(id)
if (json) {
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Project exported successfully')
}
} catch (error) {
console.error('Failed to export project:', error)
toast.error('Failed to export project')
}
}, [projectService])
const handleImportProject = useCallback(async () => {
if (!importJson.trim()) {
toast.error('Please paste project JSON')
return
}
try {
const imported = await projectService.importProjectFromJSON(importJson)
if (imported) {
toast.success('Project imported successfully')
setImportDialogOpen(false)
setImportJson('')
await loadProjectsList()
} else {
toast.error('Invalid project JSON')
}
} catch (error) {
console.error('Failed to import project:', error)
toast.error('Failed to import project')
}
}, [importJson, loadProjectsList, projectService])
const handleNewProject = useCallback(() => {
setCurrentProjectId(null)
setProjectName('')
setProjectDescription('')
setNewProjectDialogOpen(false)
toast.success('New project started')
}, [])
return {
currentProjectId,
projectName,
projectDescription,
importJson,
saveDialogOpen,
loadDialogOpen,
newProjectDialogOpen,
deleteDialogOpen,
importDialogOpen,
projectToDelete,
setSaveDialogOpen,
setLoadDialogOpen,
setNewProjectDialogOpen,
setDeleteDialogOpen,
setImportDialogOpen,
setProjectToDelete,
setProjectName,
setProjectDescription,
setImportJson,
handleSaveProject,
handleLoadProject,
handleDeleteProject,
handleDuplicateProject,
handleExportProject,
handleImportProject,
handleNewProject,
}
}

Some files were not shown because too many files have changed in this diff Show More