feat: enhance dashboard and canvas page functionality

- Added theme support to the dashboard with light, dark, and system options.
- Improved canvas ID handling with validation and fetching logic.
- Updated layout component to suppress hydration warnings for better rendering.
- Refactored dashboard to include user session management and workspace creation functionality.
This commit is contained in:
Matthias
2026-03-25 15:32:20 +01:00
parent 056e60743d
commit d1834c5694
4 changed files with 156 additions and 50 deletions

View File

@@ -17,7 +17,24 @@ export default async function CanvasPage({
} }
const { canvasId } = await params; const { canvasId } = await params;
const typedCanvasId = canvasId as Id<"canvases">; let typedCanvasId: Id<"canvases">;
if (/^\d+$/.test(canvasId)) {
const oneBasedIndex = Number(canvasId);
if (!Number.isSafeInteger(oneBasedIndex) || oneBasedIndex < 1) {
notFound();
}
const canvases = await fetchAuthQuery(api.canvases.list, {});
const selectedCanvas = canvases[oneBasedIndex - 1];
if (!selectedCanvas) {
notFound();
}
typedCanvasId = selectedCanvas._id;
} else {
typedCanvasId = canvasId as Id<"canvases">;
}
try { try {
const canvas = await fetchAuthQuery(api.canvases.get, { const canvas = await fetchAuthQuery(api.canvases.get, {

View File

@@ -1,14 +1,21 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTheme } from "next-themes";
import { useMutation, useQuery } from "convex/react";
import { import {
Activity, Activity,
ArrowUpRight, ArrowUpRight,
ChevronDown, ChevronDown,
Coins, Coins,
LayoutTemplate, LayoutTemplate,
Monitor,
Moon,
Search, Search,
Sparkles, Sparkles,
Sun,
} from "lucide-react"; } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -19,11 +26,15 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const formatEurFromCents = (cents: number) => const formatEurFromCents = (cents: number) =>
@@ -71,12 +82,6 @@ const mockRuns = [
}, },
]; ];
const mockWorkspaces = [
{ name: "Sommer-Kampagne", nodes: 24, frames: 3, initial: "S" },
{ name: "Produktfotos", nodes: 11, frames: 2, initial: "P" },
{ name: "Social Variants", nodes: 8, frames: 1, initial: "V" },
];
function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) { function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) {
const base = "inline-block size-2 rounded-full"; const base = "inline-block size-2 rounded-full";
switch (status) { switch (status) {
@@ -109,7 +114,54 @@ function statusLabel(status: (typeof mockRuns)[0]["status"]) {
} }
} }
function getInitials(nameOrEmail: string) {
const normalized = nameOrEmail.trim();
if (!normalized) return "U";
const parts = normalized.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return normalized.slice(0, 2).toUpperCase();
}
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter();
const { theme = "system", setTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
const canvases = useQuery(
api.canvases.list,
session?.user && !isSessionPending ? {} : "skip",
);
const createCanvas = useMutation(api.canvases.create);
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName);
const handleSignOut = async () => {
await authClient.signOut();
router.replace("/auth/sign-in");
router.refresh();
};
const handleCreateWorkspace = async () => {
if (isCreatingWorkspace) return;
if (!session?.user) return;
setIsCreatingWorkspace(true);
try {
const canvasId = await createCanvas({
name: "Neuer Workspace",
description: "",
});
router.push(`/canvas/${canvasId}`);
} finally {
setIsCreatingWorkspace(false);
}
};
const balanceCents = 4320; const balanceCents = 4320;
const reservedCents = 180; const reservedCents = 180;
const monthlyPoolCents = 5000; const monthlyPoolCents = 5000;
@@ -151,22 +203,43 @@ export default function DashboardPage() {
<Button variant="ghost" size="sm" className="gap-2 px-1.5"> <Button variant="ghost" size="sm" className="gap-2 px-1.5">
<Avatar className="size-7"> <Avatar className="size-7">
<AvatarFallback className="bg-primary/12 text-xs font-medium text-primary"> <AvatarFallback className="bg-primary/12 text-xs font-medium text-primary">
MK {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="hidden text-sm font-medium md:inline"> <span className="hidden text-sm font-medium md:inline">
Mock Nutzer {displayName}
</span> </span>
<ChevronDown className="size-3.5 text-muted-foreground" /> <ChevronDown className="size-3.5 text-muted-foreground" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Account (Demo)</DropdownMenuLabel> <DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Theme
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={theme}
onValueChange={(value) => setTheme(value)}
>
<DropdownMenuRadioItem value="light">
<Sun className="size-4" />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="size-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="size-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem disabled>Einstellungen</DropdownMenuItem> <DropdownMenuItem disabled>Einstellungen</DropdownMenuItem>
<DropdownMenuItem disabled>Abrechnung</DropdownMenuItem> <DropdownMenuItem disabled>Abrechnung</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem disabled>Abmelden</DropdownMenuItem> <DropdownMenuItem onSelect={handleSignOut}>Abmelden</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -177,7 +250,7 @@ export default function DashboardPage() {
{/* Greeting & Context */} {/* Greeting & Context */}
<div className="mb-10"> <div className="mb-10">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Guten Tag, Mock Nutzer Guten Tag, {displayName}
</h1> </h1>
<p className="mt-1.5 text-muted-foreground"> <p className="mt-1.5 text-muted-foreground">
Überblick über deine Credits und laufende Generierungen. Überblick über deine Credits und laufende Generierungen.
@@ -268,35 +341,47 @@ export default function DashboardPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground" className="text-muted-foreground"
disabled type="button"
onClick={handleCreateWorkspace}
disabled={isCreatingWorkspace || isSessionPending || !session?.user}
> >
Neuer Workspace {isCreatingWorkspace ? "Erstelle..." : "Neuer Workspace"}
</Button> </Button>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> {isSessionPending || canvases === undefined ? (
{mockWorkspaces.map((ws) => ( <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
<button Workspaces werden geladen...
key={ws.name} </div>
className={cn( ) : canvases.length === 0 ? (
"group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all", <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
"hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4", Noch kein Workspace vorhanden. Mit &quot;Neuer Workspace&quot; legst du den
)} ersten an.
disabled </div>
> ) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary"> <div className="grid gap-3 sm:grid-cols-3">
{ws.initial} {canvases.map((canvas) => (
</div> <button
<div className="min-w-0 flex-1"> key={canvas._id}
<p className="truncate text-sm font-medium">{ws.name}</p> type="button"
<p className="mt-0.5 text-xs text-muted-foreground"> onClick={() => router.push(`/canvas/${canvas._id}`)}
{ws.nodes} Nodes · {ws.frames} Frames className={cn(
</p> "group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all",
</div> "hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4",
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" /> )}
</button> >
))} <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
</div> {canvas.name.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{canvas.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
</div>
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
</button>
))}
</div>
)}
</section> </section>
{/* Recent Activity */} {/* Recent Activity */}

View File

@@ -21,6 +21,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="de" lang="de"
suppressHydrationWarning
className={cn("h-full", "antialiased", "font-sans", manrope.variable)} className={cn("h-full", "antialiased", "font-sans", manrope.variable)}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">

View File

@@ -4,6 +4,7 @@ import { ReactNode } from "react";
import { ConvexReactClient } from "convex/react"; import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { AuthUIProvider } from "@daveyplate/better-auth-ui"; import { AuthUIProvider } from "@daveyplate/better-auth-ui";
import { ThemeProvider } from "next-themes";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -21,20 +22,22 @@ export function Providers({
const router = useRouter(); const router = useRouter();
return ( return (
<ConvexBetterAuthProvider <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
client={convex} <ConvexBetterAuthProvider
authClient={authClient} client={convex}
initialToken={initialToken}
>
<AuthUIProvider
authClient={authClient} authClient={authClient}
navigate={router.push} initialToken={initialToken}
replace={router.replace}
onSessionChange={() => router.refresh()}
Link={Link}
> >
{children} <AuthUIProvider
</AuthUIProvider> authClient={authClient}
</ConvexBetterAuthProvider> navigate={router.push}
replace={router.replace}
onSessionChange={() => router.refresh()}
Link={Link}
>
{children}
</AuthUIProvider>
</ConvexBetterAuthProvider>
</ThemeProvider>
); );
} }