refactor(app): move auth gating and metadata to server-first patterns
This commit is contained in:
247
app/dashboard/page-client.tsx
Normal file
247
app/dashboard/page-client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
46
app/page.tsx
46
app/page.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user