feat: add Resend integration for email verification and update package dependencies
- Integrated Resend for sending email verification upon user sign-up. - Updated package.json to include Resend as a dependency. - Enhanced authentication flow with error handling for email sending. - Removed outdated Canvas Implementation Guide documentation.
This commit is contained in:
@@ -1,794 +0,0 @@
|
||||
# 🍋 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)
|
||||
@@ -1,9 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import Canvas from "@/components/canvas/canvas";
|
||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { isAuthenticated } from "@/lib/auth-server";
|
||||
import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server";
|
||||
|
||||
export default async function CanvasPage({
|
||||
params,
|
||||
@@ -18,6 +19,17 @@ export default async function CanvasPage({
|
||||
const { canvasId } = await params;
|
||||
const typedCanvasId = canvasId as Id<"canvases">;
|
||||
|
||||
try {
|
||||
const canvas = await fetchAuthQuery(api.canvases.get, {
|
||||
canvasId: typedCanvasId,
|
||||
});
|
||||
if (!canvas) {
|
||||
notFound();
|
||||
}
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-screen overflow-hidden">
|
||||
<CanvasToolbar canvasId={typedCanvasId} />
|
||||
|
||||
101
app/auth/sign-in/page.tsx
Normal file
101
app/auth/sign-in/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Anmeldung fehlgeschlagen");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch {
|
||||
setError("Ein unerwarteter Fehler ist aufgetreten");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-xl border bg-card p-8 shadow-sm">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">Willkommen zurück 🍋</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Melde dich bei LemonSpace an
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="name@beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Dein Passwort"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Wird angemeldet…" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Noch kein Konto?{" "}
|
||||
<Link href="/auth/sign-up" className="font-medium text-primary hover:underline">
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
app/auth/sign-up/page.tsx
Normal file
143
app/auth/sign-up/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Registrierung fehlgeschlagen");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch {
|
||||
setError("Ein unerwarteter Fehler ist aufgetreten");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-4 rounded-xl border bg-card p-8 shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-3">📧</div>
|
||||
<h1 className="text-xl font-semibold">E-Mail bestätigen</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Wir haben dir eine E-Mail an <strong>{email}</strong> geschickt.
|
||||
Klicke auf den Link, um dein Konto zu aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/auth/sign-in")}
|
||||
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Zum Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-xl border bg-card p-8 shadow-sm">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">Konto erstellen 🍋</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Erstelle dein LemonSpace-Konto
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignUp} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1.5">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="name@beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Wird erstellt…" : "Konto erstellen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Bereits ein Konto?{" "}
|
||||
<Link href="/auth/sign-in" className="font-medium text-primary hover:underline">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -29,6 +31,12 @@ export default function Home() {
|
||||
>
|
||||
Zum Dashboard
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => authClient.signOut().then(() => router.refresh())}
|
||||
className="rounded-lg border border-border px-6 py-3 text-sm hover:bg-accent"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -146,9 +146,9 @@ export default function Canvas({ canvasId }: CanvasProps) {
|
||||
className="bg-background"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="!rounded-lg !border !bg-card !shadow-sm" />
|
||||
<Controls className="rounded-lg! border! bg-card! shadow-sm!" />
|
||||
<MiniMap
|
||||
className="!rounded-lg !border !bg-card !shadow-sm"
|
||||
className="rounded-lg! border! bg-card! shadow-sm!"
|
||||
nodeColor="#6366f1"
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
@@ -4,9 +4,11 @@ import { components } from "./_generated/api";
|
||||
import { DataModel } from "./_generated/dataModel";
|
||||
import { query } from "./_generated/server";
|
||||
import { betterAuth } from "better-auth/minimal";
|
||||
import { Resend } from "resend";
|
||||
import authConfig from "./auth.config";
|
||||
|
||||
const siteUrl = process.env.SITE_URL!;
|
||||
const appUrl = process.env.APP_URL;
|
||||
|
||||
// Component Client — stellt Adapter, Helper und Auth-Methoden bereit
|
||||
export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||
@@ -15,11 +17,54 @@ export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||
export const createAuth = (ctx: GenericCtx<DataModel>) => {
|
||||
return betterAuth({
|
||||
baseURL: siteUrl,
|
||||
trustedOrigins: [siteUrl],
|
||||
trustedOrigins: [siteUrl, "http://localhost:3000"],
|
||||
database: authComponent.adapter(ctx),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // Später auf true → useSend Integration
|
||||
requireEmailVerification: true,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
const verificationUrl = new URL(url);
|
||||
|
||||
if (appUrl) {
|
||||
verificationUrl.searchParams.set("callbackURL", `${appUrl}/dashboard`);
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("RESEND_API_KEY is not set — skipping verification email");
|
||||
return;
|
||||
}
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
const { error } = await resend.emails.send({
|
||||
from: "LemonSpace <noreply@lemonspace.io>",
|
||||
to: user.email,
|
||||
subject: "Bestätige deine E-Mail-Adresse",
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
|
||||
<h2>Willkommen bei LemonSpace 🍋</h2>
|
||||
<p>Hi ${user.name || ""},</p>
|
||||
<p>Klicke auf den Button, um deine E-Mail-Adresse zu bestätigen:</p>
|
||||
<a href="${verificationUrl.toString()}"
|
||||
style="display: inline-block; background: #facc15; color: #1a1a1a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin: 16px 0;">
|
||||
E-Mail bestätigen
|
||||
</a>
|
||||
<p style="color: #666; font-size: 13px;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link:<br/>
|
||||
<a href="${verificationUrl.toString()}">${verificationUrl.toString()}</a>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to send verification email:", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
convex({ authConfig }),
|
||||
@@ -31,6 +76,17 @@ export const createAuth = (ctx: GenericCtx<DataModel>) => {
|
||||
export const getCurrentUser = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return authComponent.safeGetAuthUser(ctx);
|
||||
return authComponent.getAuthUser(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
export const safeGetAuthUser = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
try {
|
||||
return await authComponent.getAuthUser(ctx);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"resend": "^4.8.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
resend:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
shadcn:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@20.19.37)(typescript@5.9.3)
|
||||
@@ -1842,6 +1845,13 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@react-email/render@1.1.2':
|
||||
resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@react-email/render@2.0.4':
|
||||
resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -3108,6 +3118,9 @@ packages:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-deep-equal@2.0.1:
|
||||
resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4163,6 +4176,9 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-promise-suspense@0.3.4:
|
||||
resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
|
||||
|
||||
react-qr-code@2.0.18:
|
||||
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
|
||||
peerDependencies:
|
||||
@@ -4228,6 +4244,10 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resend@4.8.0:
|
||||
resolution: {integrity: sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -6582,6 +6602,14 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
html-to-text: 9.0.5
|
||||
prettier: 3.8.1
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-promise-suspense: 0.3.4
|
||||
|
||||
'@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
html-to-text: 9.0.5
|
||||
@@ -7951,6 +7979,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-deep-equal@2.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
@@ -8981,6 +9011,10 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-promise-suspense@0.3.4:
|
||||
dependencies:
|
||||
fast-deep-equal: 2.0.1
|
||||
|
||||
react-qr-code@2.0.18(react@19.2.4):
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
@@ -9052,6 +9086,13 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resend@4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@react-email/render': 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user