diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dcc5888..ccca5f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,8 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@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", "@uiw/react-codemirror": "^4.25.7", "@wireweave/core": "^2.6.0", @@ -29,11 +31,13 @@ "clsx": "^2.1.1", "lucide-react": "^0.577.0", "marked": "^17.0.4", + "next-themes": "^0.4.6", "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.28.0", "react-zoom-pan-pinch": "^3.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "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": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -6134,6 +6192,16 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7462,6 +7530,16 @@ "dev": true, "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": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c9bf33d..ae0cf6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,8 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@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", "@uiw/react-codemirror": "^4.25.7", "@wireweave/core": "^2.6.0", @@ -29,11 +31,13 @@ "clsx": "^2.1.1", "lucide-react": "^0.577.0", "marked": "^17.0.4", + "next-themes": "^0.4.6", "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.28.0", "react-zoom-pan-pinch": "^3.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" }, diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index c35f97f..60e1da2 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -57,6 +57,7 @@ import { isConnectionAllowed, } from '@/lib/nodeRegistry' import type { AppNode, AppEdge } from '@/lib/nodeTypes' +import { toast } from 'sonner' import { loadGraphFromStorage, saveGraphToStorage, @@ -116,8 +117,6 @@ function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } { } } -export type CanvasMessage = { type: 'success' | 'error'; text: string } - function FlowFitViewOnLoad() { const nodesInitialized = useNodesInitialized() const { fitView } = useReactFlow() @@ -178,7 +177,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) - const [canvasMessage, setCanvasMessage] = React.useState(null) const wrapperRef = useRef(null) const lastClickRef = useRef<{ clientX: number; clientY: number } | 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), []) - React.useEffect(() => { - if (!canvasMessage) return - const t = setTimeout(() => setCanvasMessage(null), 3000) - return () => clearTimeout(t) - }, [canvasMessage]) - const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd]) const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd]) @@ -297,7 +289,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { a.download = `project${PROJECT_FILE_EXT}` a.click() URL.revokeObjectURL(url) - setCanvasMessage({ type: 'success', text: 'Project exported' }) + toast.success('Project exported') }, [nodes, edges]) const handleImportProject = useCallback(() => importInputRef.current?.click(), []) @@ -305,7 +297,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const handleLoadExample = useCallback(() => { const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph() setStateImmediate({ nodes: exampleNodes, edges: exampleEdges }) - setCanvasMessage({ type: 'success', text: 'Example loaded' }) + toast.success('Example loaded') }, [setStateImmediate]) const onImportFileChange = useCallback( @@ -319,17 +311,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const text = reader.result as string const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] } 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 } setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] }) - setCanvasMessage( - state.version != null && state.version > PROJECT_VERSION - ? { type: 'error', text: 'Project was created with a newer app version' } - : { type: 'success', text: 'Project loaded' } - ) + if (state.version != null && state.version > PROJECT_VERSION) { + toast.error('Project was created with a newer app version') + } else { + toast.success('Project loaded') + } } catch { - setCanvasMessage({ type: 'error', text: 'Invalid file: not valid JSON' }) + toast.error('Invalid file: not valid JSON') } } reader.readAsText(file) @@ -517,17 +509,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) { Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'} )} - {canvasMessage && ( -
- {canvasMessage.text} -
- )}
diff --git a/frontend/src/app/platform/ProjectsPage.tsx b/frontend/src/app/platform/ProjectsPage.tsx index 98606cd..e8aa01c 100644 --- a/frontend/src/app/platform/ProjectsPage.tsx +++ b/frontend/src/app/platform/ProjectsPage.tsx @@ -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. */ @@ -30,6 +30,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' 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 { ChevronLeft, @@ -43,6 +44,9 @@ import { FolderOpen, Search, Plus, + LayoutGrid, + List, + Network, } from 'lucide-react' import { usePlatform } from './platformContext' import { getProjectIcon } from './icon-map' @@ -52,7 +56,7 @@ import { PROJECT_FILE_EXT, PROJECT_VERSION, } from './projectGraphStorage' -import { useToast } from '@/components/ui/toast' +import { toast } from 'sonner' import { NewProjectDialog } from './NewProjectDialog' 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 ( +
+ + No graph +
+ ) + } + + 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 ( + + + + + + + + + {validEdges.slice(0, 50).map((e, i) => { + const a = nodeById.get(e.source)!.position! + const b = nodeById.get(e.target)!.position! + return ( + + ) + })} + {withPos.slice(0, 80).map((n) => ( + + ))} + + ) +} + +export type ViewMode = 'table' | 'cards' + export function ProjectsPage() { const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform() const navigate = useNavigate() - const { addToast } = useToast() + const [viewMode, setViewMode] = useState('cards') const [search, setSearch] = useState('') const [pageSize, setPageSize] = useState(10) const [page, setPage] = useState(0) @@ -185,11 +278,14 @@ export function ProjectsPage() { deleteProject(project.id) setDeleteTarget(null) navigate('/projects', { replace: true }) - addToast(`"${project.name}" deleted`, { - undo: () => restoreProject(project, snapshot), + toast(`"${project.name}" deleted`, { + action: { + label: 'Undo', + onClick: () => restoreProject(project, snapshot), + }, duration: 8000, }) - }, [deleteTarget, deleteProject, navigate, addToast, restoreProject]) + }, [deleteTarget, deleteProject, navigate, restoreProject]) const handleExport = useCallback( (project: Project) => { @@ -205,9 +301,9 @@ export function ProjectsPage() { a.download = filename a.click() URL.revokeObjectURL(url) - addToast(`Exported as ${filename}`) + toast.success(`Exported as ${filename}`) }, - [addToast] + [] ) const handleCreateProject = useCallback( @@ -221,23 +317,47 @@ export function ProjectsPage() { return (
+
+

+ Hi, David +

+
-
-

Projects

-
-
- - { - setSearch(e.target.value) - setPage(0) - }} - className="pl-8" - aria-label="Search projects by name" - /> -
+
+
+ + { + setSearch(e.target.value) + setPage(0) + }} + className="h-9 w-full pl-8" + aria-label="Search projects by name" + /> +
+
+ 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" + > + + + Cards + + + + Table + + +
+