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.
This commit is contained in:
Matthias
2026-03-28 22:35:44 +01:00
parent e41d3c03b0
commit 4e55460792
14 changed files with 1104 additions and 115 deletions

View File

@@ -50,8 +50,8 @@ export default async function CanvasPage({
return ( return (
<div className="flex h-screen w-screen overflow-hidden"> <div className="flex h-screen w-screen overflow-hidden">
<CanvasSidebar /> <CanvasSidebar canvasId={typedCanvasId} />
<div className="relative flex-1"> <div className="relative min-h-0 min-w-0 flex-1">
<ConnectionBanner /> <ConnectionBanner />
<Canvas canvasId={typedCanvasId} /> <Canvas canvasId={typedCanvasId} />
</div> </div>

View File

@@ -1,6 +1,14 @@
"use client"; "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 { createPortal } from "react-dom";
import { useAction, useMutation } from "convex/react"; import { useAction, useMutation } from "convex/react";
import { X, Search, Loader2, AlertCircle } from "lucide-react"; import { X, Search, Loader2, AlertCircle } from "lucide-react";
@@ -35,6 +43,25 @@ export interface AssetBrowserSessionState {
totalPages: number; 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<AssetBrowserTargetApi | null>(
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 { interface Props {
nodeId: string; nodeId: string;
canvasId: string; canvasId: string;
@@ -50,7 +77,6 @@ export function AssetBrowserPanel({
initialState, initialState,
onStateChange, onStateChange,
}: Props) { }: Props) {
const [isMounted, setIsMounted] = useState(false);
const [term, setTerm] = useState(initialState?.term ?? ""); const [term, setTerm] = useState(initialState?.term ?? "");
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? ""); const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
const [assetType, setAssetType] = useState<AssetType>(initialState?.assetType ?? "photo"); const [assetType, setAssetType] = useState<AssetType>(initialState?.assetType ?? "photo");
@@ -69,11 +95,6 @@ export function AssetBrowserPanel({
const scrollAreaRef = useRef<HTMLDivElement | null>(null); const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const isSelecting = selectingAssetKey !== null; const isSelecting = selectingAssetKey !== null;
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setDebouncedTerm(term); setDebouncedTerm(term);
@@ -429,9 +450,5 @@ export function AssetBrowserPanel({
], ],
); );
if (!isMounted) {
return null;
}
return createPortal(modal, document.body); return createPortal(modal, document.body);
} }

View File

@@ -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 (
<>
<div className="absolute top-4 right-4 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="outline"
className="size-10 rounded-lg border-border/80 bg-card/95 shadow-md backdrop-blur-sm"
aria-label="Canvas-Menü"
title="Canvas-Menü"
>
<Menu className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
onSelect={() => {
setRenameOpen(true);
}}
>
<Pencil className="size-4" />
Projekt umbenennen
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2 className="size-4" />
Projekt löschen
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="size-4" />
Erscheinungsbild
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => setTheme("light")}>
<Sun className="size-4" />
Hell
{theme === "light" ? " ✓" : ""}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme("dark")}>
<Moon className="size-4" />
Dunkel
{theme === "dark" ? " ✓" : ""}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme("system")}>
<Monitor className="size-4" />
System
{theme === "system" ? " ✓" : ""}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>Projekt umbenennen</DialogTitle>
</DialogHeader>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRename();
}}
placeholder="Name"
autoFocus
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setRenameOpen(false)}
>
Abbrechen
</Button>
<Button
type="button"
onClick={() => void handleRename()}
disabled={renameSaving}
>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>Projekt löschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{canvas?.name ?? "dieses Projekt"} und alle Knoten werden dauerhaft
gelöscht. Das lässt sich nicht rückgängig machen.
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteBusy}
>
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import { import {
FolderOpen,
Frame, Frame,
GitCompare, GitCompare,
Image, Image,
Package,
Sparkles, Sparkles,
StickyNote, StickyNote,
Type, Type,
Video,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -23,6 +26,9 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
note: StickyNote, note: StickyNote,
frame: Frame, frame: Frame,
compare: GitCompare, compare: GitCompare,
group: FolderOpen,
asset: Package,
video: Video,
}; };
const NODE_SEARCH_KEYWORDS: Partial< const NODE_SEARCH_KEYWORDS: Partial<
@@ -33,7 +39,10 @@ const NODE_SEARCH_KEYWORDS: Partial<
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"], prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
note: ["note", "sticky", "notiz"], note: ["note", "sticky", "notiz"],
frame: ["frame", "artboard"], 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 = { export type CanvasNodeTemplatePickerProps = {

View File

@@ -1,73 +1,155 @@
"use client"; "use client";
const nodeTemplates = [ import {
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" }, Bot,
{ type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" }, ClipboardList,
{ type: "video", label: "Pexels", icon: "🎬", category: "Quelle" }, Crop,
{ type: "text", label: "Text", icon: "📝", category: "Quelle" }, FolderOpen,
{ type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" }, Frame,
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" }, GitBranch,
{ type: "frame", label: "Frame", icon: "🖥️", category: "Layout" }, GitCompare,
{ type: "group", label: "Gruppe", icon: "📁", category: "Layout" }, Image,
{ type: "compare", label: "Vergleich", icon: "🔀", category: "Layout" }, ImageOff,
] as const; 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<Record<string, LucideIcon>> = {
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) => { 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"; event.dataTransfer.effectAllowed = "move";
}; };
const hint = entry.disabledHint ?? "Noch nicht verfügbar";
return ( return (
<div <div
draggable draggable={enabled}
onDragStart={onDragStart} onDragStart={onDragStart}
className="flex cursor-grab items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent active:cursor-grabbing" title={enabled ? `${entry.label} — auf den Canvas ziehen` : hint}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
enabled
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
)}
> >
<span>{icon}</span> <Icon className="size-4 shrink-0 opacity-80" />
<span>{label}</span> <span className="min-w-0 flex-1 truncate">{entry.label}</span>
{entry.phase > 1 ? (
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
P{entry.phase}
</span>
) : null}
</div> </div>
); );
} }
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 ( return (
<aside className="flex w-56 flex-col border-r bg-background"> <aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background">
<div className="border-b px-4 py-3"> <div className="border-b border-border/80 px-4 py-4">
<h2 className="text-sm font-semibold">Nodes</h2> {canvas === undefined ? (
<p className="text-xs text-muted-foreground">Auf den Canvas ziehen</p> <div className="h-12 animate-pulse rounded-md bg-muted/50" />
) : (
<>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Canvas
</p>
<h1 className="mt-1 line-clamp-2 text-base font-semibold leading-snug text-foreground">
{canvas?.name ?? "…"}
</h1>
</>
)}
</div> </div>
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{categories.map((category) => ( {NODE_CATEGORIES_ORDERED.map((categoryId) => {
<div key={category} className="mb-4"> const entries = byCategory.get(categoryId) ?? [];
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> if (entries.length === 0) return null;
{category} const { label } = NODE_CATEGORY_META[categoryId];
</h3> return (
<div key={categoryId} className="mb-4 last:mb-0">
<h2 className="mb-2 px-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h2>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{nodeTemplates {entries.map((entry) => (
.filter((template) => template.category === category) <SidebarRow key={entry.type} entry={entry} />
.map((template) => (
<SidebarItem
key={template.type}
type={template.type}
label={template.label}
icon={template.icon}
/>
))} ))}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
<CanvasUserMenu />
</aside> </aside>
); );
} }

View File

@@ -1,68 +1,187 @@
"use client"; "use client";
import { useRef } from "react"; import { useRef } from "react";
import {
Hand,
MessageSquare,
MousePointer2,
Plus,
Redo2,
Scissors,
Undo2,
} from "lucide-react";
import { CreditDisplay } from "@/components/canvas/credit-display"; import { CreditDisplay } from "@/components/canvas/credit-display";
import { ExportButton } from "@/components/canvas/export-button"; import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position"; import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
import { Button } from "@/components/ui/button";
import { import {
CANVAS_NODE_TEMPLATES, DropdownMenu,
type CanvasNodeTemplate, DropdownMenuContent,
} from "@/lib/canvas-node-templates"; DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
NODE_CATEGORY_META,
NODE_CATEGORIES_ORDERED,
catalogEntriesByCategory,
getTemplateForCatalogType,
isNodePaletteEnabled,
type NodeCategoryId,
} from "@/lib/canvas-node-catalog";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
export type CanvasNavTool = "select" | "hand" | "scissor" | "comment";
interface CanvasToolbarProps { interface CanvasToolbarProps {
canvasName?: string; canvasName?: string;
activeTool: CanvasNavTool;
onToolChange: (tool: CanvasNavTool) => void;
} }
export default function CanvasToolbar({ export default function CanvasToolbar({
canvasName, canvasName,
activeTool,
onToolChange,
}: CanvasToolbarProps) { }: CanvasToolbarProps) {
const { createNodeWithIntersection } = useCanvasPlacement(); const { createNodeWithIntersection } = useCanvasPlacement();
const getCenteredPosition = useCenteredFlowNodePosition(); const getCenteredPosition = useCenteredFlowNodePosition();
const nodeCountRef = useRef(0); const nodeCountRef = useRef(0);
const handleAddNode = async ( const handleAddNode = async (template: CanvasNodeTemplate) => {
type: CanvasNodeTemplate["type"],
data: CanvasNodeTemplate["defaultData"],
width: number,
height: number,
) => {
const stagger = (nodeCountRef.current % 8) * 24; const stagger = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1; nodeCountRef.current += 1;
await createNodeWithIntersection({ await createNodeWithIntersection({
type, type: template.type,
position: getCenteredPosition(width, height, stagger), position: getCenteredPosition(template.width, template.height, stagger),
width, width: template.width,
height, height: template.height,
data, data: template.defaultData,
clientRequestId: crypto.randomUUID(), clientRequestId: crypto.randomUUID(),
}); });
}; };
return ( const byCategory = catalogEntriesByCategory();
<div className="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
{CANVAS_NODE_TEMPLATES.map((template) => ( const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => (
<button <Button
key={template.type}
onClick={() =>
void handleAddNode(
template.type,
template.defaultData,
template.width,
template.height,
)
}
className="rounded-lg px-3 py-1.5 text-sm transition-colors hover:bg-accent"
title={`${template.label} hinzufuegen`}
type="button" type="button"
size="icon"
variant={activeTool === tool ? "secondary" : "ghost"}
className="size-9 shrink-0"
aria-label={label}
title={label}
aria-pressed={activeTool === tool}
onClick={() => onToolChange(tool)}
> >
{template.label} {icon}
</button> </Button>
))} );
<div className="ml-1 h-6 w-px bg-border" />
return (
<div className="absolute top-4 left-1/2 z-10 flex max-w-[min(calc(100vw-12rem),52rem)] -translate-x-1/2 items-center gap-0.5 rounded-xl border border-border/80 bg-card/95 p-1.5 shadow-lg backdrop-blur-sm">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="size-9 shrink-0"
aria-label="Knoten hinzufügen"
title="Knoten hinzufügen"
>
<Plus className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-[min(24rem,70vh)] w-56 overflow-y-auto"
>
{NODE_CATEGORIES_ORDERED.map((categoryId: NodeCategoryId) => {
const entries = byCategory.get(categoryId) ?? [];
const creatable = entries.filter(isNodePaletteEnabled);
if (creatable.length === 0) return null;
return (
<div key={categoryId}>
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
{NODE_CATEGORY_META[categoryId].label}
</DropdownMenuLabel>
{creatable.map((entry) => {
const template = getTemplateForCatalogType(entry.type);
if (!template) return null;
return (
<DropdownMenuItem
key={entry.type}
onSelect={() => void handleAddNode(template)}
>
{entry.label}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
</div>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{toolBtn(
"select",
<MousePointer2 className="size-4" />,
"Auswahl (V) — schwenken: Leertaste gedrückt halten und ziehen",
)}
{toolBtn(
"hand",
<Hand className="size-4" />,
"Hand (H) — schwenken: Leertaste gedrückt halten und ziehen oder linke Maustaste",
)}
{toolBtn("scissor", <Scissors className="size-4" />, "Kanten schneiden")}
<Button
type="button"
size="icon"
variant="ghost"
className="size-9 shrink-0"
disabled
aria-label="Kommentar (folgt)"
title="Kommentar — folgt"
>
<MessageSquare className="size-4 opacity-50" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-9 shrink-0"
disabled
aria-label="Rückgängig (folgt)"
title="Rückgängig — folgt"
>
<Undo2 className="size-4 opacity-50" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-9 shrink-0"
disabled
aria-label="Wiederholen (folgt)"
title="Wiederholen — folgt"
>
<Redo2 className="size-4 opacity-50" />
</Button>
<div className="mx-1 h-6 w-px shrink-0 bg-border/80" />
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 sm:flex-initial">
<CreditDisplay /> <CreditDisplay />
<ExportButton canvasName={canvasName ?? "canvas"} /> <ExportButton canvasName={canvasName ?? "canvas"} />
</div> </div>
</div>
); );
} }

View File

@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LayoutDashboard, LogOut } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
function getInitials(nameOrEmail: string) {
const normalized = nameOrEmail.trim();
if (!normalized) return "U";
const parts = normalized.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return normalized.slice(0, 2).toUpperCase();
}
export function CanvasUserMenu() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName);
const handleSignOut = async () => {
toast.info(msg.auth.signedOut.title);
await authClient.signOut();
router.replace("/auth/sign-in");
router.refresh();
};
if (isPending && !session?.user) {
return (
<div className="border-t p-3">
<div className="h-10 animate-pulse rounded-lg bg-muted/60" />
</div>
);
}
return (
<div className="border-t border-border/80 p-3">
<div className="flex items-center gap-2.5">
<Avatar className="size-9 shrink-0 border border-border/60">
<AvatarFallback className="bg-muted text-xs font-medium text-muted-foreground">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{displayName}</p>
{session?.user.email ? (
<p className="truncate text-xs text-muted-foreground">{session.user.email}</p>
) : null}
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
<Button variant="ghost" size="sm" className="h-9 w-full justify-start" asChild>
<Link href="/dashboard">
<LayoutDashboard className="mr-2 size-4 shrink-0" />
Dashboard
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="h-9 w-full justify-start text-muted-foreground"
type="button"
onClick={() => void handleSignOut()}
>
<LogOut className="mr-2 size-4 shrink-0" />
Abmelden
</Button>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
@@ -55,13 +56,20 @@ import {
DEFAULT_ASPECT_RATIO, DEFAULT_ASPECT_RATIO,
parseAspectRatioString, parseAspectRatioString,
} from "@/lib/image-formats"; } from "@/lib/image-formats";
import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import CanvasToolbar, {
type CanvasNavTool,
} from "@/components/canvas/canvas-toolbar";
import { CanvasAppMenu } from "@/components/canvas/canvas-app-menu";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette"; import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { import {
CanvasConnectionDropMenu, CanvasConnectionDropMenu,
type ConnectionDropMenuState, type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu"; } from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import {
AssetBrowserTargetContext,
type AssetBrowserTargetApi,
} from "@/components/canvas/asset-browser-panel";
import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
@@ -765,12 +773,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
}); });
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
string | null
>(null);
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
() => ({
targetNodeId: assetBrowserTargetNodeId,
openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId),
close: () => setAssetBrowserTargetNodeId(null),
}),
[assetBrowserTargetNodeId],
);
/** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */ /** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */
const syncPendingMoveForClientRequest = useCallback( const syncPendingMoveForClientRequest = useCallback(
(clientRequestId: string | undefined, realId?: Id<"nodes">) => { (clientRequestId: string | undefined, realId?: Id<"nodes">) => {
if (!clientRequestId) return; if (!clientRequestId) return;
if (realId !== undefined) { if (realId !== undefined) {
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
setAssetBrowserTargetNodeId((current) =>
current === optimisticNodeId ? (realId as string) : current,
);
const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId); const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId);
const splitPayload = const splitPayload =
pendingEdgeSplitByClientRequestRef.current.get(clientRequestId); pendingEdgeSplitByClientRequestRef.current.get(clientRequestId);
@@ -866,6 +891,53 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const [scissorStrokePreview, setScissorStrokePreview] = useState< const [scissorStrokePreview, setScissorStrokePreview] = useState<
{ x: number; y: number }[] | null { x: number; y: number }[] | null
>(null); >(null);
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
if (tool === "scissor") {
setScissorsMode(true);
setNavTool("scissor");
return;
}
setScissorsMode(false);
setNavTool(tool);
}, []);
// Auswahl (V) / Hand (H) — ergänzt die Leertaste (Standard: panActivationKeyCode Space beim Ziehen)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableKeyboardTarget(e.target)) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : "";
if (key === "v") {
e.preventDefault();
handleNavToolChange("select");
return;
}
if (key === "h") {
e.preventDefault();
handleNavToolChange("hand");
return;
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [handleNavToolChange]);
const { flowPanOnDrag, flowSelectionOnDrag } = useMemo(() => {
const panMiddleRight: number[] = [1, 2];
if (scissorsMode) {
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: false };
}
if (navTool === "hand") {
return { flowPanOnDrag: true, flowSelectionOnDrag: false };
}
if (navTool === "comment") {
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
}
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
}, [scissorsMode, navTool]);
const edgesRef = useRef(edges); const edgesRef = useRef(edges);
edgesRef.current = edges; edgesRef.current = edges;
const scissorsModeRef = useRef(scissorsMode); const scissorsModeRef = useRef(scissorsMode);
@@ -873,6 +945,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// Drag-Lock: während des Drags kein Convex-Override // Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false); const isDragging = useRef(false);
// Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize)
const isResizing = useRef(false);
// Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen // Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen
const deletingNodeIds = useRef<Set<string>>(new Set()); const deletingNodeIds = useRef<Set<string>>(new Set());
@@ -946,7 +1020,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Convex → Lokaler State Sync ────────────────────────────── // ─── Convex → Lokaler State Sync ──────────────────────────────
useEffect(() => { useEffect(() => {
if (!convexNodes || isDragging.current) return; if (!convexNodes || isDragging.current || isResizing.current) return;
setNodes((previousNodes) => { setNodes((previousNodes) => {
const prevDataById = new Map( const prevDataById = new Map(
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]), previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
@@ -1000,6 +1074,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Node Changes (Drag, Select, Remove) ───────────────────── // ─── Node Changes (Drag, Select, Remove) ─────────────────────
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
for (const c of changes) {
if (c.type === "dimensions") {
if (c.resizing === true) {
isResizing.current = true;
} else if (c.resizing === false) {
isResizing.current = false;
}
}
}
const removedIds = new Set<string>(); const removedIds = new Set<string>();
for (const c of changes) { for (const c of changes) {
if (c.type === "remove") { if (c.type === "remove") {
@@ -1023,6 +1107,22 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
change.resizing === true || change.resizing === false; change.resizing === true || change.resizing === false;
if (node.type === "asset") { if (node.type === "asset") {
const nodeResizing = Boolean(
(node as { resizing?: boolean }).resizing,
);
const hasResizingTrueInBatch = changes.some(
(c) =>
c.type === "dimensions" &&
"id" in c &&
c.id === change.id &&
c.resizing === true,
);
if (
!isActiveResize &&
(nodeResizing || hasResizingTrueInBatch)
) {
return null;
}
if (!isActiveResize) { if (!isActiveResize) {
return change; return change;
} }
@@ -1671,6 +1771,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
deletingNodeIds.current.add(id); deletingNodeIds.current.add(id);
} }
const removedTargetSet = new Set(idsToDelete);
setAssetBrowserTargetNodeId((cur) =>
cur !== null && removedTargetSet.has(cur) ? null : cur,
);
const bridgeCreates = computeBridgeCreatesForDeletedNodes( const bridgeCreates = computeBridgeCreatesForDeletedNodes(
deletedNodes, deletedNodes,
nodes, nodes,
@@ -1805,6 +1910,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && scissorsModeRef.current) { if (e.key === "Escape" && scissorsModeRef.current) {
setScissorsMode(false); setScissorsMode(false);
setNavTool("select");
setScissorStrokePreview(null); setScissorStrokePreview(null);
return; return;
} }
@@ -1813,7 +1919,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (!k) return; if (!k) return;
if (isEditableKeyboardTarget(e.target)) return; if (isEditableKeyboardTarget(e.target)) return;
e.preventDefault(); e.preventDefault();
setScissorsMode((prev) => !prev); if (scissorsModeRef.current) {
setScissorsMode(false);
setNavTool("select");
} else {
setScissorsMode(true);
setNavTool("scissor");
}
}; };
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
@@ -1922,8 +2034,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
syncPendingMoveForClientRequest(clientRequestId, realId) syncPendingMoveForClientRequest(clientRequestId, realId)
} }
> >
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} /> <CanvasToolbar
canvasName={canvas?.name ?? "canvas"}
activeTool={navTool}
onToolChange={handleNavToolChange}
/>
<CanvasAppMenu canvasId={canvasId} />
<CanvasCommandPalette /> <CanvasCommandPalette />
<CanvasConnectionDropMenu <CanvasConnectionDropMenu
state={connectionDropMenu} state={connectionDropMenu}
@@ -1931,7 +2049,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onPick={handleConnectionDropPick} onPick={handleConnectionDropPick}
/> />
{scissorsMode ? ( {scissorsMode ? (
<div className="pointer-events-none absolute top-3 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10"> <div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "} Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "}
<span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum <span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum
Verschieben Verschieben
@@ -1990,7 +2108,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift" multiSelectionKeyCode="Shift"
nodesConnectable={!scissorsMode} nodesConnectable={!scissorsMode}
panOnDrag={scissorsMode ? [1, 2] : true} panOnDrag={flowPanOnDrag}
selectionOnDrag={flowSelectionOnDrag}
panActivationKeyCode="Space"
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"} colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")} className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
@@ -2006,6 +2126,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
</ReactFlow> </ReactFlow>
</div> </div>
</div> </div>
</AssetBrowserTargetContext.Provider>
</CanvasPlacementProvider> </CanvasPlacementProvider>
); );
} }

View File

@@ -92,7 +92,7 @@ export default function AiImageNode({
if (!canvasId) throw new Error("Missing canvasId"); if (!canvasId) throw new Error("Missing canvasId");
const prompt = nodeData.prompt; const prompt = nodeData.prompt;
if (!prompt) throw new Error("No prompt — use Generate from a KI-Bild node"); if (!prompt) throw new Error("No prompt — Generierung vom Prompt-Knoten aus starten");
const edges = getEdges(); const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === id); const incomingEdges = edges.filter((e) => e.target === id);
@@ -178,7 +178,7 @@ export default function AiImageNode({
<div className="shrink-0 border-b border-border px-3 py-2"> <div className="shrink-0 border-b border-border px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<ImageIcon className="h-3.5 w-3.5" /> <ImageIcon className="h-3.5 w-3.5" />
AI Image Bildausgabe
</div> </div>
</div> </div>
@@ -187,7 +187,7 @@ export default function AiImageNode({
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" /> <ImageIcon className="h-10 w-10 opacity-30" />
<p className="px-6 text-center text-xs opacity-60"> <p className="px-6 text-center text-xs opacity-60">
Verbinde einen KI-Bild-Knoten und starte die Generierung dort. Verbinde einen Prompt-Knoten und starte die Generierung dort.
</p> </p>
</div> </div>
)} )}

View File

@@ -1,17 +1,20 @@
"use client"; "use client";
import { import {
useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type MouseEvent, type MouseEvent,
} from "react"; } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { ExternalLink, ImageIcon } from "lucide-react"; import { ExternalLink, ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { import {
AssetBrowserPanel, AssetBrowserPanel,
useAssetBrowserTarget,
type AssetBrowserSessionState, type AssetBrowserSessionState,
} from "@/components/canvas/asset-browser-panel"; } from "@/components/canvas/asset-browser-panel";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
@@ -40,7 +43,9 @@ type AssetNodeData = {
export type AssetNodeType = Node<AssetNodeData, "asset">; export type AssetNodeType = Node<AssetNodeData, "asset">;
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) { export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
const [panelOpen, setPanelOpen] = useState(false); const { targetNodeId, openForNode, close: closeAssetBrowser } =
useAssetBrowserTarget();
const panelOpen = targetNodeId === id;
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null); const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null); const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({ const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
@@ -52,6 +57,31 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
}); });
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const edges = useStore((s) => s.edges);
const nodes = useStore((s) => s.nodes);
const linkedSearchTerm = useMemo(() => {
const incoming = edges.filter((e) => e.target === id);
for (const edge of incoming) {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (sourceNode?.type !== "text") continue;
const content = (sourceNode.data as { content?: string }).content;
if (typeof content === "string" && content.trim().length > 0) {
return content.trim();
}
}
return "";
}, [edges, id, nodes]);
const openAssetBrowser = useCallback(() => {
setBrowserState((s) =>
linkedSearchTerm
? { ...s, term: linkedSearchTerm, results: [], page: 1, totalPages: 1 }
: s,
);
openForNode(id);
}, [id, linkedSearchTerm, openForNode]);
const hasAsset = typeof data.assetId === "number"; const hasAsset = typeof data.assetId === "number";
const previewUrl = data.url ?? data.previewUrl; const previewUrl = data.url ?? data.previewUrl;
const isPreviewLoading = Boolean( const isPreviewLoading = Boolean(
@@ -143,8 +173,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
<Button <Button
size="sm" size="sm"
variant={hasAsset ? "ghost" : "default"} variant={hasAsset ? "ghost" : "default"}
className="h-6 px-2 text-xs" className="nodrag h-6 px-2 text-xs"
onClick={() => setPanelOpen(true)} onClick={openAssetBrowser}
onPointerDown={(e) => e.stopPropagation()}
type="button" type="button"
> >
{hasAsset ? "Change" : "Browse Assets"} {hasAsset ? "Change" : "Browse Assets"}
@@ -239,7 +270,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
canvasId={data.canvasId} canvasId={data.canvasId}
initialState={browserState} initialState={browserState}
onStateChange={setBrowserState} onStateChange={setBrowserState}
onClose={() => setPanelOpen(false)} onClose={closeAssetBrowser}
/> />
) : null} ) : null}

View File

@@ -311,7 +311,7 @@ export default function PromptNode({
<div className="flex h-full flex-col gap-2 p-3"> <div className="flex h-full flex-col gap-2 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400"> <div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
Eingabe KI-Bild
</div> </div>
{inputMeta.hasTextInput ? ( {inputMeta.hasTextInput ? (
<div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2"> <div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">

View File

@@ -87,7 +87,7 @@ export function RecentTransactions() {
Noch keine Aktivität Noch keine Aktivität
</p> </p>
<p className="mt-1 text-xs text-muted-foreground/70"> <p className="mt-1 text-xs text-muted-foreground/70">
Erstelle dein erstes KI-Bild im Canvas Erstelle dein erstes Bild im Canvas (Prompt-Knoten)
</p> </p>
</div> </div>
</div> </div>

284
lib/canvas-node-catalog.ts Normal file
View File

@@ -0,0 +1,284 @@
import type { Doc } from "@/convex/_generated/dataModel";
import { nodeTypes } from "@/components/canvas/node-types";
import {
CANVAS_NODE_TEMPLATES,
type CanvasNodeTemplate,
} from "@/lib/canvas-node-templates";
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
export type NodeCategoryId =
| "source"
| "ai-output"
| "transform"
| "control"
| "layout";
export const NODE_CATEGORY_META: Record<
NodeCategoryId,
{ label: string; order: number }
> = {
source: { label: "Quelle", order: 0 },
"ai-output": { label: "KI-Ausgabe", order: 1 },
transform: { label: "Transformation", order: 2 },
control: { label: "Steuerung & Flow", order: 3 },
layout: { label: "Canvas & Layout", order: 4 },
};
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order);
export type CatalogNodeType = Doc<"nodes">["type"];
export type NodeCatalogEntry = {
type: CatalogNodeType;
label: string;
category: NodeCategoryId;
phase: 1 | 2 | 3;
/** React-Flow-Komponente vorhanden. */
implemented: boolean;
/** Wird typischerweise vom KI-System erzeugt — nicht aus Palette/DnD anlegbar. */
systemOutput?: boolean;
/** Kurzer Hinweis für Tooltip (disabled). */
disabledHint?: string;
};
const REACT_FLOW_TYPES = new Set<string>(Object.keys(nodeTypes));
function entry(
partial: Omit<NodeCatalogEntry, "implemented"> & { implemented?: boolean },
): NodeCatalogEntry {
const implemented = partial.implemented ?? REACT_FLOW_TYPES.has(partial.type);
return { ...partial, implemented };
}
/**
* Vollständige Node-Taxonomie laut PRD / Convex `nodeType` (eine Zeile pro PRD-Node).
*/
export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
// Quelle
entry({
type: "image",
label: "Bild",
category: "source",
phase: 1,
}),
entry({
type: "text",
label: "Text",
category: "source",
phase: 1,
}),
entry({
type: "color",
label: "Farbe / Palette",
category: "source",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "video",
label: "Video",
category: "source",
phase: 2,
}),
entry({
type: "asset",
label: "Asset (Stock)",
category: "source",
phase: 2,
}),
// KI-Ausgabe (Prompt-Knoten: steuert Generierung, ersetzt früheres „KI-Bild“ in der Palette)
entry({
type: "prompt",
label: "KI-Bild",
category: "ai-output",
phase: 1,
}),
entry({
type: "ai-text",
label: "KI-Text",
category: "ai-output",
phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
entry({
type: "ai-video",
label: "KI-Video",
category: "ai-output",
phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
entry({
type: "agent-output",
label: "Agent-Ausgabe",
category: "ai-output",
phase: 3,
systemOutput: true,
disabledHint: "Wird vom Agenten erzeugt",
}),
// Transformation
entry({
type: "crop",
label: "Crop / Resize",
category: "transform",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "bg-remove",
label: "BG entfernen",
category: "transform",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "upscale",
label: "Upscale",
category: "transform",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "style-transfer",
label: "Style Transfer",
category: "transform",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
entry({
type: "face-restore",
label: "Gesicht",
category: "transform",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
// Steuerung & Flow
entry({
type: "splitter",
label: "Splitter",
category: "control",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "loop",
label: "Loop",
category: "control",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "agent",
label: "Agent",
category: "control",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "mixer",
label: "Mixer / Merge",
category: "control",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
entry({
type: "switch",
label: "Weiche",
category: "control",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
// Canvas & Layout
entry({
type: "group",
label: "Gruppe",
category: "layout",
phase: 1,
}),
entry({
type: "frame",
label: "Frame",
category: "layout",
phase: 1,
}),
entry({
type: "note",
label: "Notiz",
category: "layout",
phase: 1,
}),
entry({
type: "text-overlay",
label: "Text-Overlay",
category: "layout",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "compare",
label: "Vergleich",
category: "layout",
phase: 1,
}),
entry({
type: "comment",
label: "Kommentar",
category: "layout",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
entry({
type: "presentation",
label: "Präsentation",
category: "layout",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
}),
] as const;
const TEMPLATE_BY_TYPE = new Map<string, CanvasNodeTemplate>(
CANVAS_NODE_TEMPLATES.map((t) => [t.type, t]),
);
/** Sidebar / „+“: nur mit React-Flow-Typ, ohne systemOutput, mit Template. */
export function isNodePaletteEnabled(entry: NodeCatalogEntry): boolean {
if (!entry.implemented || entry.systemOutput) return false;
return TEMPLATE_BY_TYPE.has(entry.type);
}
export function getTemplateForCatalogType(
type: string,
): CanvasNodeTemplate | undefined {
return TEMPLATE_BY_TYPE.get(type);
}
export function catalogEntriesByCategory(): Map<
NodeCategoryId,
NodeCatalogEntry[]
> {
const map = new Map<NodeCategoryId, NodeCatalogEntry[]>();
for (const id of NODE_CATEGORIES_ORDERED) {
map.set(id, []);
}
for (const e of NODE_CATALOG) {
map.get(e.category)?.push(e);
}
return map;
}

View File

@@ -15,7 +15,7 @@ export const CANVAS_NODE_TEMPLATES = [
}, },
{ {
type: "prompt", type: "prompt",
label: "KI-Bild", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
@@ -36,11 +36,32 @@ export const CANVAS_NODE_TEMPLATES = [
}, },
{ {
type: "compare", type: "compare",
label: "Compare", label: "Vergleich",
width: 500, width: 500,
height: 380, height: 380,
defaultData: {}, defaultData: {},
}, },
{
type: "group",
label: "Gruppe",
width: 400,
height: 300,
defaultData: { label: "Gruppe" },
},
{
type: "asset",
label: "Asset (Stock)",
width: 260,
height: 240,
defaultData: {},
},
{
type: "video",
label: "Video",
width: 320,
height: 180,
defaultData: {},
},
] as const; ] as const;
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number]; export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];