mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-25 06:04:54 +00:00
Generated by Spark: Too risky making changes without refactoring now. Create hook library, All components <150LOC. Consider orchestrating pages using json. JSON can describe actions, hooks, component tree, seed data you name it.
This commit is contained in:
66
src/config/orchestration/PageRenderer.tsx
Normal file
66
src/config/orchestration/PageRenderer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { PageSchema, ComponentDef } from './schema'
|
||||
import { getComponent } from './component-registry'
|
||||
import { useDataSources } from './data-source-manager'
|
||||
import { executeAction, ActionContext } from './action-executor'
|
||||
|
||||
interface PageRendererProps {
|
||||
schema: PageSchema
|
||||
onNavigate?: (path: string) => void
|
||||
customHandlers?: Record<string, (payload?: any) => void | Promise<void>>
|
||||
}
|
||||
|
||||
export function PageRenderer({ schema, onNavigate, customHandlers = {} }: PageRendererProps) {
|
||||
const { dataMap, updateData, getData } = useDataSources(schema.dataSources || [])
|
||||
|
||||
const context: ActionContext = {
|
||||
navigate: onNavigate || (() => {}),
|
||||
updateData,
|
||||
getData,
|
||||
customHandlers,
|
||||
}
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, payload?: any) => {
|
||||
const action = schema.actions?.find((a) => a.id === actionId)
|
||||
if (action) {
|
||||
executeAction({ ...action, payload: payload || action.payload }, context)
|
||||
}
|
||||
},
|
||||
[schema.actions, context]
|
||||
)
|
||||
|
||||
const renderComponent = (compDef: ComponentDef): React.ReactNode => {
|
||||
const Component = getComponent(compDef.type)
|
||||
if (!Component) {
|
||||
console.warn(`Component ${compDef.type} not found in registry`)
|
||||
return null
|
||||
}
|
||||
|
||||
const props = { ...compDef.props }
|
||||
|
||||
if (compDef.dataBinding && dataMap[compDef.dataBinding]) {
|
||||
props.data = dataMap[compDef.dataBinding]
|
||||
}
|
||||
|
||||
if (compDef.eventHandlers) {
|
||||
Object.entries(compDef.eventHandlers).forEach(([event, actionId]) => {
|
||||
props[event] = (payload?: any) => handleAction(String(actionId), payload)
|
||||
})
|
||||
}
|
||||
|
||||
const children = compDef.children?.map((child) => renderComponent(child))
|
||||
|
||||
return (
|
||||
<Component key={compDef.id} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{schema.components.map((comp) => renderComponent(comp))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/config/orchestration/action-executor.ts
Normal file
78
src/config/orchestration/action-executor.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Action } from './schema'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export type ActionContext = {
|
||||
navigate: (path: string) => void
|
||||
updateData: (key: string, value: any) => void
|
||||
getData: (key: string) => any
|
||||
customHandlers: Record<string, (payload?: any) => void | Promise<void>>
|
||||
}
|
||||
|
||||
export async function executeAction(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case 'navigate':
|
||||
if (action.target) {
|
||||
context.navigate(action.target)
|
||||
}
|
||||
break
|
||||
|
||||
case 'create':
|
||||
case 'update':
|
||||
if (action.target && action.payload) {
|
||||
const currentData = context.getData(action.target) || []
|
||||
const newData = action.type === 'create'
|
||||
? [...currentData, action.payload]
|
||||
: currentData.map((item: any) =>
|
||||
item.id === action.payload?.id ? { ...item, ...action.payload } : item
|
||||
)
|
||||
context.updateData(action.target, newData)
|
||||
}
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
if (action.target && action.payload?.id) {
|
||||
const currentData = context.getData(action.target) || []
|
||||
const filtered = currentData.filter(
|
||||
(item: any) => item.id !== action.payload?.id
|
||||
)
|
||||
context.updateData(action.target, filtered)
|
||||
}
|
||||
break
|
||||
|
||||
case 'api':
|
||||
if (action.payload?.endpoint) {
|
||||
const response = await fetch(action.payload.endpoint, {
|
||||
method: action.payload.method || 'GET',
|
||||
headers: action.payload.headers || {},
|
||||
body: action.payload.body ? JSON.stringify(action.payload.body) : undefined,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (action.target) {
|
||||
context.updateData(action.target, data)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'custom':
|
||||
if (action.handler && context.customHandlers[action.handler]) {
|
||||
await context.customHandlers[action.handler](action.payload)
|
||||
}
|
||||
break
|
||||
|
||||
case 'transform':
|
||||
if (action.handler && action.target) {
|
||||
const sourceData = context.getData(action.payload?.source || action.target)
|
||||
const transformed = context.customHandlers[action.handler]?.(sourceData)
|
||||
context.updateData(action.target, transformed)
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Action execution failed:', error)
|
||||
toast.error(`Failed to execute ${action.type} action`)
|
||||
}
|
||||
}
|
||||
36
src/config/orchestration/component-registry.ts
Normal file
36
src/config/orchestration/component-registry.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ComponentType } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { ProjectDashboard } from '@/components/ProjectDashboard'
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { ModelDesigner } from '@/components/ModelDesigner'
|
||||
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
|
||||
import { StyleDesigner } from '@/components/StyleDesigner'
|
||||
import { FeatureIdeaCloud } from '@/components/FeatureIdeaCloud'
|
||||
|
||||
export const ComponentRegistry: Record<string, ComponentType<any>> = {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Badge,
|
||||
Textarea,
|
||||
|
||||
ProjectDashboard,
|
||||
CodeEditor,
|
||||
ModelDesigner,
|
||||
ComponentTreeBuilder,
|
||||
StyleDesigner,
|
||||
FeatureIdeaCloud,
|
||||
}
|
||||
|
||||
export function getComponent(name: string): ComponentType<any> | null {
|
||||
return ComponentRegistry[name] || null
|
||||
}
|
||||
81
src/config/orchestration/data-source-manager.ts
Normal file
81
src/config/orchestration/data-source-manager.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { DataSource } from './schema'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useDataSource(source: DataSource) {
|
||||
const [kvData, setKvData] = useKV(source.key || source.id, source.defaultValue)
|
||||
const [apiData, setApiData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (source.type === 'api' && source.endpoint) {
|
||||
setLoading(true)
|
||||
fetch(source.endpoint)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setApiData(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err)
|
||||
setApiData(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [source.type, source.endpoint])
|
||||
|
||||
switch (source.type) {
|
||||
case 'kv':
|
||||
return { data: kvData, setData: setKvData, loading: false, error: null }
|
||||
case 'api':
|
||||
return { data: apiData, setData: setApiData, loading, error }
|
||||
case 'static':
|
||||
return {
|
||||
data: source.defaultValue,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'computed':
|
||||
return {
|
||||
data: source.defaultValue,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
data: null,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataSources(sources: DataSource[]) {
|
||||
const [dataMap, setDataMapState] = useState<Record<string, any>>({})
|
||||
|
||||
const updateData = (key: string, value: any) => {
|
||||
setDataMapState((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const getData = (key: string) => {
|
||||
return dataMap[key]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
sources.forEach((source) => {
|
||||
if (source.type === 'static' || source.type === 'computed') {
|
||||
updateData(source.id, source.defaultValue)
|
||||
}
|
||||
})
|
||||
}, [sources])
|
||||
|
||||
return {
|
||||
dataMap,
|
||||
updateData,
|
||||
getData,
|
||||
}
|
||||
}
|
||||
5
src/config/orchestration/index.ts
Normal file
5
src/config/orchestration/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './schema'
|
||||
export * from './action-executor'
|
||||
export * from './data-source-manager'
|
||||
export * from './component-registry'
|
||||
export { PageRenderer } from './PageRenderer'
|
||||
68
src/config/orchestration/schema.ts
Normal file
68
src/config/orchestration/schema.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['create', 'update', 'delete', 'navigate', 'api', 'transform', 'custom']),
|
||||
target: z.string().optional(),
|
||||
payload: z.record(z.any()).optional(),
|
||||
handler: z.string().optional(),
|
||||
})
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'api', 'computed', 'static']),
|
||||
key: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
transform: z.string().optional(),
|
||||
defaultValue: z.any().optional(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const HookConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
params: z.record(z.any()).optional(),
|
||||
bindings: z.record(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const ComponentPropsSchema = z.record(z.any())
|
||||
|
||||
export const ComponentSchema: any = z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
props: ComponentPropsSchema.optional(),
|
||||
children: z.lazy(() => z.array(ComponentSchema)).optional(),
|
||||
dataBinding: z.string().optional(),
|
||||
eventHandlers: z.record(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const LayoutSchema = z.object({
|
||||
type: z.enum(['single', 'split', 'grid', 'tabs', 'flex']),
|
||||
direction: z.enum(['horizontal', 'vertical', 'row', 'column']).optional(),
|
||||
panels: z.array(z.object({
|
||||
id: z.string(),
|
||||
minSize: z.number().optional(),
|
||||
maxSize: z.number().optional(),
|
||||
defaultSize: z.number().optional(),
|
||||
})).optional(),
|
||||
})
|
||||
|
||||
export const PageSchemaDefinition = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
layout: LayoutSchema,
|
||||
components: z.array(ComponentSchema),
|
||||
dataSources: z.array(DataSourceSchema).optional(),
|
||||
actions: z.array(ActionSchema).optional(),
|
||||
hooks: z.array(HookConfigSchema).optional(),
|
||||
seedData: z.record(z.any()).optional(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
export type DataSource = z.infer<typeof DataSourceSchema>
|
||||
export type HookConfig = z.infer<typeof HookConfigSchema>
|
||||
export type ComponentDef = z.infer<typeof ComponentSchema>
|
||||
export type Layout = z.infer<typeof LayoutSchema>
|
||||
export type PageSchema = z.infer<typeof PageSchemaDefinition>
|
||||
49
src/config/pages/dashboard.json
Normal file
49
src/config/pages/dashboard.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"id": "dashboard-json",
|
||||
"name": "Dashboard (JSON)",
|
||||
"description": "JSON-driven dashboard page",
|
||||
"icon": "ChartBar",
|
||||
"layout": {
|
||||
"type": "single"
|
||||
},
|
||||
"dataSources": [
|
||||
{
|
||||
"id": "files",
|
||||
"type": "kv",
|
||||
"key": "project-files",
|
||||
"defaultValue": []
|
||||
},
|
||||
{
|
||||
"id": "models",
|
||||
"type": "kv",
|
||||
"key": "project-models",
|
||||
"defaultValue": []
|
||||
},
|
||||
{
|
||||
"id": "components",
|
||||
"type": "kv",
|
||||
"key": "project-components",
|
||||
"defaultValue": []
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "dashboard-root",
|
||||
"type": "ProjectDashboard",
|
||||
"props": {},
|
||||
"dataBinding": "files"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "navigate-to-code",
|
||||
"type": "navigate",
|
||||
"target": "code"
|
||||
},
|
||||
{
|
||||
"id": "create-file",
|
||||
"type": "create",
|
||||
"target": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
113
src/config/pages/simple-form.json
Normal file
113
src/config/pages/simple-form.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"id": "simple-form",
|
||||
"name": "Simple Form Example",
|
||||
"description": "Example JSON-driven form page",
|
||||
"icon": "Note",
|
||||
"layout": {
|
||||
"type": "single"
|
||||
},
|
||||
"dataSources": [
|
||||
{
|
||||
"id": "formData",
|
||||
"type": "kv",
|
||||
"key": "simple-form-data",
|
||||
"defaultValue": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"category": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "form-card",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"className": "max-w-2xl mx-auto mt-8"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "card-header",
|
||||
"type": "CardHeader",
|
||||
"children": [
|
||||
{
|
||||
"id": "card-title",
|
||||
"type": "CardTitle",
|
||||
"props": {
|
||||
"children": "Create New Item"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "card-desc",
|
||||
"type": "CardDescription",
|
||||
"props": {
|
||||
"children": "Fill out the form below to create a new item"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card-content",
|
||||
"type": "CardContent",
|
||||
"props": {
|
||||
"className": "space-y-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "title-input",
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"placeholder": "Enter title",
|
||||
"className": "w-full"
|
||||
},
|
||||
"dataBinding": "formData.title",
|
||||
"eventHandlers": {
|
||||
"onChange": "update-title"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "description-textarea",
|
||||
"type": "Textarea",
|
||||
"props": {
|
||||
"placeholder": "Enter description",
|
||||
"rows": 4
|
||||
},
|
||||
"dataBinding": "formData.description",
|
||||
"eventHandlers": {
|
||||
"onChange": "update-description"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "submit-button",
|
||||
"type": "Button",
|
||||
"props": {
|
||||
"children": "Submit",
|
||||
"className": "w-full"
|
||||
},
|
||||
"eventHandlers": {
|
||||
"onClick": "submit-form"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "update-title",
|
||||
"type": "update",
|
||||
"target": "formData"
|
||||
},
|
||||
{
|
||||
"id": "update-description",
|
||||
"type": "update",
|
||||
"target": "formData"
|
||||
},
|
||||
{
|
||||
"id": "submit-form",
|
||||
"type": "custom",
|
||||
"handler": "handleFormSubmit"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useFiles } from './use-files'
|
||||
export { useModels } from './use-models'
|
||||
export { useComponents } from './use-components'
|
||||
export { useWorkflows } from './use-workflows'
|
||||
export { useLambdas } from './use-lambdas'
|
||||
export * from './use-array'
|
||||
export * from './use-crud'
|
||||
export * from './use-search'
|
||||
export * from './use-debounce'
|
||||
export * from './use-sort'
|
||||
export * from './use-pagination'
|
||||
|
||||
63
src/hooks/data/use-array.ts
Normal file
63
src/hooks/data/use-array.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useArray<T>(key: string, defaultValue: T[] = []) {
|
||||
const [items, setItems] = useKV<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,
|
||||
}
|
||||
}
|
||||
75
src/hooks/data/use-crud.ts
Normal file
75
src/hooks/data/use-crud.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface Entity {
|
||||
id: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function useCRUD<T extends Entity>(
|
||||
items: T[],
|
||||
setItems: (items: T[] | ((prev: T[]) => T[])) => void
|
||||
) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const create = useCallback(
|
||||
(item: T) => {
|
||||
setItems((current) => [...(current || []), item])
|
||||
return item.id
|
||||
},
|
||||
[setItems]
|
||||
)
|
||||
|
||||
const read = useCallback(
|
||||
(id: string) => {
|
||||
return items?.find((item) => item.id === id)
|
||||
},
|
||||
[items]
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
(id: string, updates: Partial<T>) => {
|
||||
setItems((current) =>
|
||||
(current || []).map((item) =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
)
|
||||
)
|
||||
},
|
||||
[setItems]
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
setItems((current) => (current || []).filter((item) => item.id !== id))
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null)
|
||||
}
|
||||
},
|
||||
[setItems, selectedId]
|
||||
)
|
||||
|
||||
const duplicate = useCallback(
|
||||
(id: string, newId: string) => {
|
||||
const item = read(id)
|
||||
if (!item) return null
|
||||
|
||||
const duplicated = { ...item, id: newId }
|
||||
create(duplicated)
|
||||
return newId
|
||||
},
|
||||
[read, create]
|
||||
)
|
||||
|
||||
const selected = selectedId ? read(selectedId) : null
|
||||
|
||||
return {
|
||||
items: items || [],
|
||||
create,
|
||||
read,
|
||||
update,
|
||||
remove,
|
||||
duplicate,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
17
src/hooks/data/use-debounce.ts
Normal file
17
src/hooks/data/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
52
src/hooks/data/use-pagination.ts
Normal file
52
src/hooks/data/use-pagination.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
|
||||
export interface PaginationConfig {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function usePagination<T>(items: T[], initialPageSize: number = 10) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
|
||||
const total = items.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const paginatedItems = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
const end = start + pageSize
|
||||
return items.slice(start, end)
|
||||
}, [items, page, pageSize])
|
||||
|
||||
const goToPage = useCallback((newPage: number) => {
|
||||
setPage(Math.max(1, Math.min(newPage, totalPages)))
|
||||
}, [totalPages])
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
goToPage(page + 1)
|
||||
}, [page, goToPage])
|
||||
|
||||
const prevPage = useCallback(() => {
|
||||
goToPage(page - 1)
|
||||
}, [page, goToPage])
|
||||
|
||||
const changePageSize = useCallback((newSize: number) => {
|
||||
setPageSize(newSize)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
items: paginatedItems,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changePageSize,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
}
|
||||
42
src/hooks/data/use-search.ts
Normal file
42
src/hooks/data/use-search.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useDebounce } from './use-debounce'
|
||||
|
||||
export function useSearch<T>(
|
||||
items: T[],
|
||||
searchKeys: (keyof T)[],
|
||||
debounceMs: number = 300
|
||||
) {
|
||||
const [query, setQuery] = useState('')
|
||||
const debouncedQuery = useDebounce(query, debounceMs)
|
||||
const [results, setResults] = useState<T[]>(items)
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults(items)
|
||||
return
|
||||
}
|
||||
|
||||
const lowerQuery = debouncedQuery.toLowerCase()
|
||||
const filtered = items.filter((item) =>
|
||||
searchKeys.some((key) => {
|
||||
const value = item[key]
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(lowerQuery)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value.toString().includes(lowerQuery)
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
|
||||
setResults(filtered)
|
||||
}, [debouncedQuery, items, searchKeys])
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
isSearching: query.length > 0,
|
||||
}
|
||||
}
|
||||
49
src/hooks/data/use-sort.ts
Normal file
49
src/hooks/data/use-sort.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export function useSort<T>(items: T[], defaultKey?: keyof T) {
|
||||
const [sortKey, setSortKey] = useState<keyof T | null>(defaultKey || null)
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const toggleSort = useCallback(
|
||||
(key: keyof T) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
},
|
||||
[sortKey]
|
||||
)
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
if (!sortKey) return 0
|
||||
|
||||
const aVal = a[sortKey]
|
||||
const bVal = b[sortKey]
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
let comparison = 0
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
comparison = aVal.localeCompare(bVal)
|
||||
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
comparison = aVal - bVal
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal))
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
return {
|
||||
sortedItems,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
setSortKey,
|
||||
setSortDirection,
|
||||
}
|
||||
}
|
||||
2
src/hooks/forms/index.ts
Normal file
2
src/hooks/forms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-form'
|
||||
export * from './use-form-field'
|
||||
54
src/hooks/forms/use-form-field.ts
Normal file
54
src/hooks/forms/use-form-field.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export type ValidationRule<T> = {
|
||||
validate: (value: T) => boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export function useFormField<T>(
|
||||
initialValue: T,
|
||||
rules: ValidationRule<T>[] = []
|
||||
) {
|
||||
const [value, setValue] = useState<T>(initialValue)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [touched, setTouched] = useState(false)
|
||||
|
||||
const validate = useCallback(() => {
|
||||
for (const rule of rules) {
|
||||
if (!rule.validate(value)) {
|
||||
setError(rule.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
setError(null)
|
||||
return true
|
||||
}, [value, rules])
|
||||
|
||||
const onChange = useCallback((newValue: T) => {
|
||||
setValue(newValue)
|
||||
setTouched(true)
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setTouched(true)
|
||||
validate()
|
||||
}, [validate])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValue(initialValue)
|
||||
setError(null)
|
||||
setTouched(false)
|
||||
}, [initialValue])
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
touched,
|
||||
isValid: error === null && touched,
|
||||
validate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
73
src/hooks/forms/use-form.ts
Normal file
73
src/hooks/forms/use-form.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface FormConfig<T> {
|
||||
initialValues: T
|
||||
onSubmit: (values: T) => void | Promise<void>
|
||||
validate?: (values: T) => Record<string, string>
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, any>>({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
validate,
|
||||
}: FormConfig<T>) {
|
||||
const [values, setValues] = useState<T>(initialValues)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const setValue = useCallback((field: keyof T, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
const setFieldTouched = useCallback((field: keyof T) => {
|
||||
setTouched((prev) => ({ ...prev, [field]: true }))
|
||||
}, [])
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
if (!validate) return true
|
||||
|
||||
const validationErrors = validate(values)
|
||||
setErrors(validationErrors)
|
||||
return Object.keys(validationErrors).length === 0
|
||||
}, [values, validate])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault()
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
if (!validateForm()) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[values, validateForm, onSubmit]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValues(initialValues)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
setIsSubmitting(false)
|
||||
}, [initialValues])
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
setValue,
|
||||
setFieldTouched,
|
||||
handleSubmit,
|
||||
reset,
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
}
|
||||
}
|
||||
4
src/hooks/ui/index.ts
Normal file
4
src/hooks/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './use-dialog'
|
||||
export * from './use-tabs'
|
||||
export * from './use-selection'
|
||||
export * from './use-clipboard'
|
||||
30
src/hooks/ui/use-clipboard.ts
Normal file
30
src/hooks/ui/use-clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from '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,
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export function useDialog(initialOpen = false) {
|
||||
const [open, setOpen] = useState(initialOpen)
|
||||
export function useDialog(initialOpen: boolean = false) {
|
||||
const [isOpen, setIsOpen] = useState(initialOpen)
|
||||
|
||||
const openDialog = useCallback(() => setOpen(true), [])
|
||||
const closeDialog = useCallback(() => setOpen(false), [])
|
||||
const toggleDialog = useCallback(() => setOpen((prev) => !prev), [])
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
setOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
toggleDialog,
|
||||
close,
|
||||
toggle,
|
||||
setIsOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,53 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export function useSelection<T extends string | number>(
|
||||
initialSelection: T[] = []
|
||||
) {
|
||||
const [selected, setSelected] = useState<T[]>(initialSelection)
|
||||
export function useSelection<T extends { id: string }>() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const select = useCallback((id: T) => {
|
||||
setSelected((prev) => [...prev, id])
|
||||
const select = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => new Set(prev).add(id))
|
||||
}, [])
|
||||
|
||||
const deselect = useCallback((id: T) => {
|
||||
setSelected((prev) => prev.filter((item) => item !== id))
|
||||
const deselect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback((id: T) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
|
||||
)
|
||||
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((ids: T[]) => {
|
||||
setSelected(ids)
|
||||
const selectAll = useCallback((items: T[]) => {
|
||||
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelected([])
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
const isSelected = useCallback(
|
||||
(id: T) => selected.includes(id),
|
||||
[selected]
|
||||
(id: string) => selectedIds.has(id),
|
||||
[selectedIds]
|
||||
)
|
||||
|
||||
return {
|
||||
selected,
|
||||
selectedIds: Array.from(selectedIds),
|
||||
select,
|
||||
deselect,
|
||||
toggle,
|
||||
selectAll,
|
||||
clear,
|
||||
deselectAll,
|
||||
isSelected,
|
||||
count: selected.length,
|
||||
count: selectedIds.size,
|
||||
}
|
||||
}
|
||||
|
||||
21
src/hooks/ui/use-tabs.ts
Normal file
21
src/hooks/ui/use-tabs.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user