Generated by Spark: Load more of UI from JSON declarations and break up large components into atomic and create hooks as needed

This commit is contained in:
2026-01-17 11:38:28 +00:00
committed by GitHub
parent 19a44c4010
commit 3ae07db657
28 changed files with 2227 additions and 465 deletions

View File

@@ -1,5 +1,6 @@
import { useCRUD, useSearch, useFilter } from '@/hooks/data'
import { useCRUD, useSearchFilter } from '@/hooks/data'
import { useToggle, useDialog } from '@/hooks/ui'
import { useKV } from '@github/spark/hooks'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { SearchInput, DataCard, ActionBar } from '@/components/molecules'
@@ -15,17 +16,15 @@ interface Task {
}
export function AtomicComponentDemo() {
const { items: tasks, create, remove } = useCRUD<Task>({
key: 'demo-tasks',
defaultValue: [
{ id: 1, title: 'Build component library', status: 'active', priority: 'high' },
{ id: 2, title: 'Write documentation', status: 'pending', priority: 'medium' },
{ id: 3, title: 'Create examples', status: 'success', priority: 'low' },
],
persist: true,
})
const [tasks, setTasks] = useKV<Task[]>('demo-tasks', [
{ id: 1, title: 'Build component library', status: 'active', priority: 'high' },
{ id: 2, title: 'Write documentation', status: 'pending', priority: 'medium' },
{ id: 3, title: 'Create examples', status: 'success', priority: 'low' },
])
const { query, setQuery, filtered } = useSearch({
const crud = useCRUD<Task>({ items: tasks, setItems: setTasks })
const { searchQuery: query, setSearchQuery: setQuery, filtered } = useSearchFilter({
items: tasks,
searchFields: ['title'],
})

View File

@@ -0,0 +1,205 @@
import { ReactNode } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/atoms'
import * as Icons from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
export interface PageComponentConfig {
id: string
type: string
[key: string]: any
}
export interface PageLayoutConfig {
type: string
spacing?: string
sections?: PageSectionConfig[]
[key: string]: any
}
export interface PageSectionConfig {
type: string
[key: string]: any
}
export interface PageSchema {
id: string
layout: PageLayoutConfig
dashboardCards?: any[]
statCards?: any[]
[key: string]: any
}
interface ComponentRendererProps {
schema: PageSchema
data: Record<string, any>
functions?: Record<string, (...args: any[]) => any>
}
function resolveBinding(binding: string, data: Record<string, any>): any {
try {
const func = new Function(...Object.keys(data), `return ${binding}`)
return func(...Object.values(data))
} catch {
return binding
}
}
function getIcon(iconName: string, props?: any) {
const IconComponent = (Icons as any)[iconName]
if (!IconComponent) return null
return <IconComponent size={24} weight="duotone" {...props} />
}
export function JSONPageRenderer({ schema, data, functions = {} }: ComponentRendererProps) {
const renderSection = (section: PageSectionConfig, index: number): ReactNode => {
switch (section.type) {
case 'header':
return (
<div key={index}>
<h1 className="text-3xl font-bold mb-2">{section.title}</h1>
{section.description && (
<p className="text-muted-foreground">{section.description}</p>
)}
</div>
)
case 'cards':
const cards = schema[section.items as string] || []
return (
<div key={index} className={cn('space-y-' + (section.spacing || '4'))}>
{cards.map((card: any) => renderCard(card))}
</div>
)
case 'grid':
const gridItems = schema[section.items as string] || []
const { sm = 1, md = 2, lg = 3 } = section.columns || {}
return (
<div
key={index}
className={cn(
'grid gap-' + (section.gap || '4'),
`grid-cols-${sm} md:grid-cols-${md} lg:grid-cols-${lg}`
)}
>
{gridItems.map((item: any) => renderStatCard(item))}
</div>
)
default:
return null
}
}
const renderCard = (card: any): ReactNode => {
const icon = card.icon ? getIcon(card.icon) : null
if (card.type === 'gradient-card') {
const computeFn = functions[card.dataSource?.compute]
const computedData = computeFn ? computeFn(data) : {}
return (
<Card
key={card.id}
className={cn(
'bg-gradient-to-br border-primary/20',
card.gradient
)}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon && <span className="text-primary">{icon}</span>}
{card.title}
</CardTitle>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{card.components?.map((comp: any, idx: number) =>
renderSubComponent(comp, computedData, idx)
)}
</CardContent>
</Card>
)
}
return (
<Card key={card.id}>
<CardHeader>
<CardTitle>{card.title}</CardTitle>
</CardHeader>
<CardContent>
{card.component && renderCustomComponent(card.component, card.props || {})}
</CardContent>
</Card>
)
}
const renderSubComponent = (comp: any, dataContext: any, key: number): ReactNode => {
const value = dataContext[comp.binding]
switch (comp.type) {
case 'metric':
return (
<div key={key} className="flex items-center justify-between">
<span className={cn(
'font-bold',
comp.size === 'large' ? 'text-4xl' : 'text-2xl'
)}>
{comp.format === 'percentage' ? `${value}%` : value}
</span>
</div>
)
case 'badge':
const variant = value === 'ready' ? comp.variants?.ready : comp.variants?.inProgress
return (
<Badge key={key} variant={variant?.variant as any}>
{variant?.label}
</Badge>
)
case 'progress':
return <Progress key={key} value={value} className="h-3" />
case 'text':
return (
<p key={key} className={comp.className}>
{value}
</p>
)
default:
return null
}
}
const renderStatCard = (stat: any): ReactNode => {
const icon = stat.icon ? getIcon(stat.icon) : undefined
const value = resolveBinding(stat.dataBinding, data)
const description = `${value} ${stat.description}`
return (
<StatCard
key={stat.id}
icon={icon}
title={stat.title}
value={value}
description={description}
color={stat.color}
/>
)
}
const renderCustomComponent = (componentName: string, props: any): ReactNode => {
return <div>Custom component: {componentName}</div>
}
return (
<div className="h-full overflow-auto p-6 space-y-6">
{schema.layout.sections?.map((section, index) => renderSection(section, index))}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { JSONPageRenderer } from '@/components/JSONPageRenderer'
import dashboardSchema from '@/config/pages/dashboard.json'
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig } from '@/types/project'
interface ProjectDashboardProps {
files: ProjectFile[]
models: PrismaModel[]
components: ComponentNode[]
theme: ThemeConfig
playwrightTests: PlaywrightTest[]
storybookStories: StorybookStory[]
unitTests: UnitTest[]
flaskConfig: FlaskConfig
nextjsConfig: NextJsConfig
}
function calculateCompletionScore(data: any) {
const { files = [], models = [], components = [], playwrightTests = [], storybookStories = [], unitTests = [] } = data
const totalFiles = files.length
const totalModels = models.length
const totalComponents = components.length
const totalTests = playwrightTests.length + storybookStories.length + unitTests.length
let score = 0
if (totalFiles > 0) score += 30
if (totalModels > 0) score += 20
if (totalComponents > 0) score += 20
if (totalTests > 0) score += 30
const completionScore = Math.min(score, 100)
return {
completionScore,
completionStatus: completionScore >= 70 ? 'ready' : 'inProgress',
completionMessage: getCompletionMessage(completionScore)
}
}
function getCompletionMessage(score: number): string {
if (score >= 90) return 'Excellent! Your project is production-ready.'
if (score >= 70) return 'Great progress! Consider adding more tests.'
if (score >= 50) return 'Good start! Keep building features.'
return 'Just getting started. Add some components and models.'
}
export function ProjectDashboard(props: ProjectDashboardProps) {
return (
<JSONPageRenderer
schema={dashboardSchema as any}
data={props}
functions={{ calculateCompletionScore }}
/>
)
}

View File

@@ -1,36 +1,51 @@
import { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
interface ActionButtonProps {
icon?: React.ReactNode
label?: string
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
export interface ActionButtonProps {
icon?: ReactNode
label: string
onClick: () => void
variant?: 'default' | 'outline' | 'ghost' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
onClick?: () => void
tooltip?: string
disabled?: boolean
loading?: boolean
className?: string
}
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
({ icon, label, variant = 'default', size = 'default', onClick, disabled, loading, className }, ref) => {
export function ActionButton({
icon,
label,
onClick,
variant = 'default',
size = 'default',
tooltip,
disabled,
className,
}: ActionButtonProps) {
const button = (
<Button
variant={variant}
size={size}
onClick={onClick}
disabled={disabled}
className={className}
>
{icon && <span className="mr-2">{icon}</span>}
{label}
</Button>
)
if (tooltip) {
return (
<Button
ref={ref}
variant={variant}
size={size}
onClick={onClick}
disabled={disabled || loading}
className={cn(className)}
>
{loading ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : icon}
{label && <span className={icon ? 'ml-2' : ''}>{label}</span>}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
)
ActionButton.displayName = 'ActionButton'
return button
}

View File

@@ -1,23 +1,24 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface DataListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
export interface DataListProps {
items: any[]
renderItem: (item: any, index: number) => ReactNode
emptyMessage?: string
className?: string
itemClassName?: string
}
export function DataList<T>({
items,
renderItem,
emptyMessage = 'No items to display',
export function DataList({
items,
renderItem,
emptyMessage = 'No items',
className,
itemClassName
}: DataListProps<T>) {
itemClassName,
}: DataListProps) {
if (items.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-8 text-muted-foreground">
{emptyMessage}
</div>
)
@@ -26,7 +27,7 @@ export function DataList<T>({
return (
<div className={cn('space-y-2', className)}>
{items.map((item, index) => (
<div key={index} className={itemClassName}>
<div key={index} className={cn('transition-colors', itemClassName)}>
{renderItem(item, index)}
</div>
))}

View File

@@ -0,0 +1,48 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
export interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: {
label: string
onClick: () => void
}
className?: string
}
export function EmptyState({
icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div className={cn(
'flex flex-col items-center justify-center gap-4 py-12 px-6 text-center',
className
)}>
{icon && (
<div className="text-muted-foreground text-4xl opacity-50">
{icon}
</div>
)}
<div className="space-y-2">
<h3 className="text-lg font-semibold">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-md">
{description}
</p>
)}
</div>
{action && (
<Button onClick={action.onClick} className="mt-2">
{action.label}
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils'
export interface LoadingStateProps {
message?: string
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function LoadingState({
message = 'Loading...',
size = 'md',
className
}: LoadingStateProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div className={cn('flex flex-col items-center justify-center gap-3 py-8', className)}>
<div className={cn(
'border-primary border-t-transparent rounded-full animate-spin',
sizeClasses[size]
)} />
{message && (
<p className="text-sm text-muted-foreground">{message}</p>
)}
</div>
)
}

View File

@@ -1,37 +1,50 @@
import { ReactNode } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
interface StatCardProps {
label: string
export interface StatCardProps {
icon?: ReactNode
title: string
value: string | number
icon?: React.ReactNode
description?: string
color?: string
trend?: {
value: number
positive: boolean
direction: 'up' | 'down'
}
className?: string
}
export function StatCard({ label, value, icon, trend, className }: StatCardProps) {
export function StatCard({
icon,
title,
value,
description,
color = 'text-primary',
trend,
className,
}: StatCardProps) {
return (
<Card className={cn('bg-card/50 backdrop-blur', className)}>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-3xl font-bold">{value}</p>
<Card className={cn('transition-all hover:shadow-lg', className)}>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold mt-2">{value}</p>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
{trend && (
<p className={cn(
'text-xs flex items-center gap-1',
trend.positive ? 'text-green-600' : 'text-red-600'
<div className={cn(
'text-sm font-medium mt-2',
trend.direction === 'up' ? 'text-green-600' : 'text-red-600'
)}>
<span>{trend.positive ? '↑' : '↓'}</span>
<span>{Math.abs(trend.value)}%</span>
</p>
{trend.direction === 'up' ? '↑' : '↓'} {Math.abs(trend.value)}%
</div>
)}
</div>
{icon && (
<div className="text-muted-foreground">
<div className={cn('text-2xl', color)}>
{icon}
</div>
)}

View File

@@ -19,4 +19,8 @@ export { List } from './List'
export { Grid } from './Grid'
export { DataSourceBadge } from './DataSourceBadge'
export { BindingIndicator } from './BindingIndicator'
export { StatCard } from './StatCard'
export { LoadingState } from './LoadingState'
export { EmptyState } from './EmptyState'

View File

@@ -1,49 +1,120 @@
{
"id": "dashboard-json",
"name": "Dashboard (JSON)",
"description": "JSON-driven dashboard page",
"icon": "ChartBar",
"layout": {
"type": "single"
},
"dataSources": [
"dashboardCards": [
{
"id": "files",
"type": "kv",
"key": "project-files",
"defaultValue": []
"id": "completion",
"type": "gradient-card",
"title": "Project Completeness",
"icon": "CheckCircle",
"gradient": "from-primary/10 to-accent/10",
"dataSource": {
"type": "computed",
"compute": "calculateCompletionScore"
},
"components": [
{
"type": "metric",
"binding": "completionScore",
"format": "percentage",
"size": "large"
},
{
"type": "badge",
"binding": "completionStatus",
"variants": {
"ready": { "label": "Ready to Export", "variant": "default" },
"inProgress": { "label": "In Progress", "variant": "secondary" }
}
},
{
"type": "progress",
"binding": "completionScore"
},
{
"type": "text",
"binding": "completionMessage",
"className": "text-sm text-muted-foreground"
}
]
},
{
"id": "models",
"type": "kv",
"key": "project-models",
"defaultValue": []
"id": "build-status",
"type": "card",
"title": "GitHub Build Status",
"icon": "GitBranch",
"component": "GitHubBuildStatus",
"props": {}
}
],
"statCards": [
{
"id": "code-files",
"icon": "Code",
"title": "Code Files",
"dataBinding": "files.length",
"description": "files in your project",
"color": "text-blue-500"
},
{
"id": "database-models",
"icon": "Database",
"title": "Database Models",
"dataBinding": "models.length",
"description": "Prisma schemas",
"color": "text-purple-500"
},
{
"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"
"icon": "Cube",
"title": "Components",
"dataBinding": "components.length",
"description": "React components",
"color": "text-green-500"
},
{
"id": "create-file",
"type": "create",
"target": "files"
"id": "workflows",
"icon": "GitBranch",
"title": "Workflows",
"dataBinding": "workflows.length",
"description": "automation flows",
"color": "text-orange-500"
},
{
"id": "flask-endpoints",
"icon": "Flask",
"title": "API Endpoints",
"dataBinding": "flaskConfig.blueprints.reduce((acc, bp) => acc + bp.endpoints.length, 0)",
"description": "Flask routes",
"color": "text-pink-500"
},
{
"id": "test-suites",
"icon": "TestTube",
"title": "Test Suites",
"dataBinding": "playwrightTests.length + storybookStories.length + unitTests.length",
"description": "automated tests",
"color": "text-cyan-500"
}
]
],
"layout": {
"type": "vertical",
"spacing": "6",
"sections": [
{
"type": "header",
"title": "Project Dashboard",
"description": "Overview of your CodeForge project"
},
{
"type": "cards",
"items": "dashboardCards",
"spacing": "6"
},
{
"type": "grid",
"items": "statCards",
"columns": { "sm": 1, "md": 2, "lg": 3 },
"gap": "4"
}
]
}
}

View File

@@ -1,13 +1,14 @@
export { useJSONData } from './use-json-data'
export { useDataSources } from './use-data-sources'
export { useKVDataSource, useComputedDataSource, useStaticDataSource, useMultipleDataSources } from './use-data-source'
export { useCRUD } from './use-crud'
export { useSearch } from './use-search'
export { useSearchFilter } from './use-search-filter'
export { useSort } from './use-sort'
export { useFilter } from './use-filter'
export { useLocalStorage } from './use-local-storage'
export { usePagination } from './use-pagination'
export type { UseJSONDataOptions } from './use-json-data'
export type { UseCRUDOptions } from './use-crud'
export type { UseSearchOptions } from './use-search'
export type { UseSortOptions, SortDirection } from './use-sort'
export type { FilterConfig, UseFilterOptions } from './use-filter'
export { useSelection } from './use-selection'
export { useSeedData } from './use-seed-data'
export type { DataSourceConfig, DataSourceType } from './use-data-source'
export type { CRUDOperations, CRUDConfig } from './use-crud'
export type { SearchFilterConfig } from './use-search-filter'
export type { SortConfig, SortDirection } from './use-sort'
export type { PaginationConfig } from './use-pagination'
export type { SelectionConfig } from './use-selection'

View File

@@ -1,55 +1,51 @@
import { useState, useCallback } from 'react'
import { useKV } from '@github/spark/hooks'
import { useCallback } from 'react'
export interface UseCRUDOptions<T> {
key: string
defaultValue?: T[]
persist?: boolean
getId?: (item: T) => string | number
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 function useCRUD<T>(options: UseCRUDOptions<T>) {
const { key, defaultValue = [], persist = true, getId = (item: any) => item.id } = options
const [persistedItems, setPersistedItems] = useKV<T[]>(key, defaultValue)
const [localItems, setLocalItems] = useState<T[]>(defaultValue)
const items = persist ? persistedItems : localItems
const setItems = persist ? setPersistedItems : setLocalItems
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: T[]) => [...current, item])
setItems(current => [...current, item])
}, [setItems])
const read = useCallback((id: string | number): T | undefined => {
return items.find(item => getId(item) === id)
}, [items, getId])
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: T[]) =>
setItems(current =>
current.map(item =>
getId(item) === id ? { ...item, ...updates } : item
item[idField] === id ? { ...item, ...updates } : item
)
)
}, [setItems, getId])
}, [setItems, idField])
const remove = useCallback((id: string | number) => {
setItems((current: T[]) =>
current.filter(item => getId(item) !== id)
)
}, [setItems, getId])
const deleteItem = useCallback((id: string | number) => {
setItems(current => current.filter(item => item[idField] !== id))
}, [setItems, idField])
const clear = useCallback(() => {
setItems([])
}, [setItems])
const list = useCallback(() => items, [items])
return {
items,
create,
read,
update,
remove,
clear,
setItems,
delete: deleteItem,
list,
}
}

View File

@@ -0,0 +1,81 @@
import { useState, useEffect, useCallback } from 'react'
import { useKV } from '@github/spark/hooks'
export type DataSourceType = 'kv' | 'static' | 'computed'
export interface DataSourceConfig<T = any> {
id: string
type: DataSourceType
key?: string
defaultValue?: T
compute?: (allData: Record<string, any>) => T
dependencies?: string[]
}
export function useKVDataSource<T>(key: string, defaultValue: T) {
const [value, setValue, deleteValue] = useKV<T>(key, defaultValue)
return {
data: value,
setData: setValue,
deleteData: deleteValue,
isLoading: false,
error: null,
}
}
export function useComputedDataSource<T>(
compute: (allData: Record<string, any>) => T,
allData: Record<string, any>,
dependencies: string[],
defaultValue?: T
) {
const [computed, setComputed] = useState<T>(defaultValue as T)
useEffect(() => {
try {
const newValue = compute(allData)
setComputed(newValue)
} catch (error) {
console.error('Error computing data source:', error)
}
}, dependencies.map(dep => allData[dep]))
return {
data: computed,
setData: () => {},
deleteData: () => {},
isLoading: false,
error: null,
}
}
export function useStaticDataSource<T>(value: T) {
return {
data: value,
setData: () => {},
deleteData: () => {},
isLoading: false,
error: null,
}
}
export function useMultipleDataSources(
configs: DataSourceConfig[],
onUpdate?: (data: Record<string, any>) => void
) {
const [allData, setAllData] = useState<Record<string, any>>({})
const updateData = useCallback((id: string, value: any) => {
setAllData(prev => {
const next = { ...prev, [id]: value }
onUpdate?.(next)
return next
})
}, [onUpdate])
return {
allData,
updateData,
}
}

View File

@@ -1,52 +1,55 @@
import { useState, useCallback, useMemo } from 'react'
import { useState, useMemo, useCallback } from 'react'
export interface PaginationConfig {
page: number
pageSize: number
total: number
items: any[]
pageSize?: number
initialPage?: number
}
export function usePagination<T>(items: T[], initialPageSize: number = 10) {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(initialPageSize)
export function usePagination<T>({
items,
pageSize = 10,
initialPage = 1,
}: PaginationConfig) {
const [currentPage, setCurrentPage] = useState(initialPage)
const total = items.length
const totalPages = Math.ceil(total / pageSize)
const totalPages = Math.ceil(items.length / pageSize)
const paginatedItems = useMemo(() => {
const start = (page - 1) * pageSize
const start = (currentPage - 1) * pageSize
const end = start + pageSize
return items.slice(start, end)
}, [items, page, pageSize])
}, [items, currentPage, pageSize])
const goToPage = useCallback((newPage: number) => {
setPage(Math.max(1, Math.min(newPage, totalPages)))
const goToPage = useCallback((page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)))
}, [totalPages])
const nextPage = useCallback(() => {
goToPage(page + 1)
}, [page, goToPage])
goToPage(currentPage + 1)
}, [currentPage, goToPage])
const prevPage = useCallback(() => {
goToPage(page - 1)
}, [page, goToPage])
goToPage(currentPage - 1)
}, [currentPage, goToPage])
const changePageSize = useCallback((newSize: number) => {
setPageSize(newSize)
setPage(1)
}, [])
const reset = useCallback(() => {
setCurrentPage(initialPage)
}, [initialPage])
return {
items: paginatedItems,
page,
pageSize,
total,
currentPage,
totalPages,
pageSize,
goToPage,
nextPage,
prevPage,
changePageSize,
hasNext: page < totalPages,
hasPrev: page > 1,
reset,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
startIndex: (currentPage - 1) * pageSize + 1,
endIndex: Math.min(currentPage * pageSize, items.length),
totalItems: items.length,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -1,61 +1,54 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useCallback } from 'react'
export type SortDirection = 'asc' | 'desc' | null
export type SortDirection = 'asc' | 'desc'
export interface UseSortOptions<T> {
export interface SortConfig<T> {
items: T[]
initialField?: keyof T
initialDirection?: SortDirection
defaultField?: keyof T
defaultDirection?: SortDirection
}
export function useSort<T>(options: UseSortOptions<T>) {
const { items, initialField, initialDirection = 'asc' } = options
const [field, setField] = useState<keyof T | null>(initialField || null)
const [direction, setDirection] = useState<SortDirection>(initialDirection)
export function useSort<T extends Record<string, any>>({
items,
defaultField,
defaultDirection = 'asc',
}: SortConfig<T>) {
const [sortField, setSortField] = useState<keyof T | undefined>(defaultField)
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultDirection)
const sorted = useMemo(() => {
if (!field || !direction) return items
if (!sortField) return items
return [...items].sort((a, b) => {
const aVal = a[field]
const bVal = b[field]
const aVal = a[sortField]
const bVal = b[sortField]
if (aVal == null && bVal == null) return 0
if (aVal == null) return 1
if (bVal == null) return -1
if (aVal === bVal) return 0
if (aVal < bVal) return direction === 'asc' ? -1 : 1
if (aVal > bVal) return direction === 'asc' ? 1 : -1
return 0
const comparison = aVal < bVal ? -1 : 1
return sortDirection === 'asc' ? comparison : -comparison
})
}, [items, field, direction])
}, [items, sortField, sortDirection])
const toggleSort = (newField: keyof T) => {
if (field === newField) {
setDirection(prev =>
prev === 'asc' ? 'desc' : prev === 'desc' ? null : 'asc'
)
if (direction === 'desc') {
setField(null)
}
const toggleSort = useCallback((field: keyof T) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setField(newField)
setDirection('asc')
setSortField(field)
setSortDirection('asc')
}
}
}, [sortField])
const reset = () => {
setField(null)
setDirection(null)
}
const resetSort = useCallback(() => {
setSortField(defaultField)
setSortDirection(defaultDirection)
}, [defaultField, defaultDirection])
return {
sorted,
field,
direction,
sortField,
sortDirection,
toggleSort,
setField,
setDirection,
reset,
resetSort,
}
}

View File

@@ -1,2 +1,2 @@
export * from './use-form'
export * from './use-form-field'
export { useFormField, useForm } from './use-form-field'
export type { ValidationRule, FieldConfig, FormConfig } from './use-form-field'

View File

@@ -1,33 +1,40 @@
import { useState, useCallback } from 'react'
export type ValidationRule<T> = {
export interface ValidationRule<T = any> {
validate: (value: T) => boolean
message: string
}
export function useFormField<T>(
initialValue: T,
rules: ValidationRule<T>[] = []
) {
const [value, setValue] = useState<T>(initialValue)
export interface FieldConfig<T = any> {
name: string
defaultValue?: T
rules?: ValidationRule<T>[]
}
export function useFormField<T = any>(config: FieldConfig<T>) {
const [value, setValue] = useState<T | undefined>(config.defaultValue)
const [error, setError] = useState<string | null>(null)
const [touched, setTouched] = useState(false)
const validate = useCallback(() => {
for (const rule of rules) {
if (!rule.validate(value)) {
if (!config.rules || !touched) return true
for (const rule of config.rules) {
if (!rule.validate(value as T)) {
setError(rule.message)
return false
}
}
setError(null)
return true
}, [value, rules])
}, [value, config.rules, touched])
const onChange = useCallback((newValue: T) => {
setValue(newValue)
setTouched(true)
}, [])
if (touched) {
setError(null)
}
}, [touched])
const onBlur = useCallback(() => {
setTouched(true)
@@ -35,20 +42,43 @@ export function useFormField<T>(
}, [validate])
const reset = useCallback(() => {
setValue(initialValue)
setValue(config.defaultValue)
setError(null)
setTouched(false)
}, [initialValue])
}, [config.defaultValue])
return {
value,
setValue,
onChange,
onBlur,
error,
touched,
isValid: error === null && touched,
validate,
onChange,
onBlur,
reset,
validate,
isValid: error === null,
isDirty: value !== config.defaultValue,
}
}
export interface FormConfig {
fields: Record<string, FieldConfig>
onSubmit?: (values: Record<string, any>) => void | Promise<void>
}
export function useForm(config: FormConfig) {
const [isSubmitting, setIsSubmitting] = useState(false)
const submit = useCallback(async (values: Record<string, any>) => {
setIsSubmitting(true)
try {
await config.onSubmit?.(values)
} finally {
setIsSubmitting(false)
}
}, [config])
return {
submit,
isSubmitting,
}
}

View File

@@ -15,5 +15,13 @@ export * from './ai/use-ai-generation'
export * from './data/use-seed-data'
export * from './data/use-seed-templates'
export { useKVDataSource, useComputedDataSource, useStaticDataSource, useMultipleDataSources } from './data/use-data-source'
export { useCRUD } from './data/use-crud'
export { useSearchFilter } from './data/use-search-filter'
export { useSort } from './data/use-sort'
export { usePagination } from './data/use-pagination'
export { useSelection as useDataSelection } from './data/use-selection'
export { useFormField, useForm } from './forms/use-form-field'
export * from './use-route-preload'