improve node styles
This commit is contained in:
33
package-lock.json
generated
33
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -2375,6 +2376,38 @@
|
||||
"vite": "^4.1.0-beta.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.75",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
|
||||
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import React from 'react'
|
||||
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import Editor, { loader } from '@monaco-editor/react'
|
||||
import FlowContext from '../lib/flowContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeader,
|
||||
BaseNodeHeaderTitle,
|
||||
} from './base-node'
|
||||
import { Pencil } from 'lucide-react'
|
||||
|
||||
// Ensure Monaco can load its web workers under Vite by pointing to a CDN
|
||||
// This avoids the editor hanging while trying to locate worker scripts locally.
|
||||
loader.config({ paths: { vs: 'https://unpkg.com/monaco-editor@latest/min/vs' } })
|
||||
|
||||
type Props = {
|
||||
@@ -12,31 +19,31 @@ type Props = {
|
||||
data: any
|
||||
}
|
||||
|
||||
export default function ConfigNode({ id, data }: Props) {
|
||||
const [value, setValue] = React.useState<string>(data?.yaml ?? "# Enter YAML here\n")
|
||||
const ctx = React.useContext(FlowContext)
|
||||
export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
||||
const [value, setValue] = useState<string>(data?.yaml ?? '# Enter YAML here\n')
|
||||
|
||||
const ctx = useContext(FlowContext)
|
||||
const setNodes = ctx?.setNodes
|
||||
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
|
||||
const editorRef = React.useRef<any>(null)
|
||||
const monacoRef = React.useRef<any>(null)
|
||||
|
||||
const editorRef = useRef<any>(null)
|
||||
const monacoRef = useRef<any>(null)
|
||||
|
||||
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
||||
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
||||
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
||||
|
||||
React.useEffect(() => {
|
||||
// keep node data in sync on mount
|
||||
useEffect(() => {
|
||||
if (setNodes) {
|
||||
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: value } } : n)))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onChange = React.useCallback(
|
||||
const onChange = useCallback(
|
||||
(val?: string) => {
|
||||
const v = val ?? ''
|
||||
setValue(v)
|
||||
// debug log to help trace updates
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('ConfigNode:onChange', id, v.substring(0, 60))
|
||||
if (setNodes) {
|
||||
@@ -46,12 +53,12 @@ export default function ConfigNode({ id, data }: Props) {
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleEditorMount = React.useCallback((editor: any, monaco: any) => {
|
||||
const handleEditorMount = useCallback((editor: any, monaco: any) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
}, [])
|
||||
|
||||
const insertIncludeFromNode = React.useCallback(
|
||||
const insertIncludeFromNode = useCallback(
|
||||
(sourceNode: any) => {
|
||||
const ref = `${sourceNode.id}.yaml`
|
||||
const includeText = `!include ${ref}\n`
|
||||
@@ -85,16 +92,17 @@ export default function ConfigNode({ id, data }: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white rounded shadow hover:shadow-md border border-gray-200">
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{id}.yml</div>
|
||||
<div className="text-xs text-gray-400">CONFIG</div>
|
||||
</div>
|
||||
<BaseNode className="w-80">
|
||||
<BaseNodeHeader className="border-b">
|
||||
<Pencil className="size-4" />
|
||||
<BaseNodeHeaderTitle>{id}.yml</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
|
||||
<BaseNodeContent>
|
||||
{connectedConfigNodes.length > 0 && (
|
||||
<div className="px-3 py-2 text-xs bg-gray-50 border-b border-gray-100">
|
||||
<div className="text-xs font-medium text-gray-600">Connected configs</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="px-0 py-0 text-xs bg-gray-50 border-b border-gray-100 w-full">
|
||||
<div className="text-xs font-medium text-gray-600 px-3 pt-3">Connected configs</div>
|
||||
<div className="mt-1 space-y-1 px-3 pb-3">
|
||||
{connectedConfigNodes.map((n: any) => (
|
||||
<div key={n.id} className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-700">{n.data?.title ?? n.id}</div>
|
||||
@@ -112,7 +120,7 @@ export default function ConfigNode({ id, data }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 180 }}>
|
||||
<div style={{ height: 180 }} className="w-full">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="yaml"
|
||||
@@ -123,23 +131,19 @@ export default function ConfigNode({ id, data }: Props) {
|
||||
options={{ minimap: { enabled: false }, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs text-gray-500 border-t border-gray-100">
|
||||
Stored YAML: {storedYaml ? `${storedYaml.length} chars` : 'none'}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="ain"
|
||||
style={{ background: '#F97316', top: 20, width: 12, height: 12, borderRadius: 3 }}
|
||||
/>
|
||||
<BaseNodeFooter>
|
||||
<div className="w-full text-xs text-gray-500">Stored YAML: {storedYaml ? `${storedYaml.length} chars` : 'none'}</div>
|
||||
</BaseNodeFooter>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="out"
|
||||
style={{ background: '#10B981', top: 20, width: 12, height: 12, borderRadius: 3 }}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="ain" style={{ background: '#F97316', top: 20, width: 12, height: 12, borderRadius: 3 }} />
|
||||
|
||||
<Handle type="source" position={Position.Right} id="out" style={{ background: '#10B981', top: 20, width: 12, height: 12, borderRadius: 3 }} />
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ConfigNode.displayName = 'ConfigNode'
|
||||
|
||||
export default ConfigNode
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
import React from 'react'
|
||||
import { memo, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import yaml from 'js-yaml'
|
||||
import FlowContext from '../lib/flowContext'
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from './ui/empty'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeader,
|
||||
BaseNodeHeaderTitle,
|
||||
} from './base-node'
|
||||
import { Rocket } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
data: any
|
||||
data?: any
|
||||
}
|
||||
|
||||
export default function RenderingNode({ id }: Props) {
|
||||
const [output, setOutput] = React.useState<string>('')
|
||||
export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
||||
const [output, setOutput] = useState<string>('')
|
||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||
|
||||
const ctx = React.useContext(FlowContext)
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const incomingEdges = edges.filter((e: any) => e.target === id)
|
||||
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
||||
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
||||
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||
const yamlText = srcNode && srcNode.type === 'config' ? srcNode?.data?.yaml ?? '' : ''
|
||||
const srcData = srcNode?.data ?? {}
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
// debug
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('RenderingNode:selection', { id, incomingIds, srcNode: srcNode ? { id: srcNode.id, type: srcNode.type, yaml: (srcNode.data?.yaml ?? '').slice(0, 60) } : null })
|
||||
console.debug('RenderingNode:selection', {
|
||||
id,
|
||||
incomingIds,
|
||||
srcNode: srcNode ? { id: srcNode.id, type: srcNode.type, yaml: (srcNode.data?.yaml ?? '').slice(0, 60) } : null,
|
||||
})
|
||||
|
||||
if (!yamlText) {
|
||||
if (incomingIds.length === 0) {
|
||||
setOutput(`No configuration connected (nodes: ${nodes.length}, edges: ${edges.length})`)
|
||||
setOutput('')
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setOutput('No YAML found on connected configuration node')
|
||||
setOutput('')
|
||||
setError({ kind: 'no-yaml', message: 'No YAML found on connected configuration node' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// resolve !include directives by inlining YAML from reachable config nodes
|
||||
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
||||
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
||||
return text.replace(includeRegex, (match, indent, ref) => {
|
||||
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
||||
const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref
|
||||
// try to find a node by id or by title
|
||||
const refNode = nodes.find((n: any) => n.id === refName || (n.data?.title === refName))
|
||||
if (!refNode) {
|
||||
throw new Error(`Included node not found: ${ref}`)
|
||||
}
|
||||
if (visited.has(refNode.id)) {
|
||||
throw new Error(`Circular include detected: ${ref}`)
|
||||
}
|
||||
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
|
||||
if (!refNode) throw new Error(`Included node not found: ${ref}`)
|
||||
if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`)
|
||||
|
||||
// ensure refNode can reach this rendering node
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
@@ -67,19 +79,16 @@ export default function RenderingNode({ id }: Props) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isReachable(refNode.id, id)) {
|
||||
throw new Error(`Included node not connected to renderer: ${ref}`)
|
||||
}
|
||||
if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`)
|
||||
|
||||
visited.add(refNode.id)
|
||||
const includedRaw = String(refNode.data?.yaml ?? '')
|
||||
const resolved = resolveIncludes(includedRaw, visited)
|
||||
visited.delete(refNode.id)
|
||||
|
||||
// indent included content to match include position
|
||||
const indented = resolved
|
||||
.split('\n')
|
||||
.map((line: string, idx: number) => (line === '' ? '' : indent + line))
|
||||
.map((line: string) => (line === '' ? '' : indent + line))
|
||||
.join('\n')
|
||||
return indented
|
||||
})
|
||||
@@ -88,34 +97,75 @@ export default function RenderingNode({ id }: Props) {
|
||||
const resolvedYaml = resolveIncludes(yamlText)
|
||||
const parsed = yaml.load(resolvedYaml)
|
||||
const newOutput = JSON.stringify(parsed, null, 2)
|
||||
setError(null)
|
||||
setOutput((prev) => (prev === newOutput ? prev : newOutput))
|
||||
} catch (err: any) {
|
||||
const msg = 'YAML parse/include error: ' + err.message
|
||||
setOutput((prev) => (prev === msg ? prev : msg))
|
||||
setOutput('')
|
||||
setError({ kind: 'parse', message: msg })
|
||||
}
|
||||
}, [id, incomingIds.join(','), yamlText, nodes.length, edges.length])
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white rounded shadow hover:shadow-md border border-gray-200">
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{id}</div>
|
||||
<div className="text-xs text-gray-400">RENDERER</div>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-800 max-h-40 overflow-auto p-3">{output}</pre>
|
||||
<BaseNode className="w-96">
|
||||
<BaseNodeHeader className="border-b">
|
||||
<Rocket className="size-4" />
|
||||
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="ain"
|
||||
style={{ background: '#F97316', top: 20, width: 12, height: 12, borderRadius: 3 }}
|
||||
/>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="out"
|
||||
style={{ background: '#10B981', top: 20, width: 12, height: 12, borderRadius: 3 }}
|
||||
/>
|
||||
</div>
|
||||
<BaseNodeContent>
|
||||
{incomingIds.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Rocket className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
||||
<EmptyDescription>Connect a Configuration node or create one. The renderer will display parsed YAML.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<button
|
||||
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
|
||||
onClick={() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
|
||||
const nid = genId()
|
||||
const thisNode = nodes.find((n: any) => n.id === id)
|
||||
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
||||
const newPos = { x: pos.x - 220, y: pos.y }
|
||||
const newNode = { id: nid, type: 'config', position: newPos, data: { yaml: '# Enter YAML\n', title: `config-${nid}` } }
|
||||
setNodes((nds: any[]) => nds.concat(newNode))
|
||||
const edgeId = `e-${nid}-${id}`
|
||||
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
|
||||
}}
|
||||
>
|
||||
Create Config
|
||||
</button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : error ? (
|
||||
srcData?.renderError ? (
|
||||
srcData.renderError(error)
|
||||
) : srcData?.errorHtml ? (
|
||||
<div className="p-3 text-xs text-red-700" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
||||
) : (
|
||||
<div className="p-3 text-xs text-red-700">{error.message}</div>
|
||||
)
|
||||
}
|
||||
) : (
|
||||
<pre className="text-xs text-gray-800 max-h-48 overflow-auto">{output}</pre>
|
||||
)}
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<div className="w-full text-xs text-muted-foreground">Parsed YAML output</div>
|
||||
</BaseNodeFooter>
|
||||
|
||||
<Handle type="target" position={Position.Left} id="ain" style={{ background: '#F97316', top: 20, width: 12, height: 12, borderRadius: 3 }} />
|
||||
<Handle type="source" position={Position.Right} id="out" style={{ background: '#10B981', top: 20, width: 12, height: 12, borderRadius: 3 }} />
|
||||
</BaseNode>
|
||||
)
|
||||
})
|
||||
|
||||
RenderingNode.displayName = 'RenderingNode'
|
||||
|
||||
export default RenderingNode
|
||||
88
src/components/base-node.tsx
Normal file
88
src/components/base-node.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function BaseNode({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card text-card-foreground relative rounded-md border",
|
||||
"hover:ring-1",
|
||||
// React Flow displays node elements inside of a `NodeWrapper` component,
|
||||
// which compiles down to a div with the class `react-flow__node`.
|
||||
// When a node is selected, the class `selected` is added to the
|
||||
// `react-flow__node` element. This allows us to style the node when it
|
||||
// is selected, using Tailwind's `&` selector.
|
||||
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
|
||||
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
|
||||
className,
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A container for a consistent header layout intended to be used inside the
|
||||
* `<BaseNode />` component.
|
||||
*/
|
||||
export function BaseNodeHeader({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
{...props}
|
||||
className={cn(
|
||||
"mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
||||
// Remove or modify these classes if you modify the padding in the
|
||||
// `<BaseNode />` component.
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title text for the node. To maintain a native application feel, the title
|
||||
* text is not selectable.
|
||||
*/
|
||||
export function BaseNodeHeaderTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
data-slot="base-node-title"
|
||||
className={cn("user-select-none flex-1 font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseNodeContent({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-content"
|
||||
className={cn("flex flex-col gap-y-2 p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-footer"
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-y-2 border-t px-3 pt-2 pb-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/components/ui/empty.tsx
Normal file
104
src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
Reference in New Issue
Block a user