feat(ui): add streaming state and loading skeleton support (#70)
implement short-term generative UI improvements:
- Add StreamingState type ("started" | "streaming" | "done") to ComponentContext
- Add loadingComponent field to catalog for Card, StatCard, DataTable, SchedulePreview
- Create skeleton components (CardSkeleton, StatCardSkeleton, etc.) with animate-pulse
- Add PropSkeleton component for different prop types (text, badge, table-row, etc.)
- Add useStreamingProps/useStreamingProp hooks to track prop arrival during streaming
- Add ComponentLoadingWrapper for prop-level skeleton display (opt-in via enablePropSkeletons)
- Fix JSON parse errors in API routes when request body is incomplete
Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
parent
33b427ed33
commit
337117f895
@ -15,9 +15,17 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json() as {
|
||||
action?: string
|
||||
params?: Record<string, unknown>
|
||||
let body: { action?: string; params?: Record<string, unknown> }
|
||||
try {
|
||||
body = (await req.json()) as {
|
||||
action?: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ success: false, error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { action, params } = body
|
||||
|
||||
@ -61,11 +61,21 @@ export async function POST(
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const { prompt, context } = (await req.json()) as {
|
||||
prompt: string
|
||||
context?: Record<string, unknown>
|
||||
let body: { prompt?: string; context?: Record<string, unknown> }
|
||||
try {
|
||||
body = (await req.json()) as {
|
||||
prompt?: string
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid JSON body" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
}
|
||||
|
||||
const { prompt, context } = body
|
||||
|
||||
const previousSpec = context?.previousSpec as
|
||||
| { root?: string; elements?: Record<string, unknown> }
|
||||
| undefined
|
||||
|
||||
@ -58,8 +58,14 @@ export async function POST(req: Request): Promise<Response> {
|
||||
const pluginSections = registry.getPromptSections()
|
||||
const pluginTools = registry.getTools()
|
||||
|
||||
const body = (await req.json()) as {
|
||||
messages: UIMessage[]
|
||||
let body: { messages: UIMessage[] }
|
||||
try {
|
||||
body = (await req.json()) as { messages: UIMessage[] }
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid JSON body" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
}
|
||||
|
||||
const currentPage =
|
||||
|
||||
111
src/components/agent/component-loading-wrapper.tsx
Normal file
111
src/components/agent/component-loading-wrapper.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { type ReactNode, useMemo } from "react"
|
||||
import { PropSkeleton, type PropSkeletonType } from "./prop-skeleton"
|
||||
|
||||
export interface PropConfig {
|
||||
readonly name: string
|
||||
readonly type: PropSkeletonType
|
||||
readonly required?: boolean
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface ComponentLoadingWrapperProps {
|
||||
readonly props: Record<string, unknown>
|
||||
readonly propConfigs: PropConfig[]
|
||||
readonly children: ReactNode
|
||||
readonly loading?: boolean
|
||||
readonly showSkeletonsForMissing?: boolean
|
||||
}
|
||||
|
||||
// Maps prop types to skeleton renderers
|
||||
function renderSkeleton(type: PropSkeletonType): ReactNode {
|
||||
return <PropSkeleton type={type} />
|
||||
}
|
||||
|
||||
// Wraps components and shows skeletons for props that haven't arrived yet
|
||||
export function ComponentLoadingWrapper({
|
||||
props,
|
||||
propConfigs,
|
||||
children,
|
||||
loading,
|
||||
showSkeletonsForMissing = true,
|
||||
}: ComponentLoadingWrapperProps): ReactNode {
|
||||
const { hasAllRequired, missingProps } = useMemo(() => {
|
||||
const missing: Array<{ name: string; type: PropSkeletonType }> = []
|
||||
let hasAll = true
|
||||
|
||||
for (const config of propConfigs) {
|
||||
const value = props[config.name]
|
||||
const hasValue =
|
||||
value !== undefined && value !== null && value !== ""
|
||||
|
||||
if (!hasValue) {
|
||||
if (config.required !== false) {
|
||||
hasAll = false
|
||||
}
|
||||
if (showSkeletonsForMissing) {
|
||||
missing.push({ name: config.name, type: config.type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { hasAllRequired: hasAll, missingProps: missing }
|
||||
}, [props, propConfigs, showSkeletonsForMissing])
|
||||
|
||||
// If component is in global loading state, show full skeleton
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{propConfigs.map((config) => (
|
||||
<div key={config.name}>{renderSkeleton(config.type)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If missing required props, show skeleton placeholders
|
||||
if (!hasAllRequired && showSkeletonsForMissing) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{propConfigs.map((config) => {
|
||||
const value = props[config.name]
|
||||
const hasValue =
|
||||
value !== undefined && value !== null && value !== ""
|
||||
|
||||
if (hasValue) {
|
||||
// Show the actual value if present (partial rendering)
|
||||
return <div key={config.name}>{String(value)}</div>
|
||||
}
|
||||
|
||||
return <div key={config.name}>{renderSkeleton(config.type)}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// All props available, render the component
|
||||
return children
|
||||
}
|
||||
|
||||
// Predefined prop configs for common component patterns
|
||||
export const commonPropConfigs = {
|
||||
card: [
|
||||
{ name: "title", type: "text-short" as const, required: true },
|
||||
{ name: "description", type: "text" as const },
|
||||
{ name: "badge", type: "badge" as const },
|
||||
],
|
||||
tableRow: [
|
||||
{ name: "name", type: "text-short" as const, required: true },
|
||||
{ name: "status", type: "badge" as const },
|
||||
{ name: "date", type: "date" as const },
|
||||
{ name: "value", type: "number" as const },
|
||||
],
|
||||
stat: [
|
||||
{ name: "label", type: "text-short" as const, required: true },
|
||||
{ name: "value", type: "number" as const, required: true },
|
||||
],
|
||||
userCard: [
|
||||
{ name: "name", type: "text-short" as const, required: true },
|
||||
{ name: "role", type: "badge" as const },
|
||||
{ name: "avatar", type: "avatar" as const },
|
||||
],
|
||||
}
|
||||
88
src/components/agent/prop-skeleton.tsx
Normal file
88
src/components/agent/prop-skeleton.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type PropSkeletonType =
|
||||
| "text"
|
||||
| "text-short"
|
||||
| "number"
|
||||
| "date"
|
||||
| "badge"
|
||||
| "badge-lg"
|
||||
| "avatar"
|
||||
| "table-row"
|
||||
| "card"
|
||||
| "button"
|
||||
|
||||
interface PropSkeletonProps {
|
||||
readonly type: PropSkeletonType
|
||||
readonly className?: string
|
||||
readonly count?: number
|
||||
}
|
||||
|
||||
const skeletonVariants: Record<PropSkeletonType, string> = {
|
||||
text: "h-4 w-full",
|
||||
"text-short": "h-4 w-24",
|
||||
number: "h-4 w-16",
|
||||
date: "h-4 w-28",
|
||||
badge: "h-5 w-16 rounded-full",
|
||||
"badge-lg": "h-6 w-20 rounded-full",
|
||||
avatar: "h-8 w-8 rounded-full",
|
||||
"table-row": "h-12 w-full rounded-md",
|
||||
card: "h-32 w-full rounded-lg",
|
||||
button: "h-9 w-24 rounded-md",
|
||||
}
|
||||
|
||||
export function PropSkeleton({
|
||||
type,
|
||||
className,
|
||||
count = 1,
|
||||
}: PropSkeletonProps): React.ReactNode {
|
||||
const baseClasses = skeletonVariants[type]
|
||||
|
||||
if (count > 1) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className={cn(baseClasses, className)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Skeleton className={cn(baseClasses, className)} />
|
||||
}
|
||||
|
||||
// Convenience components for common patterns
|
||||
export function TextSkeleton({ className }: { className?: string }) {
|
||||
return <PropSkeleton type="text" className={className} />
|
||||
}
|
||||
|
||||
export function BadgeSkeleton({ className }: { className?: string }) {
|
||||
return <PropSkeleton type="badge" className={className} />
|
||||
}
|
||||
|
||||
export function TableRowSkeleton({
|
||||
columns = 4,
|
||||
className,
|
||||
}: {
|
||||
columns?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4 py-3", className)}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("space-y-3 rounded-lg border p-4", className)}>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
src/hooks/use-streaming-prop.ts
Normal file
157
src/hooks/use-streaming-prop.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { useRef, useEffect, useState } from "react"
|
||||
|
||||
interface StreamingPropState {
|
||||
readonly isStreaming: boolean
|
||||
readonly hasValue: boolean
|
||||
readonly arrivedAt: number | null
|
||||
}
|
||||
|
||||
type StreamingPropsState = Record<string, StreamingPropState>
|
||||
|
||||
interface UseStreamingPropsResult {
|
||||
readonly props: StreamingPropsState
|
||||
readonly isPropStreaming: (propName: string) => boolean
|
||||
readonly hasPropValue: (propName: string) => boolean
|
||||
readonly allPropsArrived: boolean
|
||||
readonly pendingProps: string[]
|
||||
}
|
||||
|
||||
interface UseStreamingPropsOptions {
|
||||
readonly propNames?: string[]
|
||||
readonly onPropArrived?: (propName: string) => void
|
||||
readonly onAllArrived?: () => void
|
||||
}
|
||||
|
||||
// Tracks streaming state for component props
|
||||
// Useful for showing prop-level skeletons during incremental rendering
|
||||
export function useStreamingProps(
|
||||
currentProps: Record<string, unknown>,
|
||||
options: UseStreamingPropsOptions = {}
|
||||
): UseStreamingPropsResult {
|
||||
const { propNames, onPropArrived, onAllArrived } = options
|
||||
const [props, setProps] = useState<StreamingPropsState>({})
|
||||
const prevPropsRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const trackedProps = propNames ?? Object.keys(currentProps)
|
||||
const updates: StreamingPropsState = {}
|
||||
const arrivedCallbacks: string[] = []
|
||||
|
||||
for (const propName of trackedProps) {
|
||||
const currentValue = currentProps[propName]
|
||||
const prevValue = prevPropsRef.current[propName]
|
||||
const hadValue =
|
||||
prevValue !== undefined && prevValue !== null && prevValue !== ""
|
||||
const nowHasValue =
|
||||
currentValue !== undefined &&
|
||||
currentValue !== null &&
|
||||
currentValue !== ""
|
||||
|
||||
// Check if this prop just arrived
|
||||
if (!hadValue && nowHasValue) {
|
||||
updates[propName] = {
|
||||
isStreaming: false,
|
||||
hasValue: true,
|
||||
arrivedAt: Date.now(),
|
||||
}
|
||||
arrivedCallbacks.push(propName)
|
||||
} else if (hadValue && !nowHasValue) {
|
||||
// Value was removed (shouldn't normally happen, but handle it)
|
||||
updates[propName] = {
|
||||
isStreaming: false,
|
||||
hasValue: false,
|
||||
arrivedAt: null,
|
||||
}
|
||||
} else {
|
||||
// Keep existing state
|
||||
updates[propName] = {
|
||||
isStreaming: !nowHasValue,
|
||||
hasValue: nowHasValue,
|
||||
arrivedAt: props[propName]?.arrivedAt ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProps((prev) => ({ ...prev, ...updates }))
|
||||
prevPropsRef.current = { ...currentProps }
|
||||
|
||||
// Fire callbacks after state update
|
||||
for (const propName of arrivedCallbacks) {
|
||||
onPropArrived?.(propName)
|
||||
}
|
||||
|
||||
// Check if all props arrived
|
||||
const allArrived = trackedProps.every(
|
||||
(name) => updates[name]?.hasValue ?? props[name]?.hasValue
|
||||
)
|
||||
if (allArrived && arrivedCallbacks.length > 0) {
|
||||
onAllArrived?.()
|
||||
}
|
||||
}, [currentProps, propNames, onPropArrived, onAllArrived, props])
|
||||
|
||||
const isPropStreaming = (propName: string): boolean => {
|
||||
return props[propName]?.isStreaming ?? true
|
||||
}
|
||||
|
||||
const hasPropValue = (propName: string): boolean => {
|
||||
return props[propName]?.hasValue ?? false
|
||||
}
|
||||
|
||||
const pendingProps = Object.entries(props)
|
||||
.filter(([, state]) => state.isStreaming)
|
||||
.map(([name]) => name)
|
||||
|
||||
const allPropsArrived = pendingProps.length === 0
|
||||
|
||||
return {
|
||||
props,
|
||||
isPropStreaming,
|
||||
hasPropValue,
|
||||
allPropsArrived,
|
||||
pendingProps,
|
||||
}
|
||||
}
|
||||
|
||||
// Simpler hook for tracking a single prop's streaming state
|
||||
export function useStreamingProp(
|
||||
value: unknown,
|
||||
options: {
|
||||
readonly onArrived?: () => void
|
||||
} = {}
|
||||
): StreamingPropState {
|
||||
const { onArrived } = options
|
||||
const [state, setState] = useState<StreamingPropState>({
|
||||
isStreaming: true,
|
||||
hasValue: false,
|
||||
arrivedAt: null,
|
||||
})
|
||||
const prevValueRef = useRef<unknown>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const hasValue =
|
||||
value !== undefined && value !== null && value !== ""
|
||||
const hadValue =
|
||||
prevValueRef.current !== undefined &&
|
||||
prevValueRef.current !== null &&
|
||||
prevValueRef.current !== ""
|
||||
|
||||
if (!hadValue && hasValue) {
|
||||
setState({
|
||||
isStreaming: false,
|
||||
hasValue: true,
|
||||
arrivedAt: Date.now(),
|
||||
})
|
||||
onArrived?.()
|
||||
} else if (!hasValue) {
|
||||
setState({
|
||||
isStreaming: true,
|
||||
hasValue: false,
|
||||
arrivedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
prevValueRef.current = value
|
||||
}, [value, onArrived])
|
||||
|
||||
return state
|
||||
}
|
||||
@ -15,6 +15,7 @@ export const compassCatalog = defineCatalog(schema, {
|
||||
centered: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
loadingComponent: "CardSkeleton",
|
||||
description:
|
||||
"Container card for content sections. " +
|
||||
"Use as root for dashboards.",
|
||||
@ -340,6 +341,7 @@ export const compassCatalog = defineCatalog(schema, {
|
||||
change: z.number().nullable(),
|
||||
changeLabel: z.string().nullable(),
|
||||
}),
|
||||
loadingComponent: "StatCardSkeleton",
|
||||
description:
|
||||
"Single metric with optional trend indicator",
|
||||
},
|
||||
@ -371,6 +373,7 @@ export const compassCatalog = defineCatalog(schema, {
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
loadingComponent: "DataTableSkeleton",
|
||||
description:
|
||||
"Tabular data display with columns. " +
|
||||
"Best for lists of records. Use rowActions " +
|
||||
@ -429,11 +432,44 @@ export const compassCatalog = defineCatalog(schema, {
|
||||
maxTasks: z.number().nullable(),
|
||||
groupByPhase: z.boolean().nullable(),
|
||||
}),
|
||||
loadingComponent: "SchedulePreviewSkeleton",
|
||||
description:
|
||||
"Schedule/timeline display with phase grouping. " +
|
||||
"ALWAYS prefer this over composing schedule " +
|
||||
"displays from Heading+Text+Progress+Badge primitives.",
|
||||
},
|
||||
|
||||
// Loading Skeletons
|
||||
CardSkeleton: {
|
||||
props: z.object({
|
||||
hasTitle: z.boolean().nullable(),
|
||||
hasDescription: z.boolean().nullable(),
|
||||
lines: z.number().nullable(),
|
||||
}),
|
||||
description: "Loading skeleton for Card component",
|
||||
},
|
||||
|
||||
StatCardSkeleton: {
|
||||
props: z.object({
|
||||
hasChange: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Loading skeleton for StatCard component",
|
||||
},
|
||||
|
||||
DataTableSkeleton: {
|
||||
props: z.object({
|
||||
rows: z.number().nullable(),
|
||||
columns: z.number().nullable(),
|
||||
}),
|
||||
description: "Loading skeleton for DataTable component",
|
||||
},
|
||||
|
||||
SchedulePreviewSkeleton: {
|
||||
props: z.object({
|
||||
tasks: z.number().nullable(),
|
||||
}),
|
||||
description: "Loading skeleton for SchedulePreview component",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
@ -10,41 +10,87 @@ import {
|
||||
ActionProvider,
|
||||
} from "@json-render/react"
|
||||
|
||||
import { components, Fallback } from "./registry"
|
||||
import {
|
||||
components,
|
||||
Fallback,
|
||||
type StreamingState,
|
||||
} from "./registry"
|
||||
import { executeAction, actionHandlers } from "./actions"
|
||||
import {
|
||||
ComponentLoadingWrapper,
|
||||
type PropConfig,
|
||||
} from "@/components/agent/component-loading-wrapper"
|
||||
|
||||
interface CompassRendererProps {
|
||||
readonly spec: Spec | null
|
||||
readonly data?: Record<string, unknown>
|
||||
readonly loading?: boolean
|
||||
readonly streamingState?: StreamingState
|
||||
readonly propConfigs?: Record<string, PropConfig[]>
|
||||
readonly enablePropSkeletons?: boolean
|
||||
}
|
||||
|
||||
function buildRegistry(
|
||||
loading?: boolean
|
||||
loading?: boolean,
|
||||
streamingState?: StreamingState,
|
||||
propConfigs?: Record<string, PropConfig[]>,
|
||||
enablePropSkeletons?: boolean
|
||||
): ComponentRegistry {
|
||||
const registry: ComponentRegistry = {}
|
||||
|
||||
for (const [name, Component] of Object.entries(
|
||||
components
|
||||
)) {
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
registry[name] = (renderProps: {
|
||||
element: {
|
||||
props: Record<string, unknown>
|
||||
type: string
|
||||
}
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<Component
|
||||
props={renderProps.element.props as never}
|
||||
onAction={(a: {
|
||||
name: string
|
||||
params?: Record<string, unknown>
|
||||
}) => executeAction(a.name, a.params)}
|
||||
loading={loading}
|
||||
>
|
||||
{renderProps.children}
|
||||
</Component>
|
||||
)
|
||||
}) => {
|
||||
const componentProps = renderProps.element.props
|
||||
const componentPropConfigs = propConfigs?.[name]
|
||||
|
||||
// If prop-level skeletons are enabled and configs exist, wrap component
|
||||
if (
|
||||
enablePropSkeletons &&
|
||||
componentPropConfigs &&
|
||||
componentPropConfigs.length > 0
|
||||
) {
|
||||
return (
|
||||
<ComponentLoadingWrapper
|
||||
props={componentProps}
|
||||
propConfigs={componentPropConfigs}
|
||||
loading={loading}
|
||||
>
|
||||
<Component
|
||||
props={componentProps as never}
|
||||
onAction={(a: {
|
||||
name: string
|
||||
params?: Record<string, unknown>
|
||||
}) => executeAction(a.name, a.params)}
|
||||
loading={loading}
|
||||
streamingState={streamingState}
|
||||
>
|
||||
{renderProps.children}
|
||||
</Component>
|
||||
</ComponentLoadingWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard rendering without prop-level skeletons
|
||||
return (
|
||||
<Component
|
||||
props={componentProps as never}
|
||||
onAction={(a: {
|
||||
name: string
|
||||
params?: Record<string, unknown>
|
||||
}) => executeAction(a.name, a.params)}
|
||||
loading={loading}
|
||||
streamingState={streamingState}
|
||||
>
|
||||
{renderProps.children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return registry
|
||||
@ -58,10 +104,19 @@ export function CompassRenderer({
|
||||
spec,
|
||||
data,
|
||||
loading,
|
||||
streamingState,
|
||||
propConfigs,
|
||||
enablePropSkeletons = false,
|
||||
}: CompassRendererProps): ReactNode {
|
||||
const registry = useMemo(
|
||||
() => buildRegistry(loading),
|
||||
[loading]
|
||||
() =>
|
||||
buildRegistry(
|
||||
loading,
|
||||
streamingState,
|
||||
propConfigs,
|
||||
enablePropSkeletons
|
||||
),
|
||||
[loading, streamingState, propConfigs, enablePropSkeletons]
|
||||
)
|
||||
|
||||
if (!spec) return null
|
||||
|
||||
@ -47,6 +47,11 @@ type InferProps<K extends keyof CatalogComponents> =
|
||||
? P
|
||||
: never
|
||||
|
||||
export type StreamingState =
|
||||
| "started"
|
||||
| "streaming"
|
||||
| "done"
|
||||
|
||||
interface ComponentContext<K extends keyof CatalogComponents> {
|
||||
readonly props: InferProps<K>
|
||||
readonly children?: ReactNode
|
||||
@ -55,6 +60,7 @@ interface ComponentContext<K extends keyof CatalogComponents> {
|
||||
params?: Record<string, unknown>
|
||||
}) => void
|
||||
readonly loading?: boolean
|
||||
readonly streamingState?: StreamingState
|
||||
}
|
||||
|
||||
type ComponentFn<K extends keyof CatalogComponents> = (
|
||||
@ -1666,6 +1672,131 @@ export const components: {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// --- Loading Skeletons ---
|
||||
|
||||
CardSkeleton: ({ props }) => {
|
||||
const hasTitle = props.hasTitle ?? true
|
||||
const hasDescription = props.hasDescription ?? false
|
||||
const lines = props.lines ?? 3
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-xl shadow-sm p-5 bg-card overflow-hidden animate-pulse">
|
||||
{hasTitle && (
|
||||
<div className="h-4 bg-muted rounded w-1/3 mb-1" />
|
||||
)}
|
||||
{hasDescription && (
|
||||
<div className="h-3 bg-muted rounded w-2/3 mb-3" />
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 bg-muted rounded"
|
||||
style={{
|
||||
width: `${Math.random() * 40 + 60}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
StatCardSkeleton: ({ props }) => {
|
||||
const hasChange = props.hasChange ?? true
|
||||
|
||||
return (
|
||||
<div className="border border-border border-l-4 border-l-primary/30 rounded-xl shadow-sm p-4 bg-card animate-pulse">
|
||||
<div className="h-3 bg-muted rounded w-1/4 mb-2 uppercase" />
|
||||
<div className="h-9 bg-muted rounded w-1/2 mb-1" />
|
||||
{hasChange && (
|
||||
<div className="h-3 bg-muted rounded w-1/3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
DataTableSkeleton: ({ props }) => {
|
||||
const rows = props.rows ?? 5
|
||||
const columns = props.columns ?? 4
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border border-border rounded-xl shadow-sm animate-pulse">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/70">
|
||||
{Array.from({ length: columns }).map(
|
||||
(_, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-4 py-2.5"
|
||||
>
|
||||
<div className="h-3 bg-muted/50 rounded w-3/4" />
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
{Array.from({ length: columns }).map(
|
||||
(_, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className="px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-muted rounded"
|
||||
style={{
|
||||
width: `${Math.random() * 40 + 40}%`,
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
SchedulePreviewSkeleton: ({ props }) => {
|
||||
const tasks = props.tasks ?? 5
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-xl shadow-sm bg-card overflow-hidden animate-pulse">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center justify-between">
|
||||
<div className="h-4 bg-muted rounded w-1/3" />
|
||||
<div className="h-3 bg-muted rounded w-16" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: tasks }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<div className="size-2 rounded-full bg-muted shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="h-3 bg-muted rounded w-2/3" />
|
||||
<div className="h-2 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
<div className="h-2 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-3 bg-muted rounded w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export function Fallback({ type }: { readonly type: string }) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user