mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-05-07 03:59:35 +00:00
various changes
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 `[](${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
Reference in New Issue
Block a user