Compare commits
3 Commits
649d866bef
...
e441d36cf4
| Author | SHA1 | Date | |
|---|---|---|---|
| e441d36cf4 | |||
| c73aaca45a | |||
| eb1413322f |
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||||
<title>React Flow + shadcn Canvas</title>
|
<title>React Flow + shadcn Canvas</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
@@ -2827,6 +2828,15 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@remix-run/router": {
|
||||||
|
"version": "1.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
|
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -6895,6 +6905,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "6.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
|
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remix-run/router": "1.23.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "6.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||||
|
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remix-run/router": "1.23.2",
|
||||||
|
"react-router": "6.30.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|||||||
58
frontend/public/app-icon.svg
Normal file
58
frontend/public/app-icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
@@ -57,6 +57,12 @@ import {
|
|||||||
isConnectionAllowed,
|
isConnectionAllowed,
|
||||||
} from '@/lib/nodeRegistry'
|
} from '@/lib/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||||
|
import {
|
||||||
|
loadGraphFromStorage,
|
||||||
|
saveGraphToStorage,
|
||||||
|
PROJECT_FILE_EXT,
|
||||||
|
PROJECT_VERSION,
|
||||||
|
} from '@/app/platform/projectGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||||
@@ -110,9 +116,6 @@ function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_FILE_EXT = '.zui.json'
|
|
||||||
const PROJECT_VERSION = 1
|
|
||||||
|
|
||||||
export type CanvasMessage = { type: 'success' | 'error'; text: string }
|
export type CanvasMessage = { type: 'success' | 'error'; text: string }
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad() {
|
||||||
@@ -129,8 +132,19 @@ export type CanvasPageProps = {
|
|||||||
projectId?: string
|
projectId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
|
if (projectId) {
|
||||||
|
const stored = loadGraphFromStorage(projectId)
|
||||||
|
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||||
|
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getExampleGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
@@ -145,7 +159,20 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
setStateImmediate,
|
setStateImmediate,
|
||||||
} = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges)
|
} = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
|
|
||||||
|
// Persist graph to localStorage when projectId is set (debounced)
|
||||||
|
const saveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!projectId) return
|
||||||
|
const save = () => {
|
||||||
|
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
|
||||||
|
}
|
||||||
|
saveTimeoutRef.current = setTimeout(save, 500)
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [projectId, nodes, edges])
|
||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||||
@@ -217,9 +244,12 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isValidConnection = useCallback(
|
const isValidConnection = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection | AppEdge) => {
|
||||||
const sourceNode = nodes.find((n) => n.id === connection.source)
|
const src = 'source' in connection ? connection.source : undefined
|
||||||
const targetNode = nodes.find((n) => n.id === connection.target)
|
const tgt = 'target' in connection ? connection.target : undefined
|
||||||
|
if (typeof src !== 'string' || typeof tgt !== 'string') return false
|
||||||
|
const sourceNode = nodes.find((n) => n.id === src)
|
||||||
|
const targetNode = nodes.find((n) => n.id === tgt)
|
||||||
const sourceType = sourceNode?.type
|
const sourceType = sourceNode?.type
|
||||||
const targetType = targetNode?.type
|
const targetType = targetNode?.type
|
||||||
if (!sourceType || !targetType) return false
|
if (!sourceType || !targetType) return false
|
||||||
@@ -227,13 +257,16 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
const targetData = targetNode?.data as { configType?: string } | undefined
|
const targetData = targetNode?.data as { configType?: string } | undefined
|
||||||
if (targetData?.configType !== 'markdown') return false
|
if (targetData?.configType !== 'markdown') return false
|
||||||
}
|
}
|
||||||
return isConnectionAllowed(sourceType, targetType, connection.source, connection.target)
|
return isConnectionAllowed(sourceType, targetType, src, tgt)
|
||||||
},
|
},
|
||||||
[nodes]
|
[nodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onConnectStart = useCallback(
|
const onConnectStart = useCallback(
|
||||||
(_: React.MouseEvent | React.TouchEvent, params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }) => {
|
(
|
||||||
|
_: React.MouseEvent<Element> | React.TouchEvent<Element> | MouseEvent | TouchEvent,
|
||||||
|
params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }
|
||||||
|
) => {
|
||||||
if (params.handleType !== 'source' || !params.nodeId) {
|
if (params.handleType !== 'source' || !params.nodeId) {
|
||||||
setConnectionFrom(null)
|
setConnectionFrom(null)
|
||||||
return
|
return
|
||||||
@@ -371,14 +404,14 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
const createNode = useCallback(
|
const createNode = useCallback(
|
||||||
(type: string) => {
|
(type: string) => {
|
||||||
const position = getMenuPosition()
|
const position = getMenuPosition()
|
||||||
if (position == null) return
|
if (position == null || typeof type !== 'string') return
|
||||||
const nodeType = type as Node['type']
|
const existingIds = nodesRef.current.map((n) => n.id).filter((id): id is string => id != null)
|
||||||
const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id))
|
const newId = getNextNodeId(type, existingIds)
|
||||||
const dataMap = getDefaultDataForType(nodeType, newId)
|
const dataMap = getDefaultDataForType(type, newId)
|
||||||
const style = getDefaultStyle(nodeType)
|
const style = getDefaultStyle(type)
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id: newId,
|
id: newId,
|
||||||
type: nodeType,
|
type: type as Node['type'],
|
||||||
position: { x: position.x, y: position.y },
|
position: { x: position.x, y: position.y },
|
||||||
data: dataMap,
|
data: dataMap,
|
||||||
style,
|
style,
|
||||||
@@ -416,14 +449,16 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
|
const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
|
||||||
const validIds = getRegisteredNodeTypeIds()
|
const validIds = getRegisteredNodeTypeIds()
|
||||||
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||||
|
const nodeType = raw.type
|
||||||
setNodes((nds) => {
|
setNodes((nds) => {
|
||||||
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
|
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
|
||||||
|
const newId = getNextNodeId(nodeType, existingIds)
|
||||||
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
|
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
|
||||||
if (raw.type === 'config' && data && 'title' in data) data.title = `config-${newId}`
|
if (nodeType === 'config' && data && 'title' in data) data.title = `config-${newId}`
|
||||||
const style = getDefaultStyle(raw.type)
|
const style = getDefaultStyle(nodeType)
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id: newId,
|
id: newId,
|
||||||
type: raw.type as Node['type'],
|
type: nodeType as Node['type'],
|
||||||
position: { x: position.x, y: position.y },
|
position: { x: position.x, y: position.y },
|
||||||
data,
|
data,
|
||||||
style,
|
style,
|
||||||
@@ -560,7 +595,8 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
|||||||
nodesConnectable
|
nodesConnectable
|
||||||
elementsSelectable
|
elementsSelectable
|
||||||
>
|
>
|
||||||
<Background variant="dots" gap={20} />
|
{/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */}
|
||||||
|
<Background variant={'dots' as React.ComponentProps<typeof Background>['variant']} gap={20} />
|
||||||
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||||
<Controls />
|
<Controls />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Platform sidebar (sidebar-07 style): projects list, create, delete, collapse to icons.
|
* Platform sidebar (sidebar-07 style): projects list, create, delete, collapse to icons.
|
||||||
|
* Uses platform context and router for navigation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -25,26 +27,29 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { MoreHorizontal, Plus, Trash2 } from 'lucide-react'
|
import { MoreHorizontal, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import type { Project } from './types'
|
import { usePlatform } from './platformContext'
|
||||||
import { getProjectIcon } from './icon-map'
|
import { getProjectIcon } from './icon-map'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewProjectDialog } from './NewProjectDialog'
|
||||||
|
|
||||||
type AppSidebarProps = {
|
export function AppSidebar() {
|
||||||
projects: Project[]
|
|
||||||
selectedProjectId: string | null
|
|
||||||
onSelectProject: (id: string) => void
|
|
||||||
onDeleteProject: (id: string) => void
|
|
||||||
onCreateProject: (project: Project) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({
|
|
||||||
projects,
|
|
||||||
selectedProjectId,
|
|
||||||
onSelectProject,
|
|
||||||
onDeleteProject,
|
|
||||||
onCreateProject,
|
|
||||||
}: AppSidebarProps) {
|
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const { projects, createProject, deleteProject } = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
||||||
|
|
||||||
|
const handleSelectProject = (id: string) => {
|
||||||
|
navigate(`/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateProject = (project: Parameters<typeof createProject>[0]) => {
|
||||||
|
createProject(project)
|
||||||
|
navigate(`/projects/${project.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteProject = (id: string) => {
|
||||||
|
deleteProject(id)
|
||||||
|
if (selectedProjectId === id) navigate('/projects', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -52,11 +57,27 @@ export function AppSidebar({
|
|||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" tooltip="Zui" className="font-semibold">
|
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
||||||
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground text-xs">
|
<Link to="/projects">
|
||||||
Zo
|
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
|
||||||
</span>
|
<span
|
||||||
<span className="group-data-[collapsible=icon]:hidden">Zui</span>
|
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
||||||
|
style={{
|
||||||
|
maskImage: 'url(/app-icon.svg)',
|
||||||
|
maskRepeat: 'no-repeat',
|
||||||
|
maskPosition: 'center',
|
||||||
|
maskSize: 'contain',
|
||||||
|
WebkitMaskImage: 'url(/app-icon.svg)',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
WebkitMaskPosition: 'center',
|
||||||
|
WebkitMaskSize: 'contain',
|
||||||
|
backgroundColor: 'currentColor',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="font-serif group-data-[collapsible=icon]:hidden uppercase tracking-wide">ZOË</span>
|
||||||
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -73,7 +94,7 @@ export function AppSidebar({
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={project.name}
|
tooltip={project.name}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onClick={() => onSelectProject(project.id)}
|
onClick={() => handleSelectProject(project.id)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
<span>{project.name}</span>
|
<span>{project.name}</span>
|
||||||
@@ -92,7 +113,7 @@ export function AppSidebar({
|
|||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => onDeleteProject(project.id)}
|
onClick={() => handleDeleteProject(project.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
Delete project
|
Delete project
|
||||||
@@ -104,7 +125,7 @@ export function AppSidebar({
|
|||||||
})}
|
})}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewProjectDialog
|
<NewProjectDialog
|
||||||
onCreate={onCreateProject}
|
onCreate={handleCreateProject}
|
||||||
trigger={
|
trigger={
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
|
|||||||
34
frontend/src/app/platform/CanvasRoute.tsx
Normal file
34
frontend/src/app/platform/CanvasRoute.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { CanvasPage } from '../canvas/CanvasPage'
|
||||||
|
import { usePlatform } from './platformContext'
|
||||||
|
|
||||||
|
export function CanvasRoute() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { projects, updateLastEdited } = usePlatform()
|
||||||
|
|
||||||
|
const project = projects.find((p) => p.id === projectId)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) updateLastEdited(projectId)
|
||||||
|
}, [projectId, updateLastEdited])
|
||||||
|
|
||||||
|
if (!projectId) return null
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<p className="text-sm text-muted-foreground">Project not found.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<CanvasPage key={projectId} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Platform: main entry layout with sidebar (sidebar-07). Projects in sidebar;
|
* Platform: main layout with sidebar and header. Renders child routes (projects list or canvas) via Outlet.
|
||||||
* when a project is selected, the canvas is shown in the main area.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React from 'react'
|
||||||
|
import { Link, Outlet, useParams, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -14,79 +14,19 @@ import {
|
|||||||
} from '@/components/ui/breadcrumb'
|
} from '@/components/ui/breadcrumb'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { PlatformProvider, usePlatform } from './platformContext'
|
||||||
import { Plus } from 'lucide-react'
|
|
||||||
import { AppSidebar } from './AppSidebar'
|
import { AppSidebar } from './AppSidebar'
|
||||||
import { CanvasPage } from '../canvas/CanvasPage'
|
|
||||||
import type { Project } from './types'
|
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_projects'
|
function PlatformLayoutInner() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
function loadProjects(): Project[] {
|
const location = useLocation()
|
||||||
try {
|
const { projects } = usePlatform()
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const project = projectId ? projects.find((p) => p.id === projectId) : null
|
||||||
if (!raw) return []
|
const isProjectsList = location.pathname === '/projects'
|
||||||
const parsed = JSON.parse(raw) as unknown
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
return parsed.filter(
|
|
||||||
(p): p is Project =>
|
|
||||||
p &&
|
|
||||||
typeof p === 'object' &&
|
|
||||||
typeof (p as Project).id === 'string' &&
|
|
||||||
typeof (p as Project).name === 'string' &&
|
|
||||||
typeof (p as Project).iconId === 'string' &&
|
|
||||||
typeof (p as Project).createdAt === 'number'
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProjects(projects: Project[]) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlatformPage() {
|
|
||||||
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(() => {
|
|
||||||
const list = loadProjects()
|
|
||||||
return list.length > 0 ? list[0].id : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const persist = useCallback((next: Project[]) => {
|
|
||||||
setProjects(next)
|
|
||||||
saveProjects(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCreateProject = useCallback(
|
|
||||||
(project: Project) => {
|
|
||||||
persist([...projects, project])
|
|
||||||
setSelectedProjectId(project.id)
|
|
||||||
},
|
|
||||||
[projects, persist]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeleteProject = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const next = projects.filter((p) => p.id !== id)
|
|
||||||
persist(next)
|
|
||||||
if (selectedProjectId === id) setSelectedProjectId(next[0]?.id ?? null)
|
|
||||||
},
|
|
||||||
[projects, persist, selectedProjectId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar
|
<AppSidebar />
|
||||||
projects={projects}
|
|
||||||
selectedProjectId={selectedProjectId}
|
|
||||||
onSelectProject={setSelectedProjectId}
|
|
||||||
onDeleteProject={handleDeleteProject}
|
|
||||||
onCreateProject={handleCreateProject}
|
|
||||||
/>
|
|
||||||
<SidebarInset className="flex min-h-0 flex-1 flex-col">
|
<SidebarInset className="flex min-h-0 flex-1 flex-col">
|
||||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border/40 px-1 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border/40 px-1 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
@@ -94,40 +34,41 @@ export function PlatformPage() {
|
|||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem className="hidden md:block">
|
{isProjectsList ? (
|
||||||
<BreadcrumbLink href="#">Projects</BreadcrumbLink>
|
<BreadcrumbItem>
|
||||||
</BreadcrumbItem>
|
<BreadcrumbPage className="line-clamp-1">Projects</BreadcrumbPage>
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
</BreadcrumbItem>
|
||||||
<BreadcrumbItem>
|
) : (
|
||||||
<BreadcrumbPage className="line-clamp-1">
|
<>
|
||||||
{selectedProject ? selectedProject.name : 'No project'}
|
<BreadcrumbItem className="hidden md:block">
|
||||||
</BreadcrumbPage>
|
<BreadcrumbLink asChild>
|
||||||
</BreadcrumbItem>
|
<Link to="/projects">Projects</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage className="line-clamp-1">
|
||||||
|
{project ? project.name : 'Project'}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{selectedProjectId && selectedProject ? (
|
<Outlet />
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
|
||||||
<CanvasPage projectId={selectedProjectId} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 rounded-xl border border-dashed bg-muted/30 p-8">
|
|
||||||
<p className="text-sm text-muted-foreground">No project selected. Create one to open the canvas.</p>
|
|
||||||
<NewProjectDialog
|
|
||||||
onCreate={handleCreateProject}
|
|
||||||
trigger={
|
|
||||||
<Button>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
New project
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PlatformPage() {
|
||||||
|
return (
|
||||||
|
<PlatformProvider>
|
||||||
|
<PlatformLayoutInner />
|
||||||
|
</PlatformProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
269
frontend/src/app/platform/ProjectsPage.tsx
Normal file
269
frontend/src/app/platform/ProjectsPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Projects list page: table of all projects with metadata, sorted by last edited.
|
||||||
|
* Actions: open, rename, export, delete. Pagination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Pencil, Trash2, Download, FolderOpen } from 'lucide-react'
|
||||||
|
import { usePlatform } from './platformContext'
|
||||||
|
import { getProjectIcon } from './icon-map'
|
||||||
|
import {
|
||||||
|
loadGraphFromStorage,
|
||||||
|
removeGraphFromStorage,
|
||||||
|
PROJECT_FILE_EXT,
|
||||||
|
PROJECT_VERSION,
|
||||||
|
} from './projectGraphStorage'
|
||||||
|
import type { Project } from './types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
function formatDate(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const { projects, deleteProject, renameProject } = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...projects].sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
||||||
|
}, [projects])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE))
|
||||||
|
const start = page * PAGE_SIZE
|
||||||
|
const pageItems = sorted.slice(start, start + PAGE_SIZE)
|
||||||
|
|
||||||
|
const handleOpen = (projectId: string) => {
|
||||||
|
navigate(`/projects/${projectId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameOpen = (project: Project) => {
|
||||||
|
setRenameTarget(project)
|
||||||
|
setRenameValue(project.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameSubmit = () => {
|
||||||
|
if (renameTarget && renameValue.trim()) {
|
||||||
|
renameProject(renameTarget.id, renameValue.trim())
|
||||||
|
setRenameTarget(null)
|
||||||
|
setRenameValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteOpen = (project: Project) => {
|
||||||
|
setDeleteTarget(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
removeGraphFromStorage(deleteTarget.id)
|
||||||
|
deleteProject(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
navigate('/projects', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (project: Project) => {
|
||||||
|
const stored = loadGraphFromStorage(project.id)
|
||||||
|
const state = stored
|
||||||
|
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
|
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||||
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Projects</h2>
|
||||||
|
</div>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No projects yet. Create one from the sidebar.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Created</TableHead>
|
||||||
|
<TableHead>Last edited</TableHead>
|
||||||
|
<TableHead className="w-[70px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pageItems.map((project) => {
|
||||||
|
const Icon = getProjectIcon(project.iconId)
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleOpen(project.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
|
{formatDate(project.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatDate(project.lastEditedAt ?? project.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
Open
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport(project)}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Export
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => handleDeleteOpen(project)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename dialog */}
|
||||||
|
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename project</DialogTitle>
|
||||||
|
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRenameSubmit()}
|
||||||
|
placeholder="Project name"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete project</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deleteTarget
|
||||||
|
? `Are you sure you want to delete "${deleteTarget.name}"? This cannot be undone.`
|
||||||
|
: ''}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/app/platform/platformContext.tsx
Normal file
108
frontend/src/app/platform/platformContext.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Platform context: projects list and handlers for create/delete/rename/updateLastEdited.
|
||||||
|
* Used by AppSidebar, ProjectsTablePage, and canvas route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||||
|
import type { Project } from './types'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'zui_platform_projects'
|
||||||
|
|
||||||
|
function loadProjects(): Project[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(p): p is Project =>
|
||||||
|
p &&
|
||||||
|
typeof p === 'object' &&
|
||||||
|
typeof (p as Project).id === 'string' &&
|
||||||
|
typeof (p as Project).name === 'string' &&
|
||||||
|
typeof (p as Project).iconId === 'string' &&
|
||||||
|
typeof (p as Project).createdAt === 'number'
|
||||||
|
)
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
lastEditedAt: typeof (p as Project).lastEditedAt === 'number' ? (p as Project).lastEditedAt : p.createdAt,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProjects(projects: Project[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformContextValue = {
|
||||||
|
projects: Project[]
|
||||||
|
persist: (next: Project[]) => void
|
||||||
|
createProject: (project: Project) => void
|
||||||
|
deleteProject: (id: string) => void
|
||||||
|
renameProject: (id: string, name: string) => void
|
||||||
|
updateLastEdited: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlatformContext = createContext<PlatformContextValue | null>(null)
|
||||||
|
|
||||||
|
export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
||||||
|
|
||||||
|
const persist = useCallback((next: Project[]) => {
|
||||||
|
setProjects(next)
|
||||||
|
saveProjects(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createProject = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
||||||
|
persist([...projects, withEdited])
|
||||||
|
},
|
||||||
|
[projects, persist]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteProject = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const next = projects.filter((p) => p.id !== id)
|
||||||
|
persist(next)
|
||||||
|
},
|
||||||
|
[projects, persist]
|
||||||
|
)
|
||||||
|
|
||||||
|
const renameProject = useCallback(
|
||||||
|
(id: string, name: string) => {
|
||||||
|
const next = projects.map((p) => (p.id === id ? { ...p, name } : p))
|
||||||
|
persist(next)
|
||||||
|
},
|
||||||
|
[projects, persist]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateLastEdited = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const next = projects.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
|
||||||
|
persist(next)
|
||||||
|
},
|
||||||
|
[projects, persist]
|
||||||
|
)
|
||||||
|
|
||||||
|
const value: PlatformContextValue = {
|
||||||
|
projects,
|
||||||
|
persist,
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
renameProject,
|
||||||
|
updateLastEdited,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlatform() {
|
||||||
|
const ctx = useContext(PlatformContext)
|
||||||
|
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
36
frontend/src/app/platform/projectGraphStorage.ts
Normal file
36
frontend/src/app/platform/projectGraphStorage.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Per-project graph persistence (localStorage).
|
||||||
|
* Used by CanvasPage to load/save and by ProjectsTablePage for export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PROJECT_FILE_EXT = '.zui.json'
|
||||||
|
export const PROJECT_VERSION = 1
|
||||||
|
|
||||||
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
|
|
||||||
|
export function getGraphStorageKey(projectId: string): string {
|
||||||
|
return `${GRAPH_KEY_PREFIX}${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GraphState = { version: number; nodes: unknown[]; edges: unknown[] }
|
||||||
|
|
||||||
|
export function loadGraphFromStorage(projectId: string): GraphState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getGraphStorageKey(projectId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray((data as GraphState).nodes) || !Array.isArray((data as GraphState).edges))
|
||||||
|
return null
|
||||||
|
return data as GraphState
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGraphToStorage(projectId: string, state: GraphState): void {
|
||||||
|
localStorage.setItem(getGraphStorageKey(projectId), JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGraphFromStorage(projectId: string): void {
|
||||||
|
localStorage.removeItem(getGraphStorageKey(projectId))
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export type Project = {
|
|||||||
/** Icon identifier: key of PROJECT_ICONS map */
|
/** Icon identifier: key of PROJECT_ICONS map */
|
||||||
iconId: string
|
iconId: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
/** Last time the project was opened/edited; used for sorting. Defaults to createdAt if missing. */
|
||||||
|
lastEditedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project icon ids: Lucide "Animals" category only */
|
/** Project icon ids: Lucide "Animals" category only */
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function AnimatedEdge({
|
|||||||
const nodes = ctx?.nodes ?? []
|
const nodes = ctx?.nodes ?? []
|
||||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||||
const derivedLabel = useMemo(
|
const derivedLabel = useMemo(
|
||||||
() => getConnectionLabelForTarget(targetNode?.type),
|
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
|
||||||
[targetNode?.type]
|
[targetNode?.type]
|
||||||
)
|
)
|
||||||
const label = labelProp ?? derivedLabel
|
const label = labelProp ?? derivedLabel
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { ComponentProps } from "react";
|
|
||||||
import { Handle, type HandleProps } from "@xyflow/react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type BaseHandleProps = HandleProps;
|
|
||||||
|
|
||||||
export function BaseHandle({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentProps<typeof Handle>) {
|
|
||||||
return (
|
|
||||||
<Handle
|
|
||||||
{...props}
|
|
||||||
className={cn(
|
|
||||||
"dark:border-secondary dark:bg-secondary h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Handle>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,7 @@ export function FlowKeyboardShortcuts() {
|
|||||||
}
|
}
|
||||||
const validIds = getRegisteredNodeTypeIds()
|
const validIds = getRegisteredNodeTypeIds()
|
||||||
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||||
|
const nodeType = raw.type
|
||||||
const pane = document.querySelector('.react-flow__viewport')
|
const pane = document.querySelector('.react-flow__viewport')
|
||||||
const rect = pane?.getBoundingClientRect()
|
const rect = pane?.getBoundingClientRect()
|
||||||
const center = rect
|
const center = rect
|
||||||
@@ -41,13 +42,14 @@ export function FlowKeyboardShortcuts() {
|
|||||||
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
const position = screenToFlowPosition(center)
|
const position = screenToFlowPosition(center)
|
||||||
setNodes((nds: Node[]) => {
|
setNodes((nds: Node[]) => {
|
||||||
const newId = getNextNodeId(raw.type, nds.map((n) => n.id))
|
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
|
||||||
|
const newId = getNextNodeId(nodeType, existingIds)
|
||||||
const data: Record<string, unknown> =
|
const data: Record<string, unknown> =
|
||||||
raw.data != null && typeof raw.data === 'object'
|
raw.data != null && typeof raw.data === 'object'
|
||||||
? { ...raw.data }
|
? { ...raw.data }
|
||||||
: (getDefaultDataForType(raw.type, newId) as Record<string, unknown>)
|
: (getDefaultDataForType(nodeType, newId) as Record<string, unknown>)
|
||||||
if (raw.type === 'config') data.title = `config-${newId}`
|
if (nodeType === 'config') data.title = `config-${newId}`
|
||||||
const style = getDefaultStyle(raw.type)
|
const style = getDefaultStyle(nodeType)
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id: newId,
|
id: newId,
|
||||||
type: raw.type as Node['type'],
|
type: raw.type as Node['type'],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function InputHandle({ id, nodeId }: NodeHandleProps) {
|
|||||||
isConnecting &&
|
isConnecting &&
|
||||||
isValidConnection?.({
|
isValidConnection?.({
|
||||||
source: connectionFrom!.nodeId,
|
source: connectionFrom!.nodeId,
|
||||||
sourceHandle: connectionFrom!.sourceHandle ?? undefined,
|
sourceHandle: connectionFrom!.sourceHandle ?? null,
|
||||||
target: nodeId!,
|
target: nodeId!,
|
||||||
targetHandle: id,
|
targetHandle: id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import type { AppNode } from '../../lib/nodeTypes'
|
||||||
import { replaceNodeIdInGraph } from '../../lib/flowUtils'
|
import { replaceNodeIdInGraph } from '../../lib/flowUtils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -41,7 +42,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
|
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
|
||||||
setNodes(nextNodes)
|
setNodes(nextNodes as AppNode[])
|
||||||
setEdges(nextEdges)
|
setEdges(nextEdges)
|
||||||
setRenamingNodeId(null)
|
setRenamingNodeId(null)
|
||||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
|
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
|
||||||
style: getDefaultStyle(nodeType),
|
style: getDefaultStyle(nodeType),
|
||||||
}
|
}
|
||||||
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
|
if (nodeType === 'config' && newNode.data && typeof newNode.data === 'object' && 'title' in newNode.data) {
|
||||||
|
(newNode.data as { title: string }).title = `${newId}`
|
||||||
|
}
|
||||||
return nds.concat(newNode)
|
return nds.concat(newNode)
|
||||||
})
|
})
|
||||||
}, [node, nodeType, setNodes])
|
}, [node, nodeType, setNodes])
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
|||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '../base/NodeMenubar'
|
||||||
|
|
||||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
|
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
|
||||||
|
|
||||||
type Props = AbstractNodeProps<ConfigNodeData>
|
type Props = AbstractNodeProps<ConfigNodeData>
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const incomingIds = sourceIds
|
const incomingIds = sourceIds
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : 'plantuml'
|
||||||
const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : ''
|
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : ''
|
||||||
const srcData = srcNode?.data ?? {}
|
const srcData = srcNode?.data ?? {}
|
||||||
|
|
||||||
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
||||||
@@ -94,7 +94,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
if (!node) return
|
if (!node) return
|
||||||
visited.add(nodeId)
|
visited.add(nodeId)
|
||||||
out.add(nodeId)
|
out.add(nodeId)
|
||||||
const content = getConfigContent(node.data)
|
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
for (const ref of getTemplateRefs(content)) {
|
for (const ref of getTemplateRefs(content)) {
|
||||||
const refId = resolveRef(ref)
|
const refId = resolveRef(ref)
|
||||||
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
|
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
|
||||||
@@ -223,7 +223,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||||
visited.add(refId)
|
visited.add(refId)
|
||||||
configIdsUsed.add(refId)
|
configIdsUsed.add(refId)
|
||||||
const content = getConfigContent(node.data)
|
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
if (refId !== srcId && !isReachable(refId, id))
|
if (refId !== srcId && !isReachable(refId, id))
|
||||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||||
return {
|
return {
|
||||||
src: getConfigContent(node.data),
|
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
|
||||||
path: name,
|
path: name,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -338,7 +338,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
for (const fid of functionIdsToRegister) {
|
for (const fid of functionIdsToRegister) {
|
||||||
const src = nodes.find((n: any) => n.id === fid)
|
const src = nodes.find((n: any) => n.id === fid)
|
||||||
if (!src || src.type !== 'function') continue
|
if (!src || src.type !== 'function') continue
|
||||||
const body = src.data?.body ?? 'return args[0];'
|
const body = (src.data as { body?: string } | undefined)?.body ?? 'return args[0];'
|
||||||
const parsed = parseFunctionSignature(body)
|
const parsed = parseFunctionSignature(body)
|
||||||
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
||||||
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
||||||
@@ -432,7 +432,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
|
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
if (cancelled || thisRunId !== runIdRef.current) return
|
||||||
if (nunjucksErr) {
|
if (nunjucksErr) {
|
||||||
setSvgContent(null)
|
setRenderedContent(null)
|
||||||
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -677,10 +677,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
srcData?.renderError ? (
|
(srcData as { renderError?: (err: { kind: string; message: string }) => React.ReactNode; errorHtml?: string })?.renderError ? (
|
||||||
srcData.renderError(error)
|
(srcData as { renderError: (err: { kind: string; message: string }) => React.ReactNode }).renderError(error)
|
||||||
) : srcData?.errorHtml ? (
|
) : (srcData as { errorHtml?: string })?.errorHtml ? (
|
||||||
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String((srcData as { errorHtml: string }).errorHtml) }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
|
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
|
||||||
@@ -706,7 +706,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
minScale={0.2}
|
minScale={0.2}
|
||||||
maxScale={4}
|
maxScale={4}
|
||||||
centerOnInit
|
centerOnInit
|
||||||
onInit={(ref) => ref?.centerView(1, 0, 0)}
|
onInit={(ref) => ref?.centerView(1, 200, 'easeOut')}
|
||||||
panning={{ disabled: true }}
|
panning={{ disabled: true }}
|
||||||
wheel={{ disabled: true }}
|
wheel={{ disabled: true }}
|
||||||
doubleClick={{ disabled: true }}
|
doubleClick={{ disabled: true }}
|
||||||
|
|||||||
120
frontend/src/components/ui/table.tsx
Normal file
120
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import React, { useCallback, useContext, useMemo } from 'react'
|
import React, { useCallback, useContext, useMemo } from 'react'
|
||||||
import FlowContext from './flowContext'
|
import FlowContext from './flowContext'
|
||||||
import { nodePropsAreEqual } from './flowUtils'
|
import { nodePropsAreEqual } from './flowUtils'
|
||||||
|
import type { AppNode } from './nodeTypes'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -74,10 +75,10 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
(partial: Partial<TData>) => {
|
(partial: Partial<TData>) => {
|
||||||
if (!setNodes) return
|
if (!setNodes) return
|
||||||
setNodes((nds: FlowNode[]) =>
|
setNodes((nds: AppNode[]) =>
|
||||||
nds.map((n) =>
|
nds.map((n) =>
|
||||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||||
)
|
) as AppNode[]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
@@ -105,7 +106,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
data,
|
data,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
setNodes: setNodes ?? (() => {}),
|
setNodes: ((setNodes ?? (() => {})) as AbstractNodeContext<TData>['setNodes']),
|
||||||
setEdges: setEdges ?? (() => {}),
|
setEdges: setEdges ?? (() => {}),
|
||||||
updateData,
|
updateData,
|
||||||
incomingEdges,
|
incomingEdges,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
getIdPrefix,
|
getIdPrefix,
|
||||||
getDefaultDataForType as getDefaultDataFromRegistry,
|
getDefaultDataForType as getDefaultDataFromRegistry,
|
||||||
getResetDataForType as getResetDataFromRegistry,
|
getResetDataForType as getResetDataFromRegistry,
|
||||||
getDefaultStyle,
|
|
||||||
} from './nodeRegistry'
|
} from './nodeRegistry'
|
||||||
|
|
||||||
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||||
@@ -24,14 +23,6 @@ export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: n
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use getIdPrefix from nodeRegistry for new code. Kept for compatibility. */
|
|
||||||
export const PREFIX_BY_TYPE: Record<string, string> = {
|
|
||||||
config: 'cfg_',
|
|
||||||
render: 'rnd_',
|
|
||||||
variable: 'var_',
|
|
||||||
function: 'fn_',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
|
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
|
||||||
export function getNextNodeId(type: string, existingIds: string[]): string {
|
export function getNextNodeId(type: string, existingIds: string[]): string {
|
||||||
const prefix = getIdPrefix(type)
|
const prefix = getIdPrefix(type)
|
||||||
@@ -91,7 +82,3 @@ export function getResetDataForType(type: string, nodeId?: string): any {
|
|||||||
return getResetDataFromRegistry(type, nodeId)
|
return getResetDataFromRegistry(type, nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default style for a type. Uses nodeRegistry when type is registered. */
|
|
||||||
export function getDefaultStyleForType(type: string): { width: number; height: number } {
|
|
||||||
return getDefaultStyle(type)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { StreamLanguage } from '@codemirror/language'
|
import { StreamLanguage } from '@codemirror/language'
|
||||||
|
|
||||||
/** Nunjucks block comment {# ... #} */
|
/** Nunjucks block comment {# ... #} */
|
||||||
function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
function tokenNunjucksComment(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||||
if (stream.match(/^\{#/)) {
|
if (stream.match(/^\{#/)) {
|
||||||
while (!stream.eol()) {
|
while (!stream.eol()) {
|
||||||
if (stream.match(/#\}/)) return 'comment'
|
if (stream.match(/#\}/)) return 'comment'
|
||||||
@@ -13,7 +13,7 @@ function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; ne
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
||||||
function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
function tokenNunjucksBlock(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||||
if (stream.match(/^\{\{/)) {
|
if (stream.match(/^\{\{/)) {
|
||||||
while (!stream.eol()) {
|
while (!stream.eol()) {
|
||||||
if (stream.match(/\}\}/)) return 'variableName.special'
|
if (stream.match(/\}\}/)) return 'variableName.special'
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||||
import { PlatformPage } from './app/platform/PlatformPage'
|
import { PlatformPage } from './app/platform/PlatformPage'
|
||||||
|
import { ProjectsPage } from './app/platform/ProjectsPage'
|
||||||
|
import { CanvasRoute } from './app/platform/CanvasRoute'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
@@ -11,7 +14,15 @@ registerBuiltinNodes()
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PlatformPage />
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PlatformPage />}>
|
||||||
|
<Route index element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
18
frontend/src/nunjucks.d.ts
vendored
Normal file
18
frontend/src/nunjucks.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module 'nunjucks' {
|
||||||
|
export interface Loader {
|
||||||
|
getSource(name: string): { src: string; path: string } | null
|
||||||
|
}
|
||||||
|
export interface Environment {
|
||||||
|
render(name: string, context: Record<string, unknown>, callback: (err: Error | null, res: string) => void): void
|
||||||
|
addFilter(name: string, fn: (...args: unknown[]) => void, async?: boolean): void
|
||||||
|
getFilter(name: string): (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
export class Environment {
|
||||||
|
constructor(loaders?: Loader[], opts?: { autoescape?: boolean })
|
||||||
|
render(name: string, context: Record<string, unknown>, callback: (err: Error | null, res: string) => void): void
|
||||||
|
addFilter(name: string, fn: (...args: unknown[]) => void, async?: boolean): void
|
||||||
|
getFilter(name: string): (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
const nunjucks: { Environment: typeof Environment }
|
||||||
|
export default nunjucks
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user