Add delete functionality to canvas card with confirmation dialog

- Implemented delete action for canvas cards, including a confirmation dialog.
- Updated `canvas-card.tsx` to support renaming and deleting canvases.
- Enhanced documentation in `CLAUDE.md` to reflect new features and mutations.
- Added success and error toast messages for delete actions.
This commit is contained in:
Matthias
2026-04-01 12:09:01 +02:00
parent 0022b57c88
commit 34135d643e
6 changed files with 160 additions and 59 deletions

View File

@@ -17,6 +17,18 @@ Jeder Ordner hat eine eigene CLAUDE.md als Single Source of Truth:
---
## Auth-Status (Kurzüberblick)
- Better Auth ist aktiv (Convex-Integration).
- Login unterstützt:
- E-Mail + Passwort
- Magic Link (Better-Auth Plugin)
- Details und Caveats (inkl. `SITE_URL`/`APP_URL`-Origin-Thema) stehen in:
- `convex/CLAUDE.md`
- `app/CLAUDE.md`
---
## Design Context
### Users

View File

@@ -66,6 +66,8 @@ Server Component. Initialisiert:
- Client-Helper: `authClient` aus `lib/auth-client.ts`
- Convex-Integration: `convex/auth.config.ts` + `convex/auth.ts`
- Trusted Origins: `https://app.lemonspace.io`, `http://localhost:3000`
- Sign-In unterstützt `email+password` **und** `magic link` (`authClient.signIn.magicLink`)
- Dashboard-Route (`app/dashboard/page.tsx`) redirectet bei fehlender Session explizit nach `/auth/sign-in`, damit kein permanenter Loading-State entsteht
---

View File

@@ -8,7 +8,7 @@ UI-Komponenten für die Startseite nach dem Login.
| Datei | Zweck |
|-------|-------|
| `canvas-card.tsx` | Karte für einen Canvas in der Übersicht (Thumbnail, Name, Datum) |
| `canvas-card.tsx` | Karte für einen Canvas in der Übersicht (Navigation, Umbenennen, Löschen mit Confirm-Dialog) |
| `credit-overview.tsx` | Monatsverbrauch und verfügbare Credits als Balken-Visualisierung |
| `recent-transactions.tsx` | Liste der letzten Credit-Transaktionen |
@@ -24,6 +24,12 @@ Alle Daten kommen aus Convex-Queries via `useAuthQuery` (aus `hooks/use-auth-que
| `credit-overview.tsx` | `api.credits.getBalance`, `api.credits.getUsageStats` |
| `recent-transactions.tsx` | `api.credits.getRecentTransactions` |
## Mutations
| Komponente | Mutation |
|-----------|----------|
| `canvas-card.tsx` | `api.canvases.update`, `api.canvases.remove` |
---
## Layout-Seite

View File

@@ -2,15 +2,23 @@
import { useState, useCallback, useRef } from "react";
import { useMutation } from "convex/react";
import { ArrowUpRight, MoreHorizontal, Pencil } from "lucide-react";
import { ArrowUpRight, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@@ -31,6 +39,9 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
const suppressCardNavigationRef = useRef(false);
const saveInFlightRef = useRef(false);
const updateCanvas = useMutation(api.canvases.update);
const removeCanvas = useMutation(api.canvases.remove);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const handleStartEdit = useCallback(() => {
suppressCardNavigationRef.current = true;
@@ -98,66 +109,120 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
}
}, [isEditing, onNavigate, canvas._id]);
const handleDelete = useCallback(async () => {
setDeleteBusy(true);
try {
await removeCanvas({ canvasId: canvas._id });
toast.success(msg.dashboard.deleteSuccess.title);
setDeleteOpen(false);
} catch {
toast.error(msg.dashboard.deleteFailed.title);
} finally {
setDeleteBusy(false);
}
}, [canvas._id, removeCanvas]);
return (
<div
className={cn(
"group relative flex cursor-pointer 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",
isEditing && "ring-2 ring-primary/50"
)}
onClick={handleCardClick}
>
{/* Avatar */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
{canvas.name.slice(0, 1).toUpperCase()}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{isEditing ? (
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={isSaving}
autoFocus
onClick={(e) => e.stopPropagation()}
className="h-auto py-0.5 text-sm font-medium bg-transparent border px-1.5 focus-visible:ring-1"
/>
) : (
<p className="truncate text-sm font-medium">{canvas.name}</p>
<>
<div
className={cn(
"group relative flex cursor-pointer 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",
isEditing && "ring-2 ring-primary/50"
)}
onClick={handleCardClick}
>
{/* Avatar */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
{canvas.name.slice(0, 1).toUpperCase()}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{isEditing ? (
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={isSaving}
autoFocus
onClick={(e) => e.stopPropagation()}
className="h-auto border bg-transparent px-1.5 py-0.5 text-sm font-medium focus-visible:ring-1"
/>
) : (
<p className="truncate text-sm font-medium">{canvas.name}</p>
)}
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
</div>
{/* Actions - positioned to not overlap with content */}
{!isEditing && (
<div className="ml-2 flex shrink-0 items-center gap-2">
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Optionen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleStartEdit}>
<Pencil className="size-4" />
Umbenennen
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDeleteOpen(true);
}}
>
<Trash2 className="size-4" />
Löschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
</div>
{/* Actions - positioned to not overlap with content */}
{!isEditing && (
<div className="flex shrink-0 items-center gap-2 ml-2">
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Optionen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleStartEdit}>
<Pencil className="size-4" />
Umbenennen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>Arbeitsbereich löschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{canvas.name} und alle Knoten werden dauerhaft gelöscht. Das
lässt sich nicht rückgängig machen.
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteBusy}
>
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -116,6 +116,20 @@ optionalAuth(ctx) // → { userId: string } | null
Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genutzt, die User-Daten berühren.
### Better-Auth Setup (`auth.ts`)
- Auth-Library läuft in Convex (`createAuth`) und wird via `authComponent.registerRoutes(http, createAuth)` in `http.ts` registriert.
- Login-Modi:
- `emailAndPassword` (mit `requireEmailVerification: true`)
- `magicLink` Plugin (`better-auth/plugins`)
- Magic-Link-Konfiguration:
- `disableSignUp: true` (Magic Link nur für bestehende Accounts)
- `expiresIn: 600` (10 Minuten)
- Versand über Resend (`sendMagicLink`)
- **Wichtig für Multi-Domain-Setup (`SITE_URL` + `APP_URL`)**:
- Verify-Links aus Better Auth werden vor dem Versand auf die App-Origin umgeschrieben (`toAuthAppUrl(...)`), damit Session-Cookies auf der korrekten Origin gesetzt werden.
- Ohne dieses Umschreiben kann Login per Magic Link zwar erfolgreich sein, aber das Dashboard in einem permanenten Loading-State hängen (fehlende Session auf App-Origin).
### Auth-Race-Härtung
- `canvases.get` nutzt optionalen Auth-Check und gibt bei fehlender Session `null` zurück (statt Throw), damit SSR/Client-Hydration bei kurzem Token-Race nicht in `404` kippt.

View File

@@ -188,5 +188,7 @@ export const msg = {
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
renameSuccess: { title: "Arbeitsbereich umbenannt" },
renameFailed: { title: "Umbenennen fehlgeschlagen" },
deleteSuccess: { title: "Arbeitsbereich gelöscht" },
deleteFailed: { title: "Löschen fehlgeschlagen" },
},
} as const;