diff --git a/.docs/Canvas_Implementation_Guide.md b/.docs/Canvas_Implementation_Guide.md deleted file mode 100644 index 27160bf..0000000 --- a/.docs/Canvas_Implementation_Guide.md +++ /dev/null @@ -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 ( -
- {children} -
- ); -} -``` - -### 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; - -export default function NoteNode({ data, selected }: NodeProps) { - return ( - -
📌 Notiz
-

- {data.content || 'Leere Notiz'} -

-
- ); -} -``` - -### 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; - -export default function ImageNode({ data, selected }: NodeProps) { - return ( - -
đŸ–Œïž Bild
- {data.url ? ( - {data.filename - ) : ( -
- Bild hochladen oder URL einfĂŒgen -
- )} - -
- ); -} -``` - -### 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; - -export default function TextNode({ data, selected }: NodeProps) { - return ( - -
📝 Text
-

- {data.content || 'Text eingeben
'} -

- -
- ); -} -``` - -### 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; - -export default function PromptNode({ data, selected }: NodeProps) { - return ( - -
✹ Prompt
-

- {data.prompt || 'Prompt eingeben
'} -

- {data.model && ( -
- Modell: {data.model} -
- )} - {/* Nur Source — verbindet sich ausschließlich mit KI-Nodes */} - -
- ); -} -``` - -### 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; - -export default function AiImageNode({ data, selected }: NodeProps) { - const status = data.status ?? 'idle'; - - return ( - -
đŸ€– KI-Bild
- - {status === 'executing' && ( -
-
-
- )} - - {status === 'done' && data.url && ( - {data.prompt - )} - - {status === 'error' && ( -
- {data.error ?? 'Fehler bei der Generierung'} -
- )} - - {status === 'idle' && ( -
- Prompt verbinden -
- )} - - {data.prompt && status === 'done' && ( -

- {data.prompt} -

- )} - - {/* Target: EmpfĂ€ngt Input von Prompt/Bild */} - - {/* Source: Output weitergeben (an Compare, Frame, etc.) */} - - - ); -} -``` - -### 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) { - return ( - -
📁 {data.label || 'Gruppe'}
-
- ); -} -``` - -```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) { - return ( - -
- đŸ–„ïž {data.label || 'Frame'} {data.resolution && `(${data.resolution})`} -
-
- ); -} -``` - -```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) { - return ( - -
🔀 Vergleich
-
-
- {data.leftUrl ? : 'Bild A'} -
-
- {data.rightUrl ? : 'Bild B'} -
-
- - -
- ); -} -``` - ---- - -## 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([]); - const [edges, setEdges] = useState([]); - - // 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(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 ( -
-
-
- Canvas lÀdt
 -
-
- ); - } - - return ( -
- - - - - -
- ); -} -``` - ---- - -## 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) => { - // 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 ( -
- {nodeTemplates.map((t) => ( - - ))} -
- ); -} -``` - ---- - -## 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 ( -
- - -
- ); -} -``` - ---- - -## 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/` (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) diff --git a/app/(app)/canvas/[canvasId]/page.tsx b/app/(app)/canvas/[canvasId]/page.tsx index ef6eaad..c1b586d 100644 --- a/app/(app)/canvas/[canvasId]/page.tsx +++ b/app/(app)/canvas/[canvasId]/page.tsx @@ -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 (
diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx new file mode 100644 index 0000000..af732f7 --- /dev/null +++ b/app/auth/sign-in/page.tsx @@ -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 ( +
+
+
+

Willkommen zurĂŒck 🍋

+

+ Melde dich bei LemonSpace an +

+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Noch kein Konto?{" "} + + Registrieren + +

+
+
+ ); +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..f756ee7 --- /dev/null +++ b/app/auth/sign-up/page.tsx @@ -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 ( +
+
+
+
📧
+

E-Mail bestÀtigen

+

+ Wir haben dir eine E-Mail an {email} geschickt. + Klicke auf den Link, um dein Konto zu aktivieren. +

+
+ +
+
+ ); + } + + return ( +
+
+
+

Konto erstellen 🍋

+

+ Erstelle dein LemonSpace-Konto +

+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Bereits ein Konto?{" "} + + Anmelden + +

+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 452ada3..59c5b8e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 +
) : (
diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 28b7cab..f839ab3 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -146,9 +146,9 @@ export default function Canvas({ canvasId }: CanvasProps) { className="bg-background" > - + diff --git a/convex/auth.ts b/convex/auth.ts index a855bcd..6cea815 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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(components.betterAuth); @@ -15,11 +17,54 @@ export const authComponent = createClient(components.betterAuth); export const createAuth = (ctx: GenericCtx) => { 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 ", + to: user.email, + subject: "BestĂ€tige deine E-Mail-Adresse", + html: ` +
+

Willkommen bei LemonSpace 🍋

+

Hi ${user.name || ""},

+

Klicke auf den Button, um deine E-Mail-Adresse zu bestÀtigen:

+ + E-Mail bestÀtigen + +

+ Falls der Button nicht funktioniert, kopiere diesen Link:
+ ${verificationUrl.toString()} +

+
+ `, + }); + + if (error) { + console.error("Failed to send verification email:", error); + } + }, }, plugins: [ convex({ authConfig }), @@ -31,6 +76,17 @@ export const createAuth = (ctx: GenericCtx) => { 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; + } }, }); diff --git a/package.json b/package.json index b4b2476..082558b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4b3554..46af5e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}