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:
@@ -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, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{isSessionPending || canvases === undefined ? (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
|
||||||
|
Workspaces werden geladen...
|
||||||
|
</div>
|
||||||
|
) : canvases.length === 0 ? (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
|
||||||
|
Noch kein Workspace vorhanden. Mit "Neuer Workspace" legst du den
|
||||||
|
ersten an.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{mockWorkspaces.map((ws) => (
|
{canvases.map((canvas) => (
|
||||||
<button
|
<button
|
||||||
key={ws.name}
|
key={canvas._id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/canvas/${canvas._id}`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all",
|
"group flex 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",
|
"hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4",
|
||||||
)}
|
)}
|
||||||
disabled
|
|
||||||
>
|
>
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
|
||||||
{ws.initial}
|
{canvas.name.slice(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{ws.name}</p>
|
<p className="truncate text-sm font-medium">{canvas.name}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
|
||||||
{ws.nodes} Nodes · {ws.frames} Frames
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,6 +22,7 @@ export function Providers({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ConvexBetterAuthProvider
|
<ConvexBetterAuthProvider
|
||||||
client={convex}
|
client={convex}
|
||||||
authClient={authClient}
|
authClient={authClient}
|
||||||
@@ -36,5 +38,6 @@ export function Providers({
|
|||||||
{children}
|
{children}
|
||||||
</AuthUIProvider>
|
</AuthUIProvider>
|
||||||
</ConvexBetterAuthProvider>
|
</ConvexBetterAuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user