feat: implement Convex-synced canvas foundation
This commit is contained in:
794
.docs/Canvas_Implementation_Guide.md
Normal file
794
.docs/Canvas_Implementation_Guide.md
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
# 🍋 LemonSpace — Canvas Implementation Guide
|
||||||
|
|
||||||
|
**Schritte 1–3: Basis-Canvas mit Convex-Sync**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Das Convex-Backend ist deployed und folgende Funktionen existieren bereits:
|
||||||
|
|
||||||
|
- `api.nodes.list` (Query, benötigt `canvasId`)
|
||||||
|
- `api.nodes.create` (Mutation)
|
||||||
|
- `api.nodes.move` (Mutation, benötigt `nodeId` + `position`)
|
||||||
|
- `api.nodes.resize` (Mutation)
|
||||||
|
- `api.nodes.batchMove` (Mutation)
|
||||||
|
- `api.nodes.updateData` (Mutation)
|
||||||
|
- `api.nodes.updateStatus` (Mutation)
|
||||||
|
- `api.nodes.remove` (Mutation)
|
||||||
|
- `api.edges.list` (Query, benötigt `canvasId`)
|
||||||
|
- `api.edges.create` (Mutation)
|
||||||
|
- `api.edges.remove` (Mutation)
|
||||||
|
- `api.canvases.list`, `api.canvases.get`, `api.canvases.create`
|
||||||
|
|
||||||
|
Auth via Better Auth + `@convex-dev/better-auth` ist funktionsfähig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 0 — Package-Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @xyflow/react
|
||||||
|
```
|
||||||
|
|
||||||
|
> **dnd-kit** wird erst in einem späteren Schritt benötigt (Sidebar → Canvas Drag). Für Schritt 1–3 reicht @xyflow/react allein — das bringt Drag & Drop von bestehenden Nodes bereits mit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 — Dateistruktur anlegen
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
canvas/
|
||||||
|
canvas.tsx ← Haupt-Canvas (ReactFlow + Convex-Sync)
|
||||||
|
canvas-toolbar.tsx ← Toolbar oben (Node hinzufügen, Zoom)
|
||||||
|
node-types.ts ← nodeTypes-Map (AUSSERHALB jeder Komponente!)
|
||||||
|
nodes/
|
||||||
|
image-node.tsx ← Bild-Node (Upload/URL)
|
||||||
|
text-node.tsx ← Freitext (Markdown)
|
||||||
|
prompt-node.tsx ← Prompt für KI-Nodes
|
||||||
|
ai-image-node.tsx ← KI-Bild-Output
|
||||||
|
group-node.tsx ← Container/Gruppe
|
||||||
|
frame-node.tsx ← Artboard/Export-Boundary
|
||||||
|
note-node.tsx ← Annotation
|
||||||
|
compare-node.tsx ← Slider-Vergleich
|
||||||
|
base-node-wrapper.tsx ← Shared Wrapper (Border, Selection-Ring, Status)
|
||||||
|
|
||||||
|
app/
|
||||||
|
(app)/
|
||||||
|
canvas/
|
||||||
|
[canvasId]/
|
||||||
|
page.tsx ← Canvas-Page (Server Component, Auth-Check)
|
||||||
|
|
||||||
|
lib/
|
||||||
|
canvas-utils.ts ← Hilfsfunktionen (Convex → RF Mapping)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 — Custom Node Components
|
||||||
|
|
||||||
|
### 2.1 Base Node Wrapper
|
||||||
|
|
||||||
|
Jeder Node teilt sich visuelle Grundeigenschaften: Border, Selection-Ring, Status-Anzeige. Das kapseln wir in einem Wrapper.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/base-node-wrapper.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface BaseNodeWrapperProps {
|
||||||
|
selected?: boolean;
|
||||||
|
status?: 'idle' | 'executing' | 'done' | 'error';
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BaseNodeWrapper({
|
||||||
|
selected,
|
||||||
|
status = 'idle',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: BaseNodeWrapperProps) {
|
||||||
|
const statusStyles = {
|
||||||
|
idle: '',
|
||||||
|
executing: 'animate-pulse border-yellow-400',
|
||||||
|
done: 'border-green-500',
|
||||||
|
error: 'border-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
rounded-xl border bg-card shadow-sm transition-shadow
|
||||||
|
${selected ? 'ring-2 ring-primary shadow-md' : ''}
|
||||||
|
${statusStyles[status]}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Note Node (einfachster Node — guter Startpunkt)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/note-node.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type NoteNodeData = {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteNode = Node<NoteNodeData, 'note'>;
|
||||||
|
|
||||||
|
export default function NoteNode({ data, selected }: NodeProps<NoteNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-52 p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">📌 Notiz</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{data.content || 'Leere Notiz'}
|
||||||
|
</p>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Image Node
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/image-node.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type ImageNodeData = {
|
||||||
|
storageId?: string;
|
||||||
|
url?: string;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageNode = Node<ImageNodeData, 'image'>;
|
||||||
|
|
||||||
|
export default function ImageNode({ data, selected }: NodeProps<ImageNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="p-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">🖼️ Bild</div>
|
||||||
|
{data.url ? (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={data.filename ?? 'Bild'}
|
||||||
|
className="rounded-lg object-cover max-w-[280px]"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
|
||||||
|
Bild hochladen oder URL einfügen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Text Node
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/text-node.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type TextNodeData = {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextNode = Node<TextNodeData, 'text'>;
|
||||||
|
|
||||||
|
export default function TextNode({ data, selected }: NodeProps<TextNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-64 p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">📝 Text</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap min-h-[2rem]">
|
||||||
|
{data.content || 'Text eingeben…'}
|
||||||
|
</p>
|
||||||
|
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Prompt Node
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/prompt-node.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type PromptNodeData = {
|
||||||
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptNode = Node<PromptNodeData, 'prompt'>;
|
||||||
|
|
||||||
|
export default function PromptNode({ data, selected }: NodeProps<PromptNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-72 p-3 border-purple-500/30">
|
||||||
|
<div className="text-xs font-medium text-purple-500 mb-1">✨ Prompt</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap min-h-[2rem]">
|
||||||
|
{data.prompt || 'Prompt eingeben…'}
|
||||||
|
</p>
|
||||||
|
{data.model && (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Modell: {data.model}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Nur Source — verbindet sich ausschließlich mit KI-Nodes */}
|
||||||
|
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500 !border-2 !border-background" />
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 AI Image Node
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/ai-image-node.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type AiImageNodeData = {
|
||||||
|
url?: string;
|
||||||
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
status?: 'idle' | 'executing' | 'done' | 'error';
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiImageNode = Node<AiImageNodeData, 'ai-image'>;
|
||||||
|
|
||||||
|
export default function AiImageNode({ data, selected }: NodeProps<AiImageNode>) {
|
||||||
|
const status = data.status ?? 'idle';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} status={status} className="p-2">
|
||||||
|
<div className="text-xs font-medium text-emerald-500 mb-1">🤖 KI-Bild</div>
|
||||||
|
|
||||||
|
{status === 'executing' && (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'done' && data.url && (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={data.prompt ?? 'KI-generiertes Bild'}
|
||||||
|
className="rounded-lg object-cover max-w-[280px]"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 dark:bg-red-950/20 text-sm text-red-600">
|
||||||
|
{data.error ?? 'Fehler bei der Generierung'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'idle' && (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
|
||||||
|
Prompt verbinden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.prompt && status === 'done' && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground truncate max-w-[280px]">
|
||||||
|
{data.prompt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target: Empfängt Input von Prompt/Bild */}
|
||||||
|
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-emerald-500 !border-2 !border-background" />
|
||||||
|
{/* Source: Output weitergeben (an Compare, Frame, etc.) */}
|
||||||
|
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Platzhalter für Group, Frame, Compare
|
||||||
|
|
||||||
|
Diese sind komplexer (Group braucht `expandParent`, Frame braucht Resize, Compare braucht Slider). Für Schritt 1–3 reichen einfache Platzhalter:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/group-node.tsx
|
||||||
|
'use client';
|
||||||
|
import { type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type GroupNode = Node<{ label?: string }, 'group'>;
|
||||||
|
|
||||||
|
export default function GroupNode({ data, selected }: NodeProps<GroupNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="min-w-[200px] min-h-[150px] p-3 border-dashed">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">📁 {data.label || 'Gruppe'}</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/frame-node.tsx
|
||||||
|
'use client';
|
||||||
|
import { type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type FrameNode = Node<{ label?: string; resolution?: string }, 'frame'>;
|
||||||
|
|
||||||
|
export default function FrameNode({ data, selected }: NodeProps<FrameNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="min-w-[300px] min-h-[200px] p-3 border-blue-500/30">
|
||||||
|
<div className="text-xs font-medium text-blue-500">
|
||||||
|
🖥️ {data.label || 'Frame'} {data.resolution && `(${data.resolution})`}
|
||||||
|
</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/nodes/compare-node.tsx
|
||||||
|
'use client';
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
|
import BaseNodeWrapper from './base-node-wrapper';
|
||||||
|
|
||||||
|
export type CompareNode = Node<{ leftUrl?: string; rightUrl?: string }, 'compare'>;
|
||||||
|
|
||||||
|
export default function CompareNode({ data, selected }: NodeProps<CompareNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-[500px] p-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">🔀 Vergleich</div>
|
||||||
|
<div className="flex gap-2 h-40">
|
||||||
|
<div className="flex-1 rounded bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
||||||
|
{data.leftUrl ? <img src={data.leftUrl} className="rounded object-cover h-full w-full" /> : 'Bild A'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 rounded bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
||||||
|
{data.rightUrl ? <img src={data.rightUrl} className="rounded object-cover h-full w-full" /> : 'Bild B'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary !border-2 !border-background" style={{ top: '40%' }} />
|
||||||
|
<Handle type="target" position={Position.Left} id="right" className="!h-3 !w-3 !bg-primary !border-2 !border-background" style={{ top: '60%' }} />
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 — nodeTypes registrieren
|
||||||
|
|
||||||
|
**Kritisch:** Diese Map muss AUSSERHALB jeder React-Komponente definiert werden. Wenn sie innerhalb einer Komponente liegt, erstellt React bei jedem Render ein neues Objekt → React Flow re-rendert alle Nodes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/node-types.ts
|
||||||
|
import ImageNode from './nodes/image-node';
|
||||||
|
import TextNode from './nodes/text-node';
|
||||||
|
import PromptNode from './nodes/prompt-node';
|
||||||
|
import AiImageNode from './nodes/ai-image-node';
|
||||||
|
import GroupNode from './nodes/group-node';
|
||||||
|
import FrameNode from './nodes/frame-node';
|
||||||
|
import NoteNode from './nodes/note-node';
|
||||||
|
import CompareNode from './nodes/compare-node';
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
image: ImageNode,
|
||||||
|
text: TextNode,
|
||||||
|
prompt: PromptNode,
|
||||||
|
'ai-image': AiImageNode,
|
||||||
|
group: GroupNode,
|
||||||
|
frame: FrameNode,
|
||||||
|
note: NoteNode,
|
||||||
|
compare: CompareNode,
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 — Convex ↔ React Flow Mapping
|
||||||
|
|
||||||
|
Das Herzstück: Convex-Daten in React Flow-Format transformieren und Änderungen zurückschreiben.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/canvas-utils.ts
|
||||||
|
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
|
||||||
|
import type { Doc } from '@/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformiert einen Convex-Node in das React Flow-Format.
|
||||||
|
*/
|
||||||
|
export function convexNodeToRF(node: Doc<'nodes'>): RFNode {
|
||||||
|
return {
|
||||||
|
id: node._id,
|
||||||
|
type: node.type,
|
||||||
|
position: node.position,
|
||||||
|
data: node.data ?? {},
|
||||||
|
// parentId: node.parentNodeId ?? undefined, // ← für Group-Nodes, aktivieren wenn nötig
|
||||||
|
style: node.size
|
||||||
|
? { width: node.size.width, height: node.size.height }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformiert einen Convex-Edge in das React Flow-Format.
|
||||||
|
*/
|
||||||
|
export function convexEdgeToRF(edge: Doc<'edges'>): RFEdge {
|
||||||
|
return {
|
||||||
|
id: edge._id,
|
||||||
|
source: edge.sourceNodeId,
|
||||||
|
target: edge.targetNodeId,
|
||||||
|
// sourceHandle und targetHandle können später ergänzt werden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 — Haupt-Canvas-Komponente
|
||||||
|
|
||||||
|
Die zentrale Architekturentscheidung: **Lokaler State für flüssiges Interagieren, Convex als Sync-Layer.**
|
||||||
|
|
||||||
|
React Flow braucht `onNodesChange` für jede Interaktion (Drag, Select, Remove). Wenn wir jede Drag-Bewegung direkt an Convex senden würden, wäre das zu viel Traffic und der Canvas würde laggen. Stattdessen:
|
||||||
|
|
||||||
|
1. Convex-Daten kommen rein → werden in lokalen State geschrieben
|
||||||
|
2. Lokaler State wird von React Flow gesteuert (Drag, Select, etc.)
|
||||||
|
3. Bei `onNodeDragStop` wird die finale Position an Convex committed
|
||||||
|
4. Convex-Subscription aktualisiert den lokalen State bei Remote-Änderungen
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/canvas.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
type Node as RFNode,
|
||||||
|
type Edge as RFEdge,
|
||||||
|
type NodeChange,
|
||||||
|
type EdgeChange,
|
||||||
|
type Connection,
|
||||||
|
type ReactFlowInstance,
|
||||||
|
BackgroundVariant,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { api } from '@/convex/_generated/api';
|
||||||
|
import type { Id } from '@/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
import { nodeTypes } from './node-types';
|
||||||
|
import { convexNodeToRF, convexEdgeToRF } from '@/lib/canvas-utils';
|
||||||
|
|
||||||
|
interface CanvasProps {
|
||||||
|
canvasId: Id<'canvases'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Canvas({ canvasId }: CanvasProps) {
|
||||||
|
// ─── Convex Realtime Queries ───
|
||||||
|
const convexNodes = useQuery(api.nodes.list, { canvasId });
|
||||||
|
const convexEdges = useQuery(api.edges.list, { canvasId });
|
||||||
|
|
||||||
|
// ─── Convex Mutations ───
|
||||||
|
const moveNode = useMutation(api.nodes.move);
|
||||||
|
const createNode = useMutation(api.nodes.create);
|
||||||
|
const removeNode = useMutation(api.nodes.remove);
|
||||||
|
const createEdge = useMutation(api.edges.create);
|
||||||
|
const removeEdge = useMutation(api.edges.remove);
|
||||||
|
|
||||||
|
// ─── Lokaler State (für flüssiges Dragging) ───
|
||||||
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
|
||||||
|
// Track ob gerade gedraggt wird — dann kein Convex-Override
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
// React Flow Instance Ref (für screenToFlowPosition, etc.)
|
||||||
|
const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);
|
||||||
|
|
||||||
|
// ─── Convex → Lokaler State Sync ───
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexNodes) return;
|
||||||
|
// Nur aktualisieren wenn NICHT gerade gedraggt wird
|
||||||
|
if (!isDragging.current) {
|
||||||
|
setNodes(convexNodes.map(convexNodeToRF));
|
||||||
|
}
|
||||||
|
}, [convexNodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexEdges) return;
|
||||||
|
setEdges(convexEdges.map(convexEdgeToRF));
|
||||||
|
}, [convexEdges]);
|
||||||
|
|
||||||
|
// ─── Node Changes (Drag, Select, Remove) ───
|
||||||
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||||
|
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
||||||
|
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ─── Drag Start → Lock Convex-Sync ───
|
||||||
|
const onNodeDragStart = useCallback(() => {
|
||||||
|
isDragging.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ─── Drag Stop → Commit zu Convex ───
|
||||||
|
const onNodeDragStop = useCallback(
|
||||||
|
(_: React.MouseEvent, node: RFNode) => {
|
||||||
|
isDragging.current = false;
|
||||||
|
moveNode({
|
||||||
|
nodeId: node.id as Id<'nodes'>,
|
||||||
|
position: { x: node.position.x, y: node.position.y },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[moveNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Neue Verbindung → Convex Edge ───
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
if (connection.source && connection.target) {
|
||||||
|
createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: connection.source as Id<'nodes'>,
|
||||||
|
targetNodeId: connection.target as Id<'nodes'>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createEdge, canvasId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Node löschen → Convex ───
|
||||||
|
const onNodesDelete = useCallback(
|
||||||
|
(deletedNodes: RFNode[]) => {
|
||||||
|
for (const node of deletedNodes) {
|
||||||
|
removeNode({ nodeId: node.id as Id<'nodes'> });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Edge löschen → Convex ───
|
||||||
|
const onEdgesDelete = useCallback(
|
||||||
|
(deletedEdges: RFEdge[]) => {
|
||||||
|
for (const edge of deletedEdges) {
|
||||||
|
removeEdge({ edgeId: edge.id as Id<'edges'> });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeEdge]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Loading State ───
|
||||||
|
if (convexNodes === undefined || convexEdges === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Canvas lädt…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodesDelete={onNodesDelete}
|
||||||
|
onEdgesDelete={onEdgesDelete}
|
||||||
|
onInit={setRfInstance}
|
||||||
|
fitView
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={[16, 16]}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
multiSelectionKeyCode="Shift"
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
|
<Controls className="!bg-card !border !shadow-sm !rounded-lg" />
|
||||||
|
<MiniMap
|
||||||
|
className="!bg-card !border !shadow-sm !rounded-lg"
|
||||||
|
nodeColor="#6366f1"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 6 — Canvas Toolbar
|
||||||
|
|
||||||
|
Eine einfache Toolbar zum Anlegen neuer Nodes. In Phase 1 ist das der einfachste Weg, Nodes zu erstellen (Sidebar + Drag kommt danach).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/canvas/canvas-toolbar.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import { api } from '@/convex/_generated/api';
|
||||||
|
import type { Id } from '@/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
const nodeTemplates = [
|
||||||
|
{ type: 'image', label: '🖼️ Bild', defaultData: {} },
|
||||||
|
{ type: 'text', label: '📝 Text', defaultData: { content: '' } },
|
||||||
|
{ type: 'prompt', label: '✨ Prompt', defaultData: { prompt: '' } },
|
||||||
|
{ type: 'note', label: '📌 Notiz', defaultData: { content: '' } },
|
||||||
|
{ type: 'frame', label: '🖥️ Frame', defaultData: { label: 'Untitled', resolution: '1080x1080' } },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface CanvasToolbarProps {
|
||||||
|
canvasId: Id<'canvases'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) {
|
||||||
|
const createNode = useMutation(api.nodes.create);
|
||||||
|
|
||||||
|
const handleAddNode = async (type: string, data: Record<string, any>) => {
|
||||||
|
// Platziere neue Nodes leicht versetzt, damit sie nicht übereinander liegen
|
||||||
|
const offset = Math.random() * 200;
|
||||||
|
await createNode({
|
||||||
|
canvasId,
|
||||||
|
type,
|
||||||
|
position: { x: 100 + offset, y: 100 + offset },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
|
||||||
|
{nodeTemplates.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.type}
|
||||||
|
onClick={() => handleAddNode(t.type, t.defaultData)}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||||
|
title={`${t.label} hinzufügen`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 7 — Canvas Page (Next.js App Router)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(app)/canvas/[canvasId]/page.tsx
|
||||||
|
import Canvas from '@/components/canvas/canvas';
|
||||||
|
import CanvasToolbar from '@/components/canvas/canvas-toolbar';
|
||||||
|
import type { Id } from '@/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
export default async function CanvasPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ canvasId: string }>;
|
||||||
|
}) {
|
||||||
|
const { canvasId } = await params;
|
||||||
|
const typedCanvasId = canvasId as Id<'canvases'>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-screen w-screen overflow-hidden">
|
||||||
|
<CanvasToolbar canvasId={typedCanvasId} />
|
||||||
|
<Canvas canvasId={typedCanvasId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 8 — CSS Import nicht vergessen!
|
||||||
|
|
||||||
|
@xyflow/react braucht sein eigenes CSS. Importiere es entweder in der Canvas-Komponente (wie oben) oder global in `app/globals.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* app/globals.css — NACH den Tailwind-Imports */
|
||||||
|
@import '@xyflow/react/dist/style.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tailwind v4 Hinweis:** Falls die React Flow Styles von Tailwinds Reset überschrieben werden, importiere sie NACH dem Tailwind-Import.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing-Reihenfolge
|
||||||
|
|
||||||
|
Nachdem du alle Dateien erstellt hast, teste in dieser Reihenfolge:
|
||||||
|
|
||||||
|
### Test 1: Canvas rendert
|
||||||
|
- Navigiere zu `/canvas/<eine-canvas-id>` (du brauchst eine existierende Canvas-ID aus Convex)
|
||||||
|
- Erwartung: Leerer Canvas mit Dot-Background, Controls unten links, MiniMap unten rechts
|
||||||
|
- Falls 404: Prüfe ob die Route `app/(app)/canvas/[canvasId]/page.tsx` korrekt liegt
|
||||||
|
|
||||||
|
### Test 2: Node hinzufügen
|
||||||
|
- Klicke auf "📌 Notiz" in der Toolbar
|
||||||
|
- Erwartung: Note-Node erscheint auf dem Canvas
|
||||||
|
- Prüfe im Convex Dashboard: neuer Eintrag in der `nodes`-Tabelle
|
||||||
|
|
||||||
|
### Test 3: Node verschieben
|
||||||
|
- Ziehe den Node an eine neue Position, lasse los
|
||||||
|
- Erwartung: Node bleibt an der neuen Position
|
||||||
|
- Prüfe im Convex Dashboard: `position.x` und `position.y` haben sich aktualisiert
|
||||||
|
|
||||||
|
### Test 4: Verbindung erstellen
|
||||||
|
- Erstelle einen Prompt-Node und einen (leeren) AI-Image-Node
|
||||||
|
- Ziehe vom Source-Handle (rechts am Prompt) zum Target-Handle (links am AI-Image)
|
||||||
|
- Erwartung: Edge erscheint als Linie zwischen den Nodes
|
||||||
|
- Prüfe im Convex Dashboard: neuer Eintrag in der `edges`-Tabelle
|
||||||
|
|
||||||
|
### Test 5: Node löschen
|
||||||
|
- Selektiere einen Node (Klick), drücke `Delete` oder `Backspace`
|
||||||
|
- Erwartung: Node verschwindet, zugehörige Edges werden ebenfalls entfernt
|
||||||
|
- Prüfe im Convex Dashboard: Node und Edges sind gelöscht
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Fallstricke
|
||||||
|
|
||||||
|
### 1. `nodeTypes` innerhalb der Komponente definiert
|
||||||
|
→ React Flow re-rendert ALLE Nodes bei jedem State-Update. Die Map MUSS in einer eigenen Datei liegen.
|
||||||
|
|
||||||
|
### 2. React Flow CSS fehlt
|
||||||
|
→ Nodes sind unsichtbar oder falsch positioniert. Import von `@xyflow/react/dist/style.css` ist Pflicht.
|
||||||
|
|
||||||
|
### 3. Convex-Sync während Drag
|
||||||
|
→ Wenn Convex einen neuen Wert pusht während der User draggt, springt der Node zur alten Position zurück. Die `isDragging`-Ref verhindert das.
|
||||||
|
|
||||||
|
### 4. Handle-Styling
|
||||||
|
→ Die Standard-Handles von React Flow sind winzig und dunkel. Die `!`-Klassen in Tailwind erzwingen Custom-Styling über die React Flow Defaults.
|
||||||
|
|
||||||
|
### 5. Batch-Drag (mehrere Nodes selektiert)
|
||||||
|
→ `onNodeDragStop` feuert nur für den primär gedraggten Node. Für Batch-Moves nutze `onSelectionDragStop` oder `batchMove` Mutation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte (nach Schritt 1–3)
|
||||||
|
|
||||||
|
- **Schritt 4:** Sidebar mit Node-Palette + dnd-kit (Drag von Sidebar auf Canvas)
|
||||||
|
- **Schritt 5:** Inline-Editing (Text direkt im Node bearbeiten → `updateData` Mutation)
|
||||||
|
- **Schritt 6:** Bild-Upload (Convex File Storage + Image-Node)
|
||||||
|
- **Schritt 7:** OpenRouter-Integration (Prompt → KI-Bild-Generierung)
|
||||||
|
- **Schritt 8:** Node-Status-Modell visuell ausbauen (Shimmer, Progress, Error)
|
||||||
639
.docs/lemonspace_wireframes.html
Normal file
639
.docs/lemonspace_wireframes.html
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LemonSpace — Wireframes v1</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-background-primary: #ffffff;
|
||||||
|
--color-background-secondary: #f5f5f3;
|
||||||
|
--color-background-tertiary: #ebebea;
|
||||||
|
--color-background-info: #e6f1fb;
|
||||||
|
--color-background-success: #eaf3de;
|
||||||
|
--color-background-warning: #faeeda;
|
||||||
|
--color-background-danger: #fcebeb;
|
||||||
|
--color-text-primary: #1a1a18;
|
||||||
|
--color-text-secondary: #6b6b68;
|
||||||
|
--color-text-info: #185fa5;
|
||||||
|
--color-text-success: #3b6d11;
|
||||||
|
--color-text-warning: #854f0b;
|
||||||
|
--color-text-danger: #a32d2d;
|
||||||
|
--color-border-tertiary: rgba(0,0,0,0.12);
|
||||||
|
--color-border-secondary: rgba(0,0,0,0.22);
|
||||||
|
--color-border-info: rgba(24,95,165,0.4);
|
||||||
|
--color-border-success: rgba(59,109,17,0.4);
|
||||||
|
--color-border-warning: rgba(133,79,11,0.4);
|
||||||
|
--color-border-danger: rgba(163,45,45,0.4);
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
--border-radius-lg: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background-primary: #1e1e1c;
|
||||||
|
--color-background-secondary: #2a2a28;
|
||||||
|
--color-background-tertiary: #333331;
|
||||||
|
--color-background-info: #042c53;
|
||||||
|
--color-background-success: #173404;
|
||||||
|
--color-background-warning: #412402;
|
||||||
|
--color-background-danger: #501313;
|
||||||
|
--color-text-primary: #f0efe8;
|
||||||
|
--color-text-secondary: #9b9b96;
|
||||||
|
--color-text-info: #85b7eb;
|
||||||
|
--color-text-success: #97c459;
|
||||||
|
--color-text-warning: #ef9f27;
|
||||||
|
--color-text-danger: #f09595;
|
||||||
|
--color-border-tertiary: rgba(255,255,255,0.1);
|
||||||
|
--color-border-secondary: rgba(255,255,255,0.2);
|
||||||
|
--color-border-info: rgba(133,183,235,0.35);
|
||||||
|
--color-border-success: rgba(151,196,89,0.35);
|
||||||
|
--color-border-warning: rgba(239,159,39,0.35);
|
||||||
|
--color-border-danger: rgba(240,149,149,0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--color-background-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.page-header h1 { font-size: 18px; font-weight: 500; }
|
||||||
|
.page-header span { font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nav-btn {
|
||||||
|
padding: 5px 14px; font-size: 12px; border-radius: var(--border-radius-md);
|
||||||
|
border: 0.5px solid var(--color-border-secondary);
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
color: var(--color-text-secondary); cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.nav-btn.sel {
|
||||||
|
background: var(--color-background-info);
|
||||||
|
color: var(--color-text-info);
|
||||||
|
border-color: transparent; font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf { display: none; }
|
||||||
|
.wf.active { display: block; }
|
||||||
|
|
||||||
|
.screen-wrap {
|
||||||
|
border: 0.5px solid var(--color-border-tertiary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
border-bottom: 0.5px solid var(--color-border-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot-r { background: #e05a4e; }
|
||||||
|
.dot-y { background: #e8b84b; }
|
||||||
|
.dot-g { background: #59b96f; }
|
||||||
|
.tb-title { margin-left: 8px; font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; padding: 2px 8px;
|
||||||
|
border-radius: 99px; font-size: 11px; font-weight: 500;
|
||||||
|
border: 0.5px solid var(--color-border-tertiary);
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.pill.active { background: var(--color-background-info); color: var(--color-text-info); border-color: transparent; }
|
||||||
|
.pill.success { background: var(--color-background-success); color: var(--color-text-success); border-color: transparent; }
|
||||||
|
.pill.warn { background: var(--color-background-warning); color: var(--color-text-warning); border-color: transparent; }
|
||||||
|
.pill.danger { background: var(--color-background-danger); color: var(--color-text-danger); border-color: transparent; }
|
||||||
|
|
||||||
|
.node-card {
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
border: 0.5px solid var(--color-border-secondary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.node-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||||
|
.node-label { font-size: 11px; font-weight: 500; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.node-thumb {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background-tertiary);
|
||||||
|
border: 0.5px solid var(--color-border-tertiary);
|
||||||
|
}
|
||||||
|
.connector-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
border: 1.5px solid var(--color-border-secondary);
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.divider { border: none; border-top: 0.5px solid var(--color-border-tertiary); margin: 12px 0; }
|
||||||
|
.label-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--color-text-secondary); margin-bottom: 4px; }
|
||||||
|
.label-row strong { color: var(--color-text-primary); font-weight: 500; }
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
background: linear-gradient(90deg, var(--color-background-tertiary) 25%, var(--color-background-secondary) 50%, var(--color-background-tertiary) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.6s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.8); } }
|
||||||
|
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
border: 0.5px solid var(--color-border-tertiary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
button { cursor: pointer; }
|
||||||
|
textarea { resize: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>🍋 LemonSpace — Wireframes</h1>
|
||||||
|
<span>Mid-Fi · v1 · März 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav" id="nav">
|
||||||
|
<button class="nav-btn sel" data-screen="0">1 — Canvas interface</button>
|
||||||
|
<button class="nav-btn" data-screen="1">2 — Node status & skeletons</button>
|
||||||
|
<button class="nav-btn" data-screen="2">3 — Agent clarification UX</button>
|
||||||
|
<button class="nav-btn" data-screen="3">4 — Dashboard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 1: Canvas Interface -->
|
||||||
|
<div class="wf active" id="wf-0">
|
||||||
|
<div class="screen-wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div>
|
||||||
|
<span class="tb-title">LemonSpace — Brand Campaign Q2</span>
|
||||||
|
<div style="margin-left: auto; display:flex; gap:6px; align-items:center;">
|
||||||
|
<span class="pill active">Autosaved</span>
|
||||||
|
<span class="pill">Share</span>
|
||||||
|
<div style="width:22px;height:22px;border-radius:50%;background:var(--color-background-info);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-info);font-weight:500;">MH</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; height: 560px;">
|
||||||
|
|
||||||
|
<!-- Left sidebar -->
|
||||||
|
<div style="width: 200px; flex-shrink:0; background:var(--color-background-primary); border-right:0.5px solid var(--color-border-tertiary); padding:12px; overflow-y:auto; display:flex;flex-direction:column;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Quelle</strong></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.2"/><path d="M1 11l4-4 3 3 2-2 4 4" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||||
|
<span style="font-size:12px;">Bild</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="2" rx="1" fill="currentColor" opacity=".5"/><rect x="1" y="7" width="10" height="2" rx="1" fill="currentColor" opacity=".5"/><rect x="1" y="11" width="12" height="2" rx="1" fill="currentColor" opacity=".5"/></svg>
|
||||||
|
<span style="font-size:12px;">Text</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab; opacity:.5;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.2"/><path d="M6 8l2-3 2 3H6z" fill="currentColor" opacity=".5"/></svg>
|
||||||
|
<span style="font-size:12px;">Prompt <span style="font-size:10px;opacity:.6;">Phase 2</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>KI-Ausgabe</strong></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M8 2L9.6 6.4H14L10.4 9 11.6 14 8 11.2 4.4 14 5.6 9 2 6.4H6.4L8 2z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||||
|
<span style="font-size:12px;">KI-Bild</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Canvas & Layout</strong></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.2" stroke-dasharray="3 2"/></svg>
|
||||||
|
<span style="font-size:12px;">Frame</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="9" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||||
|
<span style="font-size:12px;">Gruppe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="padding:7px 10px; cursor:grab;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.2"/><path d="M5 7h6M5 9.5h4" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></svg>
|
||||||
|
<span style="font-size:12px;">Notiz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas area -->
|
||||||
|
<div style="flex:1; position:relative; overflow:hidden; background: radial-gradient(circle, var(--color-border-tertiary) 1px, transparent 1px); background-size: 24px 24px;">
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;">
|
||||||
|
<path d="M 222 130 C 270 130, 270 200, 320 200" stroke="var(--color-border-secondary)" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 222 130 C 270 130, 270 300, 320 300" stroke="var(--color-border-secondary)" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 490 200 C 540 200, 540 250, 590 250" stroke="var(--color-border-secondary)" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="node-card" style="position:absolute; left:80px; top:96px; width:150px;">
|
||||||
|
<div class="node-header">
|
||||||
|
<span class="node-label">Quelle</span>
|
||||||
|
<span class="pill success" style="font-size:10px; padding:1px 6px;">Bild</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-thumb" style="height:80px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"><rect x="1" y="1" width="26" height="26" rx="3" stroke="var(--color-border-secondary)" stroke-width="1.2"/><path d="M1 19l7-7 5 5 4-4 9 9" stroke="var(--color-border-secondary)" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:11px;color:var(--color-text-secondary);">product-hero.jpg</div>
|
||||||
|
<div class="connector-dot" style="right:-5px;top:50%;transform:translateY(-50%);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node-card" style="position:absolute; left:320px; top:170px; width:170px;">
|
||||||
|
<div class="node-header">
|
||||||
|
<span class="node-label">KI-Ausgabe</span>
|
||||||
|
<span class="pill active" style="font-size:10px; padding:1px 6px;">KI-Bild</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-thumb" style="height:90px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"><path d="M14 4L16.8 11.2H24L18 15.6 20.4 24 14 19.6 7.6 24 10 15.6 4 11.2H11.2L14 4z" stroke="var(--color-border-info)" stroke-width="1.2" stroke-linejoin="round" fill="var(--color-background-info)"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:11px;color:var(--color-text-secondary);">Gemini 2.5 Flash</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:2px;">~€0,03 / Bild</div>
|
||||||
|
<div class="connector-dot" style="left:-5px;top:50%;transform:translateY(-50%);"></div>
|
||||||
|
<div class="connector-dot" style="right:-5px;top:50%;transform:translateY(-50%);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node-card" style="position:absolute; left:320px; top:280px; width:170px; opacity:.7;">
|
||||||
|
<div class="node-header">
|
||||||
|
<span class="node-label">KI-Ausgabe</span>
|
||||||
|
<span class="pill active" style="font-size:10px; padding:1px 6px;">KI-Bild</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-thumb" style="height:90px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"><path d="M14 4L16.8 11.2H24L18 15.6 20.4 24 14 19.6 7.6 24 10 15.6 4 11.2H11.2L14 4z" stroke="var(--color-border-info)" stroke-width="1.2" stroke-linejoin="round" fill="var(--color-background-info)"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="connector-dot" style="left:-5px;top:50%;transform:translateY(-50%);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:570px;top:180px;width:180px;height:140px;border:1.5px dashed var(--color-border-info);border-radius:6px;background:var(--color-background-info);opacity:.25;"></div>
|
||||||
|
<div style="position:absolute;left:574px;top:167px;font-size:11px;color:var(--color-text-info);font-weight:500;">Frame — 1080×1080px</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:flex;gap:6px;background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:99px;padding:5px 10px;">
|
||||||
|
<button style="padding:3px 10px;border-radius:99px;border:none;background:transparent;font-size:12px;color:var(--color-text-secondary);">Fit</button>
|
||||||
|
<div style="width:0.5px;background:var(--color-border-tertiary);"></div>
|
||||||
|
<button style="padding:3px 10px;border-radius:99px;border:none;background:transparent;font-size:12px;color:var(--color-text-secondary);">50%</button>
|
||||||
|
<div style="width:0.5px;background:var(--color-border-tertiary);"></div>
|
||||||
|
<button style="padding:3px 10px;border-radius:99px;border:none;background:transparent;font-size:12px;color:var(--color-text-secondary);">100%</button>
|
||||||
|
<div style="width:0.5px;background:var(--color-border-tertiary);"></div>
|
||||||
|
<button style="padding:3px 10px;border-radius:99px;border:none;background:var(--color-background-info);font-size:12px;color:var(--color-text-info);font-weight:500;">+ Node</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;top:10px;right:10px;display:flex;align-items:center;gap:6px;background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:99px;padding:4px 10px;font-size:11px;">
|
||||||
|
<span style="color:var(--color-text-secondary);">Credits:</span>
|
||||||
|
<strong style="color:var(--color-text-primary); font-weight:500;">€34,18</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right inspector -->
|
||||||
|
<div style="width:220px;flex-shrink:0;background:var(--color-background-primary);border-left:0.5px solid var(--color-border-tertiary);padding:14px;overflow-y:auto;">
|
||||||
|
<div style="font-size:12px;font-weight:500;color:var(--color-text-primary);margin-bottom:12px;">KI-Bild — Inspector</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div>
|
||||||
|
<div class="label-row"><span>Modell</span></div>
|
||||||
|
<select style="width:100%;font-size:12px;">
|
||||||
|
<option>Gemini 2.5 Flash Image</option>
|
||||||
|
<option>FLUX.2 Klein 4B</option>
|
||||||
|
<option>GPT-5 Image</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label-row"><span>Prompt</span></div>
|
||||||
|
<textarea style="width:100%;font-size:12px;height:70px;background:var(--color-background-secondary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:6px 8px;color:var(--color-text-primary);">Product on white background, studio lighting, soft shadows</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label-row"><span>Auflösung</span></div>
|
||||||
|
<select style="width:100%;font-size:12px;"><option>1024×1024</option><option>1792×1024</option></select>
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:11px;">
|
||||||
|
<span style="color:var(--color-text-secondary);">Geschätzte Kosten</span>
|
||||||
|
<span style="font-weight:500;">~€0,03</span>
|
||||||
|
</div>
|
||||||
|
<button style="width:100%;padding:7px;font-size:12px;font-weight:500;border-radius:var(--border-radius-md);border:none;background:var(--color-background-info);color:var(--color-text-info);">Generieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 2: Node Status & Skeletons -->
|
||||||
|
<div class="wf" id="wf-1">
|
||||||
|
<div class="screen-wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div>
|
||||||
|
<span class="tb-title">Node Status — Ausführungsmodell</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:20px;background:var(--color-background-secondary);">
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Idle</strong></div>
|
||||||
|
<div class="node-card">
|
||||||
|
<div class="node-header"><span class="node-label">KI-Bild</span><span class="pill" style="font-size:10px;padding:1px 6px;">idle</span></div>
|
||||||
|
<div class="node-thumb" style="height:70px;display:flex;align-items:center;justify-content:center;opacity:.4;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 3L14 9H20L15 13L17 20L12 16L7 20L9 13L4 9H10L12 3Z" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:6px;">Bereit zur Ausführung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Executing</strong></div>
|
||||||
|
<div class="node-card" style="border-color:var(--color-border-info);">
|
||||||
|
<div class="node-header"><span class="node-label">KI-Bild</span><span class="pill active" style="font-size:10px;padding:1px 6px;">executing</span></div>
|
||||||
|
<div class="node-thumb" style="height:70px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<div style="display:flex;gap:5px;align-items:center;">
|
||||||
|
<div style="width:6px;height:6px;border-radius:50%;background:var(--color-text-info);animation:pulse 1s 0s infinite;"></div>
|
||||||
|
<div style="width:6px;height:6px;border-radius:50%;background:var(--color-text-info);animation:pulse 1s 0.2s infinite;"></div>
|
||||||
|
<div style="width:6px;height:6px;border-radius:50%;background:var(--color-text-info);animation:pulse 1s 0.4s infinite;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-info);margin-top:6px;">Gemini 2.5 Flash — ~€0,03 reserviert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Done</strong></div>
|
||||||
|
<div class="node-card" style="border-color:var(--color-border-success);">
|
||||||
|
<div class="node-header"><span class="node-label">KI-Bild</span><span class="pill success" style="font-size:10px;padding:1px 6px;">done</span></div>
|
||||||
|
<div class="node-thumb" style="height:70px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="2" y="2" width="20" height="20" rx="3" fill="var(--color-background-success)"/><path d="M4 14l5 5 10-10" stroke="var(--color-text-success)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-success);margin-top:6px;">€0,028 verbucht — 4,2s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Error</strong></div>
|
||||||
|
<div class="node-card" style="border-color:var(--color-border-danger);">
|
||||||
|
<div class="node-header"><span class="node-label">KI-Bild</span><span class="pill danger" style="font-size:10px;padding:1px 6px;">error</span></div>
|
||||||
|
<div class="node-thumb" style="height:70px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="2" y="2" width="20" height="20" rx="3" fill="var(--color-background-danger)"/><path d="M8 8l8 8M16 8l-8 8" stroke="var(--color-text-danger)" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-danger);margin-top:6px;">Timeout — Credits nicht abgebucht</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider" style="margin:20px 0;">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text-primary);margin-bottom:12px;">Skeleton-Nodes — Agent Execution Plan</div>
|
||||||
|
<div style="background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:12px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-bottom:10px;">
|
||||||
|
Agent hat Plan erstellt: <strong style="color:var(--color-text-primary);">3× KI-Bild, 2× KI-Text, 1× Text-Overlay</strong> — Skeleton-Nodes platziert
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<div class="node-card" style="width:110px;border-color:var(--color-border-success);">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-success);font-weight:500;margin-bottom:6px;">KI-Bild 1/3</div>
|
||||||
|
<div style="height:50px;display:flex;align-items:center;justify-content:center;background:var(--color-background-success);border-radius:4px;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M9 2l1.5 4.5H15L11.5 9l1.5 5L9 11.5 5 14l1.5-5L3 6.5h4.5L9 2z" stroke="var(--color-text-success)" stroke-width="1.2" fill="none"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="width:110px;border-color:var(--color-border-info);">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-info);font-weight:500;margin-bottom:6px;">KI-Bild 2/3</div>
|
||||||
|
<div class="shimmer" style="height:50px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="width:110px;opacity:.55;">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-secondary);font-weight:500;margin-bottom:6px;">KI-Bild 3/3</div>
|
||||||
|
<div class="shimmer" style="height:50px;opacity:.5;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="width:110px;opacity:.55;">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-secondary);font-weight:500;margin-bottom:6px;">KI-Text 1/2</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:3px;">
|
||||||
|
<div class="shimmer" style="height:8px;opacity:.5;"></div>
|
||||||
|
<div class="shimmer" style="height:8px;width:70%;opacity:.5;"></div>
|
||||||
|
<div class="shimmer" style="height:8px;width:85%;opacity:.5;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="width:110px;opacity:.55;">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-secondary);font-weight:500;margin-bottom:6px;">KI-Text 2/2</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:3px;">
|
||||||
|
<div class="shimmer" style="height:8px;opacity:.5;"></div>
|
||||||
|
<div class="shimmer" style="height:8px;width:60%;opacity:.5;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="width:110px;opacity:.55;">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-secondary);font-weight:500;margin-bottom:6px;">Text-Overlay</div>
|
||||||
|
<div class="shimmer" style="height:50px;opacity:.5;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;font-size:11px;color:var(--color-text-secondary);">
|
||||||
|
<span class="pill active" style="font-size:10px;margin-right:6px;">executing 2/6</span>
|
||||||
|
Nodes verschiebbar — Position bleibt bei Output-Ersatz erhalten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 3: Agent Clarification -->
|
||||||
|
<div class="wf" id="wf-2">
|
||||||
|
<div class="screen-wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div>
|
||||||
|
<span class="tb-title">Agent Clarification — 3 Varianten</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:20px;background:var(--color-background-secondary);">
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Option A</strong> <span>— Inline am Node</span></div>
|
||||||
|
<div class="node-card" style="border-color:var(--color-border-warning);">
|
||||||
|
<div class="node-header"><span class="node-label">Agent</span><span class="pill warn" style="font-size:10px;padding:1px 6px;">clarifying</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin:6px 0 8px;">Instagram Curator</div>
|
||||||
|
<div style="background:var(--color-background-warning);border-radius:4px;padding:8px;margin-bottom:8px;">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-warning);font-weight:500;margin-bottom:4px;">Fehlende Angabe</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-warning);">Welche Zielgruppe soll angesprochen werden?</div>
|
||||||
|
</div>
|
||||||
|
<textarea style="width:100%;font-size:11px;height:44px;background:var(--color-background-secondary);border:0.5px solid var(--color-border-warning);border-radius:4px;padding:5px 7px;color:var(--color-text-primary);" placeholder="Antwort eingeben..."></textarea>
|
||||||
|
<button style="margin-top:6px;width:100%;padding:5px;font-size:11px;border-radius:4px;border:none;background:var(--color-background-warning);color:var(--color-text-warning);font-weight:500;">Weiter</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:11px;color:var(--color-text-secondary);line-height:1.6;">
|
||||||
|
+ Direkt am Node, kein Kontextverlust<br>
|
||||||
|
+ Canvas bleibt sichtbar<br>
|
||||||
|
− Wenig Platz für lange Antworten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Option B</strong> <span>— Modal</span></div>
|
||||||
|
<div style="min-height:200px;background:rgba(0,0,0,0.35);border-radius:var(--border-radius-md);display:flex;align-items:center;justify-content:center;padding:16px;">
|
||||||
|
<div class="node-card" style="width:100%;max-width:260px;border-color:var(--color-border-warning);">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text-primary);margin-bottom:4px;">Agent braucht Angaben</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-bottom:10px;">Instagram Curator — Clarification</div>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-primary);margin-bottom:6px;">Welche Zielgruppe soll angesprochen werden?</div>
|
||||||
|
<textarea style="width:100%;font-size:12px;height:60px;background:var(--color-background-secondary);border:0.5px solid var(--color-border-tertiary);border-radius:4px;padding:6px 8px;color:var(--color-text-primary);" placeholder="z.B. 25–35, Mode-affin, urban"></textarea>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:8px;">
|
||||||
|
<button style="flex:1;padding:6px;font-size:11px;border-radius:4px;border:0.5px solid var(--color-border-secondary);background:transparent;color:var(--color-text-secondary);">Abbrechen</button>
|
||||||
|
<button style="flex:1;padding:6px;font-size:11px;border-radius:4px;border:none;background:var(--color-background-info);color:var(--color-text-info);font-weight:500;">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:11px;color:var(--color-text-secondary);line-height:1.6;">
|
||||||
|
+ Mehr Platz, klarer Fokus<br>
|
||||||
|
+ Bekanntes Interaktionsmuster<br>
|
||||||
|
− Canvas wird verdeckt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="label-row" style="margin-bottom:8px;"><strong>Option C</strong> <span>— Chat-Sidebar</span></div>
|
||||||
|
<div style="background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);overflow:hidden;">
|
||||||
|
<div style="padding:8px 10px;border-bottom:0.5px solid var(--color-border-tertiary);font-size:11px;font-weight:500;">Agent — Instagram Curator</div>
|
||||||
|
<div style="padding:10px;display:flex;flex-direction:column;gap:6px;min-height:130px;">
|
||||||
|
<div style="align-self:flex-start;background:var(--color-background-secondary);border-radius:0 6px 6px 6px;padding:6px 8px;font-size:11px;max-width:85%;">Ich habe alle Inputs analysiert. Eine Angabe fehlt noch: Welche Zielgruppe soll angesprochen werden?</div>
|
||||||
|
<div style="align-self:flex-end;background:var(--color-background-info);border-radius:6px 0 6px 6px;padding:6px 8px;font-size:11px;color:var(--color-text-info);max-width:85%;">25–35, Mode-affin, urban</div>
|
||||||
|
<div style="align-self:flex-start;background:var(--color-background-secondary);border-radius:0 6px 6px 6px;padding:6px 8px;font-size:11px;color:var(--color-text-success);max-width:85%;">Danke! Starte jetzt Execution...</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 8px;border-top:0.5px solid var(--color-border-tertiary);display:flex;gap:6px;">
|
||||||
|
<input type="text" placeholder="Antworten..." style="flex:1;font-size:11px;padding:4px 7px;"/>
|
||||||
|
<button style="padding:4px 10px;font-size:11px;border-radius:4px;border:none;background:var(--color-background-info);color:var(--color-text-info);">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:11px;color:var(--color-text-secondary);line-height:1.6;">
|
||||||
|
+ Natürlich, erweiterbar auf Multi-Turn<br>
|
||||||
|
+ Verlauf bleibt sichtbar<br>
|
||||||
|
− Platzbedarf, Canvas verkleinert
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider" style="margin:20px 0;">
|
||||||
|
<div style="background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:12px;">
|
||||||
|
<div style="font-size:12px;font-weight:500;color:var(--color-text-primary);margin-bottom:6px;">Empfehlung zur Entscheidung</div>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-secondary);line-height:1.6;">Option A (inline) passt am besten zur Canvas-nativen Philosophie von LemonSpace. Option C (Chat) skaliert besser für komplexere Agent-Workflows mit mehreren Rückfragen. Option B (Modal) ist der sicherste UX-Kompromiss — vertraut, fokussiert, aber ohne Canvas-Nähe.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 4: Dashboard -->
|
||||||
|
<div class="wf" id="wf-3">
|
||||||
|
<div class="screen-wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div>
|
||||||
|
<span class="tb-title">LemonSpace — Dashboard</span>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;">
|
||||||
|
<span class="pill">Starter</span>
|
||||||
|
<div style="width:22px;height:22px;border-radius:50%;background:var(--color-background-info);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-info);font-weight:500;">MH</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;height:520px;">
|
||||||
|
<div style="width:180px;flex-shrink:0;background:var(--color-background-primary);border-right:0.5px solid var(--color-border-tertiary);padding:16px 10px;display:flex;flex-direction:column;gap:2px;">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);padding:4px 8px;margin-bottom:4px;letter-spacing:.04em;">LemonSpace</div>
|
||||||
|
<div style="padding:6px 8px;border-radius:var(--border-radius-md);background:var(--color-background-secondary);font-size:12px;color:var(--color-text-primary);">Alle Canvases</div>
|
||||||
|
<div style="padding:6px 8px;border-radius:var(--border-radius-md);font-size:12px;color:var(--color-text-secondary);">Zuletzt geöffnet</div>
|
||||||
|
<div style="padding:6px 8px;border-radius:var(--border-radius-md);font-size:12px;color:var(--color-text-secondary);">Templates</div>
|
||||||
|
<hr class="divider" style="margin:8px 0;">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);padding:4px 8px;letter-spacing:.04em;">Konto</div>
|
||||||
|
<div style="padding:6px 8px;border-radius:var(--border-radius-md);font-size:12px;color:var(--color-text-secondary);">Credits & Abo</div>
|
||||||
|
<div style="padding:6px 8px;border-radius:var(--border-radius-md);font-size:12px;color:var(--color-text-secondary);">Einstellungen</div>
|
||||||
|
<div style="margin-top:auto;background:var(--color-background-secondary);border-radius:var(--border-radius-md);padding:10px;">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-bottom:4px;">Credits verbleibend</div>
|
||||||
|
<div style="font-size:18px;font-weight:500;color:var(--color-text-primary);">€6,18</div>
|
||||||
|
<div style="height:4px;background:var(--color-border-tertiary);border-radius:2px;margin:6px 0;">
|
||||||
|
<div style="height:4px;width:98%;background:var(--color-text-info);border-radius:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--color-text-secondary);">Starter — erneuert am 1. Apr</div>
|
||||||
|
<button style="margin-top:6px;width:100%;padding:4px;font-size:11px;border-radius:4px;border:0.5px solid var(--color-border-secondary);background:transparent;color:var(--color-text-secondary);">Upgrade</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;padding:20px;overflow-y:auto;background:var(--color-background-secondary);">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
|
<div style="font-size:15px;font-weight:500;color:var(--color-text-primary);">Meine Canvases</div>
|
||||||
|
<button style="padding:6px 14px;font-size:12px;font-weight:500;border-radius:var(--border-radius-md);border:none;background:var(--color-background-info);color:var(--color-text-info);">+ Neuer Canvas</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;">
|
||||||
|
<div class="node-card" style="cursor:pointer;">
|
||||||
|
<div class="node-thumb" style="height:90px;margin-bottom:8px;display:flex;align-items:center;justify-content:center;background:var(--color-background-info);">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="4" y="4" width="24" height="24" rx="4" stroke="var(--color-border-info)" stroke-width="1.5"/><path d="M4 20l8-8 5 5 4-4 9 9" stroke="var(--color-border-info)" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;">Brand Campaign Q2</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:2px;">12 Nodes · vor 2h</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="cursor:pointer;">
|
||||||
|
<div class="node-thumb" style="height:90px;margin-bottom:8px;display:flex;align-items:center;justify-content:center;background:var(--color-background-success);">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="4" y="4" width="24" height="24" rx="4" stroke="var(--color-border-success)" stroke-width="1.5"/><path d="M8 16l5 5 10-10" stroke="var(--color-border-success)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;">Instagram Curator Test</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:2px;">8 Nodes · gestern</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="cursor:pointer;border-style:dashed;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:140px;opacity:.6;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:6px;">Neuer Canvas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider" style="margin:20px 0;">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text-primary);margin-bottom:12px;">Templates</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;">
|
||||||
|
<div class="node-card" style="cursor:pointer;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:2px;">Instagram Curator</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);">Agent · Phase 2</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="cursor:pointer;opacity:.5;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:2px;">Product Batch</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);">Agent · coming soon</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-card" style="cursor:pointer;opacity:.5;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:2px;">Brand Guidelines</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-secondary);">Agent · coming soon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const btns = document.querySelectorAll('.nav-btn');
|
||||||
|
const screens = document.querySelectorAll('.wf');
|
||||||
|
btns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const i = +btn.dataset.screen;
|
||||||
|
btns.forEach(b => b.classList.remove('sel'));
|
||||||
|
screens.forEach(s => s.classList.remove('active'));
|
||||||
|
btn.classList.add('sel');
|
||||||
|
screens[i].classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
app/(app)/canvas/[canvasId]/page.tsx
Normal file
27
app/(app)/canvas/[canvasId]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import Canvas from "@/components/canvas/canvas";
|
||||||
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { isAuthenticated } from "@/lib/auth-server";
|
||||||
|
|
||||||
|
export default async function CanvasPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ canvasId: string }>;
|
||||||
|
}) {
|
||||||
|
const authenticated = await isAuthenticated();
|
||||||
|
if (!authenticated) {
|
||||||
|
redirect("/auth/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canvasId } = await params;
|
||||||
|
const typedCanvasId = canvasId as Id<"canvases">;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-screen w-screen overflow-hidden">
|
||||||
|
<CanvasToolbar canvasId={typedCanvasId} />
|
||||||
|
<Canvas canvasId={typedCanvasId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image"
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -21,16 +21,16 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const formatEurFromCents = (cents: number) =>
|
const formatEurFromCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("de-DE", {
|
new Intl.NumberFormat("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}).format(cents / 100)
|
}).format(cents / 100);
|
||||||
|
|
||||||
const mockRuns = [
|
const mockRuns = [
|
||||||
{
|
{
|
||||||
@@ -69,53 +69,53 @@ const mockRuns = [
|
|||||||
credits: 0,
|
credits: 0,
|
||||||
updated: "vor 2 Std.",
|
updated: "vor 2 Std.",
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const mockWorkspaces = [
|
const mockWorkspaces = [
|
||||||
{ name: "Sommer-Kampagne", nodes: 24, frames: 3, initial: "S" },
|
{ name: "Sommer-Kampagne", nodes: 24, frames: 3, initial: "S" },
|
||||||
{ name: "Produktfotos", nodes: 11, frames: 2, initial: "P" },
|
{ name: "Produktfotos", nodes: 11, frames: 2, initial: "P" },
|
||||||
{ name: "Social Variants", nodes: 8, frames: 1, initial: "V" },
|
{ name: "Social Variants", nodes: 8, frames: 1, initial: "V" },
|
||||||
]
|
];
|
||||||
|
|
||||||
function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) {
|
function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) {
|
||||||
const base = "inline-block size-2 rounded-full"
|
const base = "inline-block size-2 rounded-full";
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "done":
|
case "done":
|
||||||
return <span className={cn(base, "bg-primary")} />
|
return <span className={cn(base, "bg-primary")} />;
|
||||||
case "executing":
|
case "executing":
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex size-2">
|
<span className="relative inline-flex size-2">
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||||
<span className={cn(base, "relative bg-primary")} />
|
<span className={cn(base, "relative bg-primary")} />
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
case "idle":
|
case "idle":
|
||||||
return <span className={cn(base, "bg-border")} />
|
return <span className={cn(base, "bg-border")} />;
|
||||||
case "error":
|
case "error":
|
||||||
return <span className={cn(base, "bg-destructive")} />
|
return <span className={cn(base, "bg-destructive")} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: (typeof mockRuns)[0]["status"]) {
|
function statusLabel(status: (typeof mockRuns)[0]["status"]) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "done":
|
case "done":
|
||||||
return "Fertig"
|
return "Fertig";
|
||||||
case "executing":
|
case "executing":
|
||||||
return "Läuft"
|
return "Läuft";
|
||||||
case "idle":
|
case "idle":
|
||||||
return "Bereit"
|
return "Bereit";
|
||||||
case "error":
|
case "error":
|
||||||
return "Fehler"
|
return "Fehler";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const balanceCents = 4320
|
const balanceCents = 4320;
|
||||||
const reservedCents = 180
|
const reservedCents = 180;
|
||||||
const monthlyPoolCents = 5000
|
const monthlyPoolCents = 5000;
|
||||||
const usagePercent = Math.round(
|
const usagePercent = Math.round(
|
||||||
((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100,
|
((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100,
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-background">
|
<div className="min-h-full bg-background">
|
||||||
@@ -131,6 +131,7 @@ export default function DashboardPage() {
|
|||||||
unoptimized
|
unoptimized
|
||||||
className="h-5 w-auto shrink-0"
|
className="h-5 w-auto shrink-0"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
loading="eager"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,7 +205,9 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-baseline justify-between text-sm">
|
<div className="mb-2 flex items-baseline justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Monatskontingent</span>
|
<span className="text-muted-foreground">
|
||||||
|
Monatskontingent
|
||||||
|
</span>
|
||||||
<span className="tabular-nums text-muted-foreground">
|
<span className="tabular-nums text-muted-foreground">
|
||||||
{usagePercent}%
|
{usagePercent}%
|
||||||
</span>
|
</span>
|
||||||
@@ -214,7 +217,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
||||||
Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch freigegeben.
|
Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch
|
||||||
|
freigegeben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,9 +252,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<p className="mt-4 text-xs text-muted-foreground leading-relaxed">
|
<p className="mt-4 text-xs text-muted-foreground leading-relaxed">
|
||||||
Step 2 von 4 —{" "}
|
Step 2 von 4 —{" "}
|
||||||
<span className="font-mono text-[0.7rem]">
|
<span className="font-mono text-[0.7rem]">flux-schnell</span>
|
||||||
flux-schnell
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +264,12 @@ export default function DashboardPage() {
|
|||||||
<LayoutTemplate className="size-3.5 text-muted-foreground" />
|
<LayoutTemplate className="size-3.5 text-muted-foreground" />
|
||||||
Workspaces
|
Workspaces
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground" disabled>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
Neuer Workspace
|
Neuer Workspace
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +280,7 @@ export default function DashboardPage() {
|
|||||||
key={ws.name}
|
key={ws.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all",
|
"group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all",
|
||||||
"hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4"
|
"hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4",
|
||||||
)}
|
)}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
@@ -319,7 +326,9 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{run.model !== "—" && (
|
{run.model !== "—" && (
|
||||||
<span className="font-mono text-[0.7rem]">{run.model}</span>
|
<span className="font-mono text-[0.7rem]">
|
||||||
|
{run.model}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{run.credits > 0 && (
|
{run.credits > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -345,5 +354,5 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
3
components/canvas/canvas-sidebar.tsx
Normal file
3
components/canvas/canvas-sidebar.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function CanvasSidebar() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
96
components/canvas/canvas-toolbar.tsx
Normal file
96
components/canvas/canvas-toolbar.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation } from "convex/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
const nodeTemplates = [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
label: "Bild",
|
||||||
|
width: 280,
|
||||||
|
height: 180,
|
||||||
|
defaultData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Text",
|
||||||
|
width: 256,
|
||||||
|
height: 120,
|
||||||
|
defaultData: { content: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
label: "Prompt",
|
||||||
|
width: 320,
|
||||||
|
height: 140,
|
||||||
|
defaultData: { content: "", model: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "note",
|
||||||
|
label: "Notiz",
|
||||||
|
width: 220,
|
||||||
|
height: 120,
|
||||||
|
defaultData: { content: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "frame",
|
||||||
|
label: "Frame",
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface CanvasToolbarProps {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) {
|
||||||
|
const createNode = useMutation(api.nodes.create);
|
||||||
|
const nodeCountRef = useRef(0);
|
||||||
|
|
||||||
|
const handleAddNode = async (
|
||||||
|
type: (typeof nodeTemplates)[number]["type"],
|
||||||
|
data: (typeof nodeTemplates)[number]["defaultData"],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) => {
|
||||||
|
const offset = (nodeCountRef.current % 8) * 24;
|
||||||
|
nodeCountRef.current += 1;
|
||||||
|
await createNode({
|
||||||
|
canvasId,
|
||||||
|
type,
|
||||||
|
positionX: 100 + offset,
|
||||||
|
positionY: 100 + offset,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{nodeTemplates.map((template) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{template.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
components/canvas/canvas.tsx
Normal file
158
components/canvas/canvas.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
type Connection,
|
||||||
|
type Edge as RFEdge,
|
||||||
|
type EdgeChange,
|
||||||
|
type Node as RFNode,
|
||||||
|
type NodeChange,
|
||||||
|
BackgroundVariant,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { convexEdgeToRF, convexNodeToRF } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
import { nodeTypes } from "./node-types";
|
||||||
|
|
||||||
|
interface CanvasProps {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Canvas({ canvasId }: CanvasProps) {
|
||||||
|
const convexNodes = useQuery(api.nodes.list, { canvasId });
|
||||||
|
const convexEdges = useQuery(api.edges.list, { canvasId });
|
||||||
|
|
||||||
|
const moveNode = useMutation(api.nodes.move);
|
||||||
|
const createEdge = useMutation(api.edges.create);
|
||||||
|
const removeNode = useMutation(api.nodes.remove);
|
||||||
|
const removeEdge = useMutation(api.edges.remove);
|
||||||
|
|
||||||
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexNodes) return;
|
||||||
|
if (!isDragging.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setNodes(convexNodes.map(convexNodeToRF));
|
||||||
|
}
|
||||||
|
}, [convexNodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexEdges) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setEdges(convexEdges.map(convexEdgeToRF));
|
||||||
|
}, [convexEdges]);
|
||||||
|
|
||||||
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||||
|
setNodes((current) => applyNodeChanges(changes, current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
||||||
|
setEdges((current) => applyEdgeChanges(changes, current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNodeDragStart = useCallback(() => {
|
||||||
|
isDragging.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNodeDragStop = useCallback(
|
||||||
|
(_event: React.MouseEvent, node: RFNode) => {
|
||||||
|
isDragging.current = false;
|
||||||
|
void moveNode({
|
||||||
|
nodeId: node.id as Id<"nodes">,
|
||||||
|
positionX: node.position.x,
|
||||||
|
positionY: node.position.y,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[moveNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
if (!connection.source || !connection.target) return;
|
||||||
|
void createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: connection.source as Id<"nodes">,
|
||||||
|
targetNodeId: connection.target as Id<"nodes">,
|
||||||
|
sourceHandle: connection.sourceHandle ?? undefined,
|
||||||
|
targetHandle: connection.targetHandle ?? undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[canvasId, createEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onNodesDelete = useCallback(
|
||||||
|
(deletedNodes: RFNode[]) => {
|
||||||
|
for (const node of deletedNodes) {
|
||||||
|
void removeNode({ nodeId: node.id as Id<"nodes"> });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEdgesDelete = useCallback(
|
||||||
|
(deletedEdges: RFEdge[]) => {
|
||||||
|
for (const edge of deletedEdges) {
|
||||||
|
void removeEdge({ edgeId: edge.id as Id<"edges"> });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (convexNodes === undefined || convexEdges === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Canvas laedt...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodesDelete={onNodesDelete}
|
||||||
|
onEdgesDelete={onEdgesDelete}
|
||||||
|
fitView
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={[16, 16]}
|
||||||
|
deleteKeyCode={["Backspace", "Delete"]}
|
||||||
|
multiSelectionKeyCode="Shift"
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
|
<Controls className="!rounded-lg !border !bg-card !shadow-sm" />
|
||||||
|
<MiniMap
|
||||||
|
className="!rounded-lg !border !bg-card !shadow-sm"
|
||||||
|
nodeColor="#6366f1"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
components/canvas/edges/default-edge.tsx
Normal file
3
components/canvas/edges/default-edge.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DefaultEdge() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
21
components/canvas/node-types.ts
Normal file
21
components/canvas/node-types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NodeTypes } from "@xyflow/react";
|
||||||
|
|
||||||
|
import AiImageNode from "./nodes/ai-image-node";
|
||||||
|
import CompareNode from "./nodes/compare-node";
|
||||||
|
import FrameNode from "./nodes/frame-node";
|
||||||
|
import GroupNode from "./nodes/group-node";
|
||||||
|
import ImageNode from "./nodes/image-node";
|
||||||
|
import NoteNode from "./nodes/note-node";
|
||||||
|
import PromptNode from "./nodes/prompt-node";
|
||||||
|
import TextNode from "./nodes/text-node";
|
||||||
|
|
||||||
|
export const nodeTypes: NodeTypes = {
|
||||||
|
image: ImageNode,
|
||||||
|
text: TextNode,
|
||||||
|
prompt: PromptNode,
|
||||||
|
"ai-image": AiImageNode,
|
||||||
|
group: GroupNode,
|
||||||
|
frame: FrameNode,
|
||||||
|
note: NoteNode,
|
||||||
|
compare: CompareNode,
|
||||||
|
};
|
||||||
67
components/canvas/nodes/ai-image-node.tsx
Normal file
67
components/canvas/nodes/ai-image-node.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type AiImageNodeData = {
|
||||||
|
url?: string;
|
||||||
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
status?: "idle" | "executing" | "done" | "error";
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiImageNode = Node<AiImageNodeData, "ai-image">;
|
||||||
|
|
||||||
|
export default function AiImageNode({ data, selected }: NodeProps<AiImageNode>) {
|
||||||
|
const status = data.status ?? "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} status={status} className="p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-emerald-500">KI-Bild</div>
|
||||||
|
|
||||||
|
{status === "executing" ? (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "done" && data.url ? (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={data.prompt ?? "KI-generiertes Bild"}
|
||||||
|
className="max-w-[280px] rounded-lg object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "error" ? (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 text-sm text-red-600 dark:bg-red-950/20">
|
||||||
|
{data.errorMessage ?? "Fehler bei der Generierung"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "idle" ? (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
|
||||||
|
Prompt verbinden
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.prompt && status === "done" ? (
|
||||||
|
<p className="mt-1 max-w-[280px] truncate text-xs text-muted-foreground">{data.prompt}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/canvas/nodes/base-node-wrapper.tsx
Normal file
37
components/canvas/nodes/base-node-wrapper.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface BaseNodeWrapperProps {
|
||||||
|
selected?: boolean;
|
||||||
|
status?: "idle" | "executing" | "done" | "error";
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClassMap: Record<NonNullable<BaseNodeWrapperProps["status"]>, string> = {
|
||||||
|
idle: "",
|
||||||
|
executing: "animate-pulse border-yellow-400",
|
||||||
|
done: "border-green-500",
|
||||||
|
error: "border-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BaseNodeWrapper({
|
||||||
|
selected,
|
||||||
|
status = "idle",
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: BaseNodeWrapperProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"rounded-xl border bg-card shadow-sm transition-shadow",
|
||||||
|
selected ? "ring-2 ring-primary shadow-md" : "",
|
||||||
|
statusClassMap[status],
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/canvas/nodes/compare-node.tsx
Normal file
50
components/canvas/nodes/compare-node.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type CompareNodeData = {
|
||||||
|
leftUrl?: string;
|
||||||
|
rightUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompareNode = Node<CompareNodeData, "compare">;
|
||||||
|
|
||||||
|
export default function CompareNode({ data, selected }: NodeProps<CompareNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-[500px] p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">Vergleich</div>
|
||||||
|
<div className="flex h-40 gap-2">
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
|
||||||
|
{data.leftUrl ? (
|
||||||
|
<img src={data.leftUrl} alt="Bild A" className="h-full w-full rounded object-cover" />
|
||||||
|
) : (
|
||||||
|
"Bild A"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
|
||||||
|
{data.rightUrl ? (
|
||||||
|
<img src={data.rightUrl} alt="Bild B" className="h-full w-full rounded object-cover" />
|
||||||
|
) : (
|
||||||
|
"Bild B"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
|
||||||
|
style={{ top: "40%" }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="right"
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
|
||||||
|
style={{ top: "60%" }}
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/canvas/nodes/frame-node.tsx
Normal file
28
components/canvas/nodes/frame-node.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type FrameNodeData = {
|
||||||
|
label?: string;
|
||||||
|
exportWidth?: number;
|
||||||
|
exportHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrameNode = Node<FrameNodeData, "frame">;
|
||||||
|
|
||||||
|
export default function FrameNode({ data, selected }: NodeProps<FrameNode>) {
|
||||||
|
const resolution =
|
||||||
|
data.exportWidth && data.exportHeight
|
||||||
|
? `${data.exportWidth}x${data.exportHeight}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="min-h-[200px] min-w-[300px] border-blue-500/30 p-3">
|
||||||
|
<div className="text-xs font-medium text-blue-500">
|
||||||
|
{data.label || "Frame"} {resolution ? `(${resolution})` : ""}
|
||||||
|
</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/canvas/nodes/group-node.tsx
Normal file
15
components/canvas/nodes/group-node.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type GroupNode = Node<{ label?: string }, "group">;
|
||||||
|
|
||||||
|
export default function GroupNode({ data, selected }: NodeProps<GroupNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="min-h-[150px] min-w-[200px] border-dashed p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">{data.label || "Gruppe"}</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/canvas/nodes/image-node.tsx
Normal file
38
components/canvas/nodes/image-node.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type ImageNodeData = {
|
||||||
|
storageId?: string;
|
||||||
|
url?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageNode = Node<ImageNodeData, "image">;
|
||||||
|
|
||||||
|
export default function ImageNode({ data, selected }: NodeProps<ImageNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">Bild</div>
|
||||||
|
{data.url ? (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={data.originalFilename ?? "Bild"}
|
||||||
|
className="max-w-[280px] rounded-lg object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
|
||||||
|
Bild hochladen oder URL einfuegen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/canvas/nodes/note-node.tsx
Normal file
20
components/canvas/nodes/note-node.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type NoteNodeData = {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteNode = Node<NoteNodeData, "note">;
|
||||||
|
|
||||||
|
export default function NoteNode({ data, selected }: NodeProps<NoteNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-52 p-3">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">Notiz</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{data.content || "Leere Notiz"}</p>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/canvas/nodes/prompt-node.tsx
Normal file
29
components/canvas/nodes/prompt-node.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type PromptNodeData = {
|
||||||
|
content?: string;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptNode = Node<PromptNodeData, "prompt">;
|
||||||
|
|
||||||
|
export default function PromptNode({ data, selected }: NodeProps<PromptNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-72 border-purple-500/30 p-3">
|
||||||
|
<div className="mb-1 text-xs font-medium text-purple-500">Prompt</div>
|
||||||
|
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Prompt eingeben..."}</p>
|
||||||
|
{data.model ? (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">Modell: {data.model}</div>
|
||||||
|
) : null}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-purple-500"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/canvas/nodes/text-node.tsx
Normal file
25
components/canvas/nodes/text-node.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
export type TextNodeData = {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextNode = Node<TextNodeData, "text">;
|
||||||
|
|
||||||
|
export default function TextNode({ data, selected }: NodeProps<TextNode>) {
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper selected={selected} className="w-64 p-3">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">Text</div>
|
||||||
|
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Text eingeben..."}</p>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
lib/canvas-utils.ts
Normal file
35
lib/canvas-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { Doc } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
export function convexNodeToRF(node: Doc<"nodes">): RFNode {
|
||||||
|
return {
|
||||||
|
id: node._id,
|
||||||
|
type: node.type,
|
||||||
|
position: {
|
||||||
|
x: node.positionX,
|
||||||
|
y: node.positionY,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(typeof node.data === "object" && node.data !== null ? node.data : {}),
|
||||||
|
status: node.status,
|
||||||
|
statusMessage: node.statusMessage,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
},
|
||||||
|
zIndex: node.zIndex,
|
||||||
|
parentId: node.parentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
||||||
|
return {
|
||||||
|
id: edge._id,
|
||||||
|
source: edge.sourceNodeId,
|
||||||
|
target: edge.targetNodeId,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/better-auth": "^0.11.3",
|
"@convex-dev/better-auth": "^0.11.3",
|
||||||
"@daveyplate/better-auth-ui": "^3.4.0",
|
"@daveyplate/better-auth-ui": "^3.4.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
215
pnpm-lock.yaml
generated
215
pnpm-lock.yaml
generated
@@ -14,6 +14,15 @@ importers:
|
|||||||
'@daveyplate/better-auth-ui':
|
'@daveyplate/better-auth-ui':
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0(aed41ba285ba33af5b1a54a1a4efb176)
|
version: 3.4.0(aed41ba285ba33af5b1a54a1a4efb176)
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.4)
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.10.1
|
||||||
|
version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.5.6(@opentelemetry/api@1.9.0)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.5.6(@opentelemetry/api@1.9.0)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -376,6 +385,22 @@ packages:
|
|||||||
tailwindcss: '>=3.0.0'
|
tailwindcss: '>=3.0.0'
|
||||||
zod: '>=3.0.0'
|
zod: '>=3.0.0'
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@dotenvx/dotenvx@1.57.2':
|
'@dotenvx/dotenvx@1.57.2':
|
||||||
resolution: {integrity: sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==}
|
resolution: {integrity: sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2046,6 +2071,24 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -2244,6 +2287,15 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.1':
|
||||||
|
resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.75':
|
||||||
|
resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2509,6 +2561,9 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2653,6 +2708,44 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8:
|
damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
|
|
||||||
@@ -4701,6 +4794,21 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -5048,6 +5156,24 @@ snapshots:
|
|||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@dotenvx/dotenvx@1.57.2':
|
'@dotenvx/dotenvx@1.57.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@@ -6650,6 +6776,27 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -6830,6 +6977,29 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.75
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.75':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.8
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
@@ -7092,6 +7262,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
@@ -7191,6 +7363,42 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
@@ -9539,3 +9747,10 @@ snapshots:
|
|||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.14)(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
react: 19.2.4
|
||||||
|
|||||||
Reference in New Issue
Block a user