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:
12
CLAUDE.md
12
CLAUDE.md
@@ -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
|
## Design Context
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ Server Component. Initialisiert:
|
|||||||
- Client-Helper: `authClient` aus `lib/auth-client.ts`
|
- Client-Helper: `authClient` aus `lib/auth-client.ts`
|
||||||
- Convex-Integration: `convex/auth.config.ts` + `convex/auth.ts`
|
- Convex-Integration: `convex/auth.config.ts` + `convex/auth.ts`
|
||||||
- Trusted Origins: `https://app.lemonspace.io`, `http://localhost:3000`
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ UI-Komponenten für die Startseite nach dem Login.
|
|||||||
|
|
||||||
| Datei | Zweck |
|
| 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 |
|
| `credit-overview.tsx` | Monatsverbrauch und verfügbare Credits als Balken-Visualisierung |
|
||||||
| `recent-transactions.tsx` | Liste der letzten Credit-Transaktionen |
|
| `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` |
|
| `credit-overview.tsx` | `api.credits.getBalance`, `api.credits.getUsageStats` |
|
||||||
| `recent-transactions.tsx` | `api.credits.getRecentTransactions` |
|
| `recent-transactions.tsx` | `api.credits.getRecentTransactions` |
|
||||||
|
|
||||||
|
## Mutations
|
||||||
|
|
||||||
|
| Komponente | Mutation |
|
||||||
|
|-----------|----------|
|
||||||
|
| `canvas-card.tsx` | `api.canvases.update`, `api.canvases.remove` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Layout-Seite
|
## Layout-Seite
|
||||||
|
|||||||
@@ -2,15 +2,23 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { useMutation } from "convex/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 { toast } from "@/lib/toast";
|
||||||
import { msg } from "@/lib/toast-messages";
|
import { msg } from "@/lib/toast-messages";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
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";
|
||||||
@@ -31,6 +39,9 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
const suppressCardNavigationRef = useRef(false);
|
const suppressCardNavigationRef = useRef(false);
|
||||||
const saveInFlightRef = useRef(false);
|
const saveInFlightRef = useRef(false);
|
||||||
const updateCanvas = useMutation(api.canvases.update);
|
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(() => {
|
const handleStartEdit = useCallback(() => {
|
||||||
suppressCardNavigationRef.current = true;
|
suppressCardNavigationRef.current = true;
|
||||||
@@ -98,7 +109,21 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
}
|
}
|
||||||
}, [isEditing, onNavigate, canvas._id]);
|
}, [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 (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@@ -124,7 +149,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
autoFocus
|
autoFocus
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="h-auto py-0.5 text-sm font-medium bg-transparent border px-1.5 focus-visible:ring-1"
|
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="truncate text-sm font-medium">{canvas.name}</p>
|
||||||
@@ -134,7 +159,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
|
|
||||||
{/* Actions - positioned to not overlap with content */}
|
{/* Actions - positioned to not overlap with content */}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex shrink-0 items-center gap-2 ml-2">
|
<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" />
|
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -142,7 +167,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
@@ -154,10 +179,50 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Umbenennen
|
Umbenennen
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => {
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Löschen
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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.
|
- `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.
|
||||||
|
|||||||
@@ -188,5 +188,7 @@ export const msg = {
|
|||||||
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
|
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
|
||||||
renameSuccess: { title: "Arbeitsbereich umbenannt" },
|
renameSuccess: { title: "Arbeitsbereich umbenannt" },
|
||||||
renameFailed: { title: "Umbenennen fehlgeschlagen" },
|
renameFailed: { title: "Umbenennen fehlgeschlagen" },
|
||||||
|
deleteSuccess: { title: "Arbeitsbereich gelöscht" },
|
||||||
|
deleteFailed: { title: "Löschen fehlgeschlagen" },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user