refactor(app): move auth gating and metadata to server-first patterns

This commit is contained in:
2026-04-03 18:55:13 +02:00
parent 9c8cd364b4
commit df2a6c1759
4 changed files with 309 additions and 328 deletions

View File

@@ -0,0 +1,247 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import {
ChevronDown,
Coins,
LayoutTemplate,
Monitor,
Moon,
Search,
Sun,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { CreditOverview } from "@/components/dashboard/credit-overview";
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import CanvasCard from "@/components/dashboard/canvas-card";
import { toast } from "@/lib/toast";
import { useAuthQuery } from "@/hooks/use-auth-query";
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 function DashboardPageClient() {
const t = useTranslations("toasts");
const router = useRouter();
const welcomeToastSentRef = useRef(false);
const { theme = "system", setTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
const canvases = useAuthQuery(
api.canvases.list,
session?.user && !isSessionPending ? {} : "skip",
);
const createCanvas = useMutation(api.canvases.create);
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
const [hasClientMounted, setHasClientMounted] = useState(false);
useEffect(() => {
setHasClientMounted(true);
}, []);
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName);
useEffect(() => {
if (!session?.user || welcomeToastSentRef.current) return;
const key = `ls-dashboard-welcome-${session.user.id}`;
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
welcomeToastSentRef.current = true;
sessionStorage.setItem(key, "1");
toast.success(t("auth.welcomeOnDashboard"));
}, [t, session?.user]);
const handleSignOut = async () => {
toast.info(t("auth.signedOut"));
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);
}
};
return (
<div className="min-h-full bg-background">
<header className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-4 px-6">
<div className="flex items-center gap-2.5 text-base font-semibold tracking-tight">
<Image
src="/logos/lemonspace-logo-v2-primary-rgb.svg"
alt=""
width={449}
height={86}
unoptimized
className="h-5 w-auto shrink-0"
aria-hidden
loading="eager"
/>
</div>
<div className="relative ml-8 hidden max-w-xs flex-1 sm:block">
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg bg-muted/60 pl-8 text-sm"
placeholder="Suchen…"
type="search"
disabled
/>
</div>
<div className="ml-auto flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 px-1.5">
<Avatar className="size-7">
<AvatarFallback className="bg-primary/12 text-xs font-medium text-primary">
{initials}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline">{displayName}</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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 />
<DropdownMenuItem disabled>Einstellungen</DropdownMenuItem>
<DropdownMenuItem disabled>Abrechnung</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut}>Abmelden</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 pt-10 pb-16">
<div className="mb-10">
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {displayName}</h1>
<p className="mt-1.5 text-muted-foreground">
Überblick über deine Credits und laufende Generierungen.
</p>
</div>
<section className="mb-12">
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Coins className="size-3.5 text-muted-foreground" />
Credit-Übersicht
</div>
<CreditOverview />
</section>
<section className="mb-12">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<LayoutTemplate className="size-3.5 text-muted-foreground" />
Arbeitsbereiche
</div>
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-muted-foreground"
type="button"
onClick={handleCreateWorkspace}
disabled={
isCreatingWorkspace ||
!hasClientMounted ||
isSessionPending ||
!session?.user
}
>
{isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
</Button>
</div>
{isSessionPending || canvases === undefined ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Arbeitsbereiche 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 Arbeitsbereich vorhanden. Mit Neuer Arbeitsbereich legst du den
ersten an.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
{canvases.map((canvas: Doc<"canvases">) => (
<CanvasCard
key={canvas._id}
canvas={canvas}
onNavigate={(id) => router.push(`/canvas/${id}`)}
/>
))}
</div>
)}
</section>
<section className="mb-12">
<RecentTransactions />
</section>
</main>
</div>
);
}

View File

