feat: integrate Radix UI toggle components and replace custom toast implementation with Sonner
- Added @radix-ui/react-toggle and @radix-ui/react-toggle-group to package dependencies. - Implemented ToggleGroup and Toggle components for improved UI interaction. - Replaced custom toast notifications with Sonner for better user feedback. - Updated CanvasPage and ProjectsPage to utilize new toast notifications. - Removed obsolete toast context and provider. - Enhanced ProjectsPage with a toggle view for project display (cards/table).
This commit is contained in:
78
frontend/package-lock.json
generated
78
frontend/package-lock.json
generated
@@ -21,6 +21,8 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@uiw/react-codemirror": "^4.25.7",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
@@ -29,11 +31,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"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-router-dom": "^6.28.0",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
@@ -2593,6 +2597,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle-group": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
@@ -6134,6 +6192,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -7462,6 +7530,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@uiw/react-codemirror": "^4.25.7",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
@@ -29,11 +31,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"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-router-dom": "^6.28.0",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
isConnectionAllowed,
|
isConnectionAllowed,
|
||||||
} from '@/lib/nodeRegistry'
|
} from '@/lib/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
loadGraphFromStorage,
|
loadGraphFromStorage,
|
||||||
saveGraphToStorage,
|
saveGraphToStorage,
|
||||||
@@ -116,8 +117,6 @@ function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasMessage = { type: 'success' | 'error'; text: string }
|
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad() {
|
||||||
const nodesInitialized = useNodesInitialized()
|
const nodesInitialized = useNodesInitialized()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
@@ -178,7 +177,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
||||||
const [canvasMessage, setCanvasMessage] = React.useState<CanvasMessage | null>(null)
|
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||||
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
|
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
|
||||||
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
|
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
|
||||||
@@ -279,12 +277,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
|
|
||||||
const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])
|
const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!canvasMessage) return
|
|
||||||
const t = setTimeout(() => setCanvasMessage(null), 3000)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [canvasMessage])
|
|
||||||
|
|
||||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||||||
|
|
||||||
@@ -297,7 +289,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
a.download = `project${PROJECT_FILE_EXT}`
|
a.download = `project${PROJECT_FILE_EXT}`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
setCanvasMessage({ type: 'success', text: 'Project exported' })
|
toast.success('Project exported')
|
||||||
}, [nodes, edges])
|
}, [nodes, edges])
|
||||||
|
|
||||||
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
|
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
|
||||||
@@ -305,7 +297,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const handleLoadExample = useCallback(() => {
|
const handleLoadExample = useCallback(() => {
|
||||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||||
setCanvasMessage({ type: 'success', text: 'Example loaded' })
|
toast.success('Example loaded')
|
||||||
}, [setStateImmediate])
|
}, [setStateImmediate])
|
||||||
|
|
||||||
const onImportFileChange = useCallback(
|
const onImportFileChange = useCallback(
|
||||||
@@ -319,17 +311,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const text = reader.result as string
|
const text = reader.result as string
|
||||||
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
||||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
||||||
setCanvasMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
|
toast.error('Invalid file: expected nodes and edges arrays')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||||
setCanvasMessage(
|
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||||
state.version != null && state.version > PROJECT_VERSION
|
toast.error('Project was created with a newer app version')
|
||||||
? { type: 'error', text: 'Project was created with a newer app version' }
|
} else {
|
||||||
: { type: 'success', text: 'Project loaded' }
|
toast.success('Project loaded')
|
||||||
)
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setCanvasMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
|
toast.error('Invalid file: not valid JSON')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
@@ -517,17 +509,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
|
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canvasMessage && (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
className={`shrink-0 px-3 py-2 text-sm ${canvasMessage.type === 'success'
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{canvasMessage.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<FlowContext.Provider value={flowContextValue}>
|
<FlowContext.Provider value={flowContextValue}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Projects list page: table with search, sort, pagination, actions.
|
* Projects list page: table or cards view with search, sort, pagination, actions.
|
||||||
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -43,6 +44,9 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Network,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from './platformContext'
|
||||||
import { getProjectIcon } from './icon-map'
|
import { getProjectIcon } from './icon-map'
|
||||||
@@ -52,7 +56,7 @@ import {
|
|||||||
PROJECT_FILE_EXT,
|
PROJECT_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
} from './projectGraphStorage'
|
} from './projectGraphStorage'
|
||||||
import { useToast } from '@/components/ui/toast'
|
import { toast } from 'sonner'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewProjectDialog } from './NewProjectDialog'
|
||||||
import type { Project } from './types'
|
import type { Project } from './types'
|
||||||
|
|
||||||
@@ -94,10 +98,99 @@ function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeLike = { id: string; position?: { x: number; y: number } }
|
||||||
|
type EdgeLike = { id?: string; source: string; target: string }
|
||||||
|
|
||||||
|
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
||||||
|
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
||||||
|
const stored = loadGraphFromStorage(projectId)
|
||||||
|
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
||||||
|
const edges = (stored?.edges ?? []) as EdgeLike[]
|
||||||
|
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
||||||
|
|
||||||
|
const dotGridPatternId = `dotgrid-${projectId.replace(/\W/g, '-')}`
|
||||||
|
|
||||||
|
if (withPos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)',
|
||||||
|
backgroundSize: '8px 8px',
|
||||||
|
backgroundColor: 'hsl(var(--muted) / 0.5)',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Network className="size-8" />
|
||||||
|
<span className="text-[10px]">No graph</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 12
|
||||||
|
const xs = withPos.map((n) => n.position!.x)
|
||||||
|
const ys = withPos.map((n) => n.position!.y)
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
const w = Math.max(maxX - minX, 1)
|
||||||
|
const h = Math.max(maxY - minY, 1)
|
||||||
|
const scale = Math.min((200 - padding * 2) / w, (120 - padding * 2) / h, 8)
|
||||||
|
const ox = padding - minX * scale
|
||||||
|
const oy = padding - minY * scale
|
||||||
|
|
||||||
|
const nodeById = new Map(withPos.map((n) => [n.id, n]))
|
||||||
|
const validEdges = edges.filter((e) => nodeById.has(e.source) && nodeById.has(e.target))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`h-full w-full ${className ?? ''}`}
|
||||||
|
viewBox="0 0 200 120"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern id={dotGridPatternId} width={8} height={8} patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx={1} cy={1} r={0.6} fill="hsl(var(--muted-foreground) / 0.2)" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width={200} height={120} fill="hsl(var(--muted) / 0.4)" />
|
||||||
|
<rect width={200} height={120} fill={`url(#${dotGridPatternId})`} />
|
||||||
|
{validEdges.slice(0, 50).map((e, i) => {
|
||||||
|
const a = nodeById.get(e.source)!.position!
|
||||||
|
const b = nodeById.get(e.target)!.position!
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={e.id ?? `e-${i}`}
|
||||||
|
x1={ox + a.x * scale}
|
||||||
|
y1={oy + a.y * scale}
|
||||||
|
x2={ox + b.x * scale}
|
||||||
|
y2={oy + b.y * scale}
|
||||||
|
stroke="hsl(var(--muted-foreground) / 0.4)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{withPos.slice(0, 80).map((n) => (
|
||||||
|
<circle
|
||||||
|
key={n.id}
|
||||||
|
cx={ox + n.position!.x * scale}
|
||||||
|
cy={oy + n.position!.y * scale}
|
||||||
|
r={3}
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToast } = useToast()
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [pageSize, setPageSize] = useState<number>(10)
|
const [pageSize, setPageSize] = useState<number>(10)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
@@ -185,11 +278,14 @@ export function ProjectsPage() {
|
|||||||
deleteProject(project.id)
|
deleteProject(project.id)
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
navigate('/projects', { replace: true })
|
navigate('/projects', { replace: true })
|
||||||
addToast(`"${project.name}" deleted`, {
|
toast(`"${project.name}" deleted`, {
|
||||||
undo: () => restoreProject(project, snapshot),
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => restoreProject(project, snapshot),
|
||||||
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
})
|
})
|
||||||
}, [deleteTarget, deleteProject, navigate, addToast, restoreProject])
|
}, [deleteTarget, deleteProject, navigate, restoreProject])
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
(project: Project) => {
|
(project: Project) => {
|
||||||
@@ -205,9 +301,9 @@ export function ProjectsPage() {
|
|||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
addToast(`Exported as ${filename}`)
|
toast.success(`Exported as ${filename}`)
|
||||||
},
|
},
|
||||||
[addToast]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCreateProject = useCallback(
|
const handleCreateProject = useCallback(
|
||||||
@@ -221,12 +317,15 @@ export function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance">
|
||||||
|
Hi, David
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-3 sm:items-center">
|
||||||
<h2 className="text-lg font-semibold">Projects</h2>
|
<div className="relative w-64 shrink-0 sm:justify-self-start">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
<div className="relative flex-1 sm:w-48">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search projects"
|
placeholder="Search projects"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -234,10 +333,31 @@ export function ProjectsPage() {
|
|||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setPage(0)
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
className="pl-8"
|
className="h-9 w-full pl-8"
|
||||||
aria-label="Search projects by name"
|
aria-label="Search projects by name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-self-start sm:justify-self-center">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={viewMode}
|
||||||
|
onValueChange={(v) => v && setViewMode(v as ViewMode)}
|
||||||
|
aria-label="View mode"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="cards" aria-label="Cards view" className="gap-1.5 px-2.5">
|
||||||
|
<LayoutGrid className="size-3.5" />
|
||||||
|
Cards
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="table" aria-label="Table view" className="gap-1.5 px-2.5">
|
||||||
|
<List className="size-3.5" />
|
||||||
|
Table
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-9 items-center gap-2 sm:justify-self-end">
|
||||||
<Select
|
<Select
|
||||||
value={pageSize === -1 ? 'all' : String(pageSize)}
|
value={pageSize === -1 ? 'all' : String(pageSize)}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -245,7 +365,7 @@ export function ProjectsPage() {
|
|||||||
setPage(0)
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[100px]">
|
<SelectTrigger className="h-9 w-[120px]">
|
||||||
<SelectValue placeholder="Page size" />
|
<SelectValue placeholder="Page size" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -261,30 +381,43 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed bg-muted/20 py-12">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{search.trim() ? 'No projects match your search.' : 'No projects yet. Create one to get started.'}
|
|
||||||
</p>
|
|
||||||
{!search.trim() && (
|
|
||||||
<NewProjectDialog
|
<NewProjectDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateProject}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedProjects.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<Button>
|
<div
|
||||||
<Plus className="size-4" />
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||||
New project
|
role="button"
|
||||||
</Button>
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.currentTarget as HTMLElement).click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Create new project"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
|
||||||
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
|
<span className="font-medium text-muted-foreground">New project</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{search.trim() ? 'No projects match your search.' : 'Create a new project'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === 'table' ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table className="table-fixed">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
<TableRow className="hover:bg-transparent border-b">
|
<TableRow className="hover:bg-transparent border-b h-8">
|
||||||
<TableHead>
|
<TableHead className="w-[35%] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center font-medium hover:underline"
|
className="flex items-center font-medium hover:underline"
|
||||||
@@ -295,7 +428,7 @@ export function ProjectsPage() {
|
|||||||
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden sm:table-cell w-[100px]">
|
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
@@ -306,8 +439,8 @@ export function ProjectsPage() {
|
|||||||
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell w-[90px]">Size</TableHead>
|
<TableHead className="hidden md:table-cell w-[100px] min-w-[80px] h-8 px-2 py-1.5 text-xs">Size</TableHead>
|
||||||
<TableHead className="w-[140px]">
|
<TableHead className="w-[115px] min-w-[90px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
@@ -318,10 +451,10 @@ export function ProjectsPage() {
|
|||||||
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="sticky right-0 z-10 w-[70px] bg-card" />
|
<TableHead className="sticky right-0 z-10 w-[52px] min-w-[52px] bg-card h-8 px-1 py-1.5 text-xs" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody className="text-xs">
|
||||||
{pageItems.map((project) => {
|
{pageItems.map((project) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getProjectIcon(project.iconId)
|
||||||
const counts = getGraphCounts(project.id)
|
const counts = getGraphCounts(project.id)
|
||||||
@@ -329,7 +462,7 @@ export function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50 h-8"
|
||||||
onClick={() => handleOpen(project.id)}
|
onClick={() => handleOpen(project.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -341,28 +474,30 @@ export function ProjectsPage() {
|
|||||||
role="button"
|
role="button"
|
||||||
aria-label={`Open ${project.name}`}
|
aria-label={`Open ${project.name}`}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="px-2 py-1.5 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium">{project.name}</span>
|
<span className="font-medium truncate">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
||||||
|
<span className="truncate block" title={formatDate(project.createdAt)}>
|
||||||
{formatDate(project.createdAt)}
|
{formatDate(project.createdAt)}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
|
||||||
{counts.nodes} nodes, {counts.edges} edges
|
{counts.nodes}n, {counts.edges}e
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground px-2 py-1.5 w-[115px] min-w-[90px]">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>{getRelativeTime(lastEdited)}</span>
|
<span className="truncate block">{getRelativeTime(lastEdited)}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="sticky right-0 bg-card"
|
className="sticky right-0 bg-card w-[52px] min-w-[52px] px-1 py-1.5"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -370,7 +505,7 @@ export function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-8"
|
className="size-7"
|
||||||
aria-label={`Actions for ${project.name}`}
|
aria-label={`Actions for ${project.name}`}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
@@ -406,6 +541,158 @@ export function ProjectsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="First page"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
aria-label="Last page"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<NewProjectDialog
|
||||||
|
onCreate={handleCreateProject}
|
||||||
|
existingNames={orderedProjects.map((p) => p.name)}
|
||||||
|
trigger={
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
; (e.currentTarget as HTMLElement).click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Create new project"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
|
||||||
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
|
<span className="font-medium text-muted-foreground">New project</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Create a new project</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{pageItems.map((project) => {
|
||||||
|
const Icon = getProjectIcon(project.iconId)
|
||||||
|
const lastEdited = project.lastEditedAt ?? project.createdAt
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
onClick={() => handleOpen(project.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpen(project.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Open ${project.name}`}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
||||||
|
<GraphThumbnail projectId={project.id} className="h-full w-full object-cover" />
|
||||||
|
<div
|
||||||
|
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 rounded-full shadow-sm"
|
||||||
|
aria-label={`Actions for ${project.name}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-xs text-muted-foreground">{getRelativeTime(lastEdited)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Simple toast: message + optional Undo action. Auto-dismisses after a delay.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export type ToastItem = {
|
|
||||||
id: string
|
|
||||||
message: string
|
|
||||||
undo?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToastContextValue = {
|
|
||||||
toasts: ToastItem[]
|
|
||||||
addToast: (message: string, options?: { undo?: () => void; duration?: number }) => void
|
|
||||||
removeToast: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
||||||
|
|
||||||
const TOAST_DURATION = 5000
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
|
||||||
const timersRef = React.useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
|
||||||
|
|
||||||
const removeToast = useCallback((id: string) => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
||||||
const t = timersRef.current[id]
|
|
||||||
if (t) {
|
|
||||||
clearTimeout(t)
|
|
||||||
delete timersRef.current[id]
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addToast = useCallback(
|
|
||||||
(message: string, options?: { undo?: () => void; duration?: number }) => {
|
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
const duration = options?.duration ?? TOAST_DURATION
|
|
||||||
setToasts((prev) => [...prev, { id, message, undo: options?.undo }])
|
|
||||||
const timer = setTimeout(() => removeToast(id), duration)
|
|
||||||
timersRef.current[id] = timer
|
|
||||||
},
|
|
||||||
[removeToast]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
|
||||||
{children}
|
|
||||||
<ToastViewport toasts={toasts} removeToast={removeToast} />
|
|
||||||
</ToastContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToastViewport({
|
|
||||||
toasts,
|
|
||||||
removeToast,
|
|
||||||
}: {
|
|
||||||
toasts: ToastItem[]
|
|
||||||
removeToast: (id: string) => void
|
|
||||||
}) {
|
|
||||||
if (toasts.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed bottom-4 right-4 z-[100] flex max-w-[420px] flex-col gap-2"
|
|
||||||
role="region"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.id}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-between gap-3 rounded-lg border bg-background px-4 py-3 text-sm shadow-lg',
|
|
||||||
'animate-in slide-in-from-bottom-2 fade-in-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex-1">{t.message}</span>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
{t.undo && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
|
||||||
onClick={() => {
|
|
||||||
t.undo?.()
|
|
||||||
removeToast(t.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => removeToast(t.id)}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToast() {
|
|
||||||
const ctx = useContext(ToastContext)
|
|
||||||
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
61
frontend/src/components/ui/toggle-group.tsx
Normal file
61
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToggleGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, children, ...props }, ref) => (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center justify-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
))
|
||||||
|
|
||||||
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ToggleGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
import { ToastProvider } from './components/ui/toast'
|
|
||||||
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 { ProjectsPage } from './app/platform/ProjectsPage'
|
||||||
@@ -15,7 +15,6 @@ registerBuiltinNodes()
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ToastProvider>
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PlatformPage />}>
|
<Route path="/" element={<PlatformPage />}>
|
||||||
@@ -25,7 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ToastProvider>
|
<Toaster richColors position="bottom-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user