From 4e554607928c8e6d8779bedc9fcdf918bdc651fa Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 28 Mar 2026 22:35:44 +0100 Subject: [PATCH] feat: enhance canvas components with improved sidebar and toolbar functionality - Updated CanvasSidebar to accept canvasId as a prop, enabling dynamic content based on the current canvas. - Refactored CanvasToolbar to implement a dropdown menu for adding nodes, improving usability and organization. - Introduced new node types and updated existing ones in the node template picker for better categorization and searchability. - Enhanced AssetNode to utilize context for asset browser interactions, streamlining asset management on the canvas. - Improved overall layout and styling for better user experience across canvas components. --- app/(app)/canvas/[canvasId]/page.tsx | 4 +- components/canvas/asset-browser-panel.tsx | 39 ++- components/canvas/canvas-app-menu.tsx | 225 ++++++++++++++ .../canvas/canvas-node-template-picker.tsx | 11 +- components/canvas/canvas-sidebar.tsx | 178 ++++++++--- components/canvas/canvas-toolbar.tsx | 189 +++++++++--- components/canvas/canvas-user-menu.tsx | 80 +++++ components/canvas/canvas.tsx | 133 +++++++- components/canvas/nodes/ai-image-node.tsx | 6 +- components/canvas/nodes/asset-node.tsx | 41 ++- components/canvas/nodes/prompt-node.tsx | 2 +- components/dashboard/recent-transactions.tsx | 2 +- lib/canvas-node-catalog.ts | 284 ++++++++++++++++++ lib/canvas-node-templates.ts | 25 +- 14 files changed, 1104 insertions(+), 115 deletions(-) create mode 100644 components/canvas/canvas-app-menu.tsx create mode 100644 components/canvas/canvas-user-menu.tsx create mode 100644 lib/canvas-node-catalog.ts diff --git a/app/(app)/canvas/[canvasId]/page.tsx b/app/(app)/canvas/[canvasId]/page.tsx index 0525a1f..c382a8e 100644 --- a/app/(app)/canvas/[canvasId]/page.tsx +++ b/app/(app)/canvas/[canvasId]/page.tsx @@ -50,8 +50,8 @@ export default async function CanvasPage({ return (
- -
+ +
diff --git a/components/canvas/asset-browser-panel.tsx b/components/canvas/asset-browser-panel.tsx index 578749a..fa2a1c1 100644 --- a/components/canvas/asset-browser-panel.tsx +++ b/components/canvas/asset-browser-panel.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { createPortal } from "react-dom"; import { useAction, useMutation } from "convex/react"; import { X, Search, Loader2, AlertCircle } from "lucide-react"; @@ -35,6 +43,25 @@ export interface AssetBrowserSessionState { totalPages: number; } +/** Canvas-weit: bleibt beim Wechsel optimistic_… → echte Node-ID erhalten (sonst remount = Panel zu). */ +export type AssetBrowserTargetApi = { + targetNodeId: string | null; + openForNode: (nodeId: string) => void; + close: () => void; +}; + +export const AssetBrowserTargetContext = createContext( + null, +); + +export function useAssetBrowserTarget(): AssetBrowserTargetApi { + const v = useContext(AssetBrowserTargetContext); + if (!v) { + throw new Error("useAssetBrowserTarget must be used within AssetBrowserTargetContext.Provider"); + } + return v; +} + interface Props { nodeId: string; canvasId: string; @@ -50,7 +77,6 @@ export function AssetBrowserPanel({ initialState, onStateChange, }: Props) { - const [isMounted, setIsMounted] = useState(false); const [term, setTerm] = useState(initialState?.term ?? ""); const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? ""); const [assetType, setAssetType] = useState(initialState?.assetType ?? "photo"); @@ -69,11 +95,6 @@ export function AssetBrowserPanel({ const scrollAreaRef = useRef(null); const isSelecting = selectingAssetKey !== null; - useEffect(() => { - setIsMounted(true); - return () => setIsMounted(false); - }, []); - useEffect(() => { const timeout = setTimeout(() => { setDebouncedTerm(term); @@ -429,9 +450,5 @@ export function AssetBrowserPanel({ ], ); - if (!isMounted) { - return null; - } - return createPortal(modal, document.body); } diff --git a/components/canvas/canvas-app-menu.tsx b/components/canvas/canvas-app-menu.tsx new file mode 100644 index 0000000..6c39846 --- /dev/null +++ b/components/canvas/canvas-app-menu.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation } from "convex/react"; +import { useTheme } from "next-themes"; +import { + Monitor, + Moon, + Pencil, + Sun, + Trash2, + Menu, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { toast } from "@/lib/toast"; +import { msg } from "@/lib/toast-messages"; +import { useAuthQuery } from "@/hooks/use-auth-query"; + +type CanvasAppMenuProps = { + canvasId: Id<"canvases">; +}; + +export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) { + const router = useRouter(); + const canvas = useAuthQuery(api.canvases.get, { canvasId }); + const removeCanvas = useMutation(api.canvases.remove); + const renameCanvas = useMutation(api.canvases.update); + const { theme = "system", setTheme } = useTheme(); + + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [renameSaving, setRenameSaving] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + + useEffect(() => { + if (renameOpen && canvas?.name !== undefined) { + setRenameValue(canvas.name); + } + }, [renameOpen, canvas?.name]); + + const handleRename = async () => { + const trimmed = renameValue.trim(); + if (!trimmed) { + const { title, desc } = msg.dashboard.renameEmpty; + toast.error(title, desc); + return; + } + if (trimmed === canvas?.name) { + setRenameOpen(false); + return; + } + setRenameSaving(true); + try { + await renameCanvas({ canvasId, name: trimmed }); + toast.success(msg.dashboard.renameSuccess.title); + setRenameOpen(false); + } catch { + toast.error(msg.dashboard.renameFailed.title); + } finally { + setRenameSaving(false); + } + }; + + const handleDelete = async () => { + setDeleteBusy(true); + try { + await removeCanvas({ canvasId }); + toast.success("Projekt gelöscht"); + setDeleteOpen(false); + router.replace("/dashboard"); + router.refresh(); + } catch { + toast.error("Löschen fehlgeschlagen"); + } finally { + setDeleteBusy(false); + } + }; + + return ( + <> +
+ + + + + + { + setRenameOpen(true); + }} + > + + Projekt umbenennen + + setDeleteOpen(true)} + > + + Projekt löschen + + + + + + Erscheinungsbild + + + setTheme("light")}> + + Hell + {theme === "light" ? " ✓" : ""} + + setTheme("dark")}> + + Dunkel + {theme === "dark" ? " ✓" : ""} + + setTheme("system")}> + + System + {theme === "system" ? " ✓" : ""} + + + + + +
+ + + + + Projekt umbenennen + + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleRename(); + }} + placeholder="Name" + autoFocus + /> + + + + + + + + + + + Projekt löschen? + +

+ „{canvas?.name ?? "dieses Projekt"}“ und alle Knoten werden dauerhaft + gelöscht. Das lässt sich nicht rückgängig machen. +

+ + + + +
+
+ + ); +} diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index 5c49322..1dba897 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -1,12 +1,15 @@ "use client"; import { + FolderOpen, Frame, GitCompare, Image, + Package, Sparkles, StickyNote, Type, + Video, type LucideIcon, } from "lucide-react"; @@ -23,6 +26,9 @@ const NODE_ICONS: Record = { note: StickyNote, frame: Frame, compare: GitCompare, + group: FolderOpen, + asset: Package, + video: Video, }; const NODE_SEARCH_KEYWORDS: Partial< @@ -33,7 +39,10 @@ const NODE_SEARCH_KEYWORDS: Partial< prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"], note: ["note", "sticky", "notiz"], frame: ["frame", "artboard"], - compare: ["compare", "before", "after"], + compare: ["compare", "before", "after", "vergleich"], + group: ["group", "gruppe", "folder"], + asset: ["asset", "freepik", "stock"], + video: ["video", "pexels", "clip"], }; export type CanvasNodeTemplatePickerProps = { diff --git a/components/canvas/canvas-sidebar.tsx b/components/canvas/canvas-sidebar.tsx index 2fbd022..b8ae4c4 100644 --- a/components/canvas/canvas-sidebar.tsx +++ b/components/canvas/canvas-sidebar.tsx @@ -1,73 +1,155 @@ "use client"; -const nodeTemplates = [ - { type: "image", label: "Bild", icon: "🖼️", category: "Quelle" }, - { type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" }, - { type: "video", label: "Pexels", icon: "🎬", category: "Quelle" }, - { type: "text", label: "Text", icon: "📝", category: "Quelle" }, - { type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" }, - { type: "note", label: "Notiz", icon: "📌", category: "Layout" }, - { type: "frame", label: "Frame", icon: "🖥️", category: "Layout" }, - { type: "group", label: "Gruppe", icon: "📁", category: "Layout" }, - { type: "compare", label: "Vergleich", icon: "🔀", category: "Layout" }, -] as const; +import { + Bot, + ClipboardList, + Crop, + FolderOpen, + Frame, + GitBranch, + GitCompare, + Image, + ImageOff, + Layers, + LayoutPanelTop, + MessageSquare, + Package, + Palette, + Presentation, + Repeat, + Sparkles, + Split, + StickyNote, + Type, + Video, + Wand2, + type LucideIcon, +} from "lucide-react"; -const categories = [...new Set(nodeTemplates.map((template) => template.category))]; +import { CanvasUserMenu } from "@/components/canvas/canvas-user-menu"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { + NODE_CATEGORY_META, + NODE_CATEGORIES_ORDERED, + catalogEntriesByCategory, + isNodePaletteEnabled, + type NodeCatalogEntry, +} from "@/lib/canvas-node-catalog"; +import { cn } from "@/lib/utils"; + +const CATALOG_ICONS: Partial> = { + image: Image, + text: Type, + prompt: Sparkles, + color: Palette, + video: Video, + asset: Package, + "ai-image": Sparkles, + "ai-text": Type, + "ai-video": Video, + "agent-output": Bot, + crop: Crop, + "bg-remove": ImageOff, + upscale: Wand2, + "style-transfer": Wand2, + "face-restore": Sparkles, + splitter: Split, + loop: Repeat, + agent: Bot, + mixer: Layers, + switch: GitBranch, + group: FolderOpen, + frame: Frame, + note: StickyNote, + "text-overlay": LayoutPanelTop, + compare: GitCompare, + comment: MessageSquare, + presentation: Presentation, +}; + +function SidebarRow({ entry }: { entry: NodeCatalogEntry }) { + const enabled = isNodePaletteEnabled(entry); + const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList; -function SidebarItem({ - type, - label, - icon, -}: { - type: string; - label: string; - icon: string; -}) { const onDragStart = (event: React.DragEvent) => { - event.dataTransfer.setData("application/lemonspace-node-type", type); + if (!enabled) return; + event.dataTransfer.setData("application/lemonspace-node-type", entry.type); event.dataTransfer.effectAllowed = "move"; }; + const hint = entry.disabledHint ?? "Noch nicht verfügbar"; + return (
- {icon} - {label} + + {entry.label} + {entry.phase > 1 ? ( + + P{entry.phase} + + ) : null}
); } -export default function CanvasSidebar() { +type CanvasSidebarProps = { + canvasId: Id<"canvases">; +}; + +export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) { + const canvas = useAuthQuery(api.canvases.get, { canvasId }); + const byCategory = catalogEntriesByCategory(); + return ( -