@@ -1,263 +1,15 @@
"use client"; import { redirect } from "next/navigation";
import Image from "next/image"; import { isAuthenticated } from "@/lib/auth-server";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import {
ChevronDown,
Coins,
LayoutTemplate,
Monitor,
Moon,
Search,
Sun,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DashboardPageClient } from "./page-client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { CreditOverview } from "@/components/dashboard/credit-overview";
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import CanvasCard from "@/components/dashboard/canvas-card";
import { toast } from "@/lib/toast";
import { useAuthQuery } from "@/hooks/use-auth-query";
export default async function DashboardPage() {
const authenticated = await isAuthenticated();
function getInitials(nameOrEmail: string) { if (!authenticated) {
const normalized = nameOrEmail.trim(); redirect("/auth/sign-in");
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(); return <DashboardPageClient />;
}
export default function DashboardPage() {
const t = useTranslations('toasts');
const router = useRouter();
const welcomeToastSentRef = useRef(false);
const { theme = "system", setTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
const canvases = useAuthQuery(
api.canvases.list,
session?.user && !isSessionPending ? {} : "skip",
);
const createCanvas = useMutation(api.canvases.create);
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
const [hasClientMounted, setHasClientMounted] = useState(false);
useEffect(() => {
setHasClientMounted(true);
}, []);
useEffect(() => {
if (!isSessionPending && !session?.user) {
router.replace("/auth/sign-in");
}
}, [isSessionPending, router, session?.user]);
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName);
useEffect(() => {
if (!session?.user || welcomeToastSentRef.current) return;
const key = `ls-dashboard-welcome-${session.user.id}`;
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
welcomeToastSentRef.current = true;
sessionStorage.setItem(key, "1");
toast.success(t('auth.welcomeOnDashboard'));
}, [t, session?.user]);
const handleSignOut = async () => {
toast.info(t('auth.signedOut'));
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);
}
};
return (
<div className="min-h-full bg-background">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-4 px-6">
<div className="flex items-center gap-2.5 text-base font-semibold tracking-tight">
<Image
src="/logos/lemonspace-logo-v2-primary-rgb.svg"
alt=""
width={449}
height={86}
unoptimized
className="h-5 w-auto shrink-0"
aria-hidden
loading="eager"
/>
</div>
<div className="relative ml-8 hidden max-w-xs flex-1 sm:block">
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg bg-muted/60 pl-8 text-sm"
placeholder="Suchen…"
type="search"
disabled
/>
</div>
<div className="ml-auto flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 px-1.5">
<Avatar className="size-7">
<AvatarFallback className="bg-primary/12 text-xs font-medium text-primary">
{initials}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline">
{displayName}
</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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 />
<DropdownMenuItem disabled>Einstellungen</DropdownMenuItem>
<DropdownMenuItem disabled>Abrechnung</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut}>Abmelden</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 pt-10 pb-16">
{/* Greeting & Context */}
<div className="mb-10">
<h1 className="text-2xl font-semibold tracking-tight">
Guten Tag, {displayName}
</h1>
<p className="mt-1.5 text-muted-foreground">
Überblick über deine Credits und laufende Generierungen.
</p>
</div>
{/* Credits Overview */}
<section className="mb-12">
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Coins className="size-3.5 text-muted-foreground" />
Credit-Übersicht
</div>
<CreditOverview />
</section>
{/* Workspaces */}
<section className="mb-12">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<LayoutTemplate className="size-3.5 text-muted-foreground" />
Arbeitsbereiche
</div>
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-muted-foreground"
type="button"
onClick={handleCreateWorkspace}
disabled={
isCreatingWorkspace ||
!hasClientMounted ||
isSessionPending ||
!session?.user
}
>
{isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
</Button>
</div>
{isSessionPending || canvases === undefined ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Arbeitsbereiche 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 Arbeitsbereich vorhanden. Mit Neuer Arbeitsbereich legst du den
ersten an.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
{canvases.map((canvas: Doc<"canvases">) => (
<CanvasCard
key={canvas._id}
canvas={canvas}
onNavigate={(id) => router.push(`/canvas/${id}`)}
/>
))}
</div>
)}
</section>
{/* Recent Transactions */}
<section className="mb-12">
<RecentTransactions />
</section>
</main>
</div>
);
} }

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script";
import { Manrope } from "next/font/google"; import { Manrope } from "next/font/google";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import "./globals.css"; import "./globals.css";
@@ -11,8 +12,34 @@ import { getLocale, getMessages, getTimeZone } from "next-intl/server";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" }); const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", metadataBase: new URL("https://app.lemonspace.io"),
description: "Generated by create next app", title: {
default: "LemonSpace",
template: "%s | LemonSpace",
},
description: "LemonSpace is a platform for creating and sharing digital content with nodes.",
keywords: ["LemonSpace", "digital content", "platform", "nodes"],
authors: [{ name: "LemonSpace" }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
url: "https://app.lemonspace.io",
siteName: "LemonSpace",
title: "LemonSpace",
description: "LemonSpace is a platform for creating and sharing digital content with nodes.",
},
twitter: {
card: "summary_large_image",
title: "LemonSpace",
description: "LemonSpace is a platform for creating and sharing digital content with nodes.",
},
}; };
export default async function RootLayout({ export default async function RootLayout({
@@ -41,25 +68,12 @@ export default async function RootLayout({
suppressHydrationWarning suppressHydrationWarning
className={cn("h-full", "antialiased", "font-sans", manrope.variable)} className={cn("h-full", "antialiased", "font-sans", manrope.variable)}
> >
<head> <body className="min-h-full flex flex-col">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <Script
<meta
name="description"
content="Lemonspace is a platform for creating and sharing digital content with nodes."
/>
<meta name="keywords" content="Lemonspace, digital content, platform" />
<meta name="author" content="Lemonspace" />
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow" />
<meta name="bingbot" content="index, follow" />
<meta name="yandexbot" content="index, follow" />
<script
src="https://rybbit.matthias.lol/api/script.js" src="https://rybbit.matthias.lol/api/script.js"
data-site-id="bb1ac546eda7" data-site-id="bb1ac546eda7"
defer strategy="afterInteractive"
></script> />
</head>
<body className="min-h-full flex flex-col">
<Providers <Providers
initialToken={initialToken} initialToken={initialToken}
locale={locale} locale={locale}

View File

@@ -1,50 +1,19 @@
"use client";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { redirect } from "next/navigation";
import { toast } from "@/lib/toast";
export default function Home() { import { isAuthenticated } from "@/lib/auth-server";
const t = useTranslations('toasts');
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
if (isPending) { export default async function Home() {
return ( const authenticated = await isAuthenticated();
<main className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Laden...</p> if (authenticated) {
</main> redirect("/dashboard");
);
} }
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-4"> <main className="flex min-h-screen flex-col items-center justify-center gap-6 p-4">
<h1 className="text-4xl font-bold">🍋 LemonSpace</h1> <h1 className="text-4xl font-bold">🍋 LemonSpace</h1>
{session?.user ? (
<div className="flex flex-col items-center gap-4">
<p className="text-lg">
Willkommen, <span className="font-semibold">{session.user.name}</span>
</p>
<Link
href="/dashboard"
className="rounded-lg bg-primary px-6 py-3 text-primary-foreground hover:bg-primary/90"
>
Zum Dashboard
</Link>
<button
onClick={() => {
toast.info(t('auth.signedOut'));
void authClient.signOut().then(() => router.refresh());
}}
className="rounded-lg border border-border px-6 py-3 text-sm hover:bg-accent"
>
Abmelden
</button>
</div>
) : (
<div className="flex gap-4"> <div className="flex gap-4">
<Link <Link
href="/auth/sign-in" href="/auth/sign-in"
@@ -59,7 +28,6 @@ export default function Home() {
Registrieren Registrieren
</Link> </Link>
</div> </div>
)}
</main> </main>
); );
} }