feat: dashboard, Convex auth, UI components, and LemonSpace branding

- Add dashboard shell with auth integration
- Wire Better Auth / Convex (client, server, HTTP routes)
- Add shadcn-style UI primitives and logo assets
- Update global styles and landing page
- Add internal docs (.docs)

Made-with: Cursor
This commit is contained in:
Matthias
2026-03-25 10:15:42 +01:00
parent 2cead5e87b
commit cd857a01f5
42 changed files with 24599 additions and 118 deletions

View File

@@ -0,0 +1,3 @@
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;

349
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,349 @@
"use client"
import Image from "next/image"
import {
Activity,
ArrowUpRight,
ChevronDown,
Coins,
LayoutTemplate,
Search,
Sparkles,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
const formatEurFromCents = (cents: number) =>
new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(cents / 100)
const mockRuns = [
{
id: "run-8841",
workspace: "Sommer-Kampagne",
node: "KI-Bild",
model: "flux-pro",
status: "done" as const,
credits: 42,
updated: "vor 12 Min.",
},
{
id: "run-8839",
workspace: "Produktfotos",
node: "KI-Bild",
model: "flux-schnell",
status: "executing" as const,
credits: 18,
updated: "gerade eben",
},
{
id: "run-8832",
workspace: "Social Variants",
node: "Compare",
model: "—",
status: "idle" as const,
credits: 0,
updated: "vor 1 Std.",
},
{
id: "run-8828",
workspace: "Sommer-Kampagne",
node: "KI-Bild",
model: "flux-pro",
status: "error" as const,
credits: 0,
updated: "vor 2 Std.",
},
]
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"] }) {
const base = "inline-block size-2 rounded-full"
switch (status) {
case "done":
return <span className={cn(base, "bg-primary")} />
case "executing":
return (
<span className="relative inline-flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className={cn(base, "relative bg-primary")} />
</span>
)
case "idle":
return <span className={cn(base, "bg-border")} />
case "error":
return <span className={cn(base, "bg-destructive")} />
}
}
function statusLabel(status: (typeof mockRuns)[0]["status"]) {
switch (status) {
case "done":
return "Fertig"
case "executing":
return "Läuft"
case "idle":
return "Bereit"
case "error":
return "Fehler"
}
}
export default function DashboardPage() {
const balanceCents = 4320
const reservedCents = 180
const monthlyPoolCents = 5000
const usagePercent = Math.round(
((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100,
)
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
/>
</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">
MK
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline">
Mock Nutzer
</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Account (Demo)</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>Einstellungen</DropdownMenuItem>
<DropdownMenuItem disabled>Abrechnung</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>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, Mock Nutzer
</h1>
<p className="mt-1.5 text-muted-foreground">
Überblick über deine Credits und laufende Generierungen.
</p>
</div>
{/* Credits & Active Generation — asymmetric two-column */}
<div className="mb-12 grid gap-6 lg:grid-cols-[1fr_1.2fr]">
{/* Credits Section */}
<div className="space-y-5">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Coins className="size-3.5" />
<span>Credit-Guthaben</span>
</div>
<div className="text-4xl font-semibold tabular-nums tracking-tight">
{formatEurFromCents(balanceCents)}
</div>
<div className="space-y-3 pt-1">
<div className="flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Reserviert</span>
<span className="tabular-nums font-medium">
{formatEurFromCents(reservedCents)}
</span>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Monatskontingent</span>
<span className="tabular-nums text-muted-foreground">
{usagePercent}%
</span>
</div>
<Progress value={usagePercent} className="h-1.5" />
</div>
</div>
<p className="text-xs leading-relaxed text-muted-foreground/80">
Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch freigegeben.
</p>
</div>
{/* Active Generation */}
<div className="rounded-2xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="size-3.5" />
<span>Aktive Generierung</span>
</div>
<Badge className="gap-1.5 font-normal">
<span className="relative inline-flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground/60 opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
Läuft
</Badge>
</div>
<h2 className="mt-4 text-lg font-medium">
Produktfotos Variante 3/4
</h2>
<div className="mt-5">
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Fortschritt</span>
<span className="font-medium tabular-nums">62%</span>
</div>
<Progress value={62} className="h-1.5" />
</div>
<p className="mt-4 text-xs text-muted-foreground leading-relaxed">
Step 2 von 4 {" "}
<span className="font-mono text-[0.7rem]">
flux-schnell
</span>
</p>
</div>
</div>
{/* 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" />
Workspaces
</div>
<Button variant="ghost" size="sm" className="text-muted-foreground" disabled>
Neuer Workspace
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{mockWorkspaces.map((ws) => (
<button
key={ws.name}
className={cn(
"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"
)}
disabled
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
{ws.initial}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{ws.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{ws.nodes} Nodes · {ws.frames} Frames
</p>
</div>
<ArrowUpRight className="size-4 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
</button>
))}
</div>
</section>
{/* Recent Activity */}
<section>
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Letzte Aktivität
</div>
<div className="rounded-xl border bg-card shadow-sm shadow-foreground/3">
<div className="divide-y">
{mockRuns.map((run) => (
<div
key={run.id}
className="flex items-center gap-4 px-5 py-3.5"
>
<StatusDot status={run.status} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-medium">
{run.workspace}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{run.node}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{run.model !== "—" && (
<span className="font-mono text-[0.7rem]">{run.model}</span>
)}
{run.credits > 0 && (
<>
<span aria-hidden>·</span>
<span className="tabular-nums">{run.credits} ct</span>
</>
)}
</div>
</div>
<div className="shrink-0 text-right">
<span className="text-xs text-muted-foreground">
{statusLabel(run.status)}
</span>
<p className="mt-0.5 text-[0.7rem] text-muted-foreground/70">
{run.updated}
</p>
</div>
</div>
))}
</div>
</div>
</section>
</main>
</div>
)
}

View File

@@ -49,72 +49,72 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.511 0.096 186.391);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.18 0.012 60);
--card: oklch(0.993 0.003 80);
--card-foreground: oklch(0.18 0.012 60);
--popover: oklch(0.993 0.003 80);
--popover-foreground: oklch(0.18 0.012 60);
--primary: oklch(0.52 0.09 178);
--primary-foreground: oklch(0.985 0.01 178);
--secondary: oklch(0.955 0.01 82);
--secondary-foreground: oklch(0.25 0.012 60);
--muted: oklch(0.96 0.008 80);
--muted-foreground: oklch(0.52 0.015 60);
--accent: oklch(0.92 0.1 95);
--accent-foreground: oklch(0.3 0.04 80);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--border: oklch(0.91 0.01 75);
--input: oklch(0.91 0.01 75);
--ring: oklch(0.52 0.09 178);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
--radius: 0.625rem;
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.6 0.118 184.704);
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
--radius: 0.75rem;
--sidebar: oklch(0.975 0.006 80);
--sidebar-foreground: oklch(0.18 0.012 60);
--sidebar-primary: oklch(0.52 0.09 178);
--sidebar-primary-foreground: oklch(0.985 0.01 178);
--sidebar-accent: oklch(0.96 0.008 80);
--sidebar-accent-foreground: oklch(0.25 0.012 60);
--sidebar-border: oklch(0.91 0.01 75);
--sidebar-ring: oklch(0.52 0.09 178);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--background: oklch(0.16 0.01 65);
--foreground: oklch(0.93 0.008 80);
--card: oklch(0.21 0.012 65);
--card-foreground: oklch(0.93 0.008 80);
--popover: oklch(0.21 0.012 65);
--popover-foreground: oklch(0.93 0.008 80);
--primary: oklch(0.62 0.1 178);
--primary-foreground: oklch(0.15 0.03 178);
--secondary: oklch(0.26 0.01 65);
--secondary-foreground: oklch(0.92 0.006 80);
--muted: oklch(0.24 0.01 65);
--muted-foreground: oklch(0.65 0.012 70);
--accent: oklch(0.35 0.06 90);
--accent-foreground: oklch(0.93 0.008 80);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.62 0.1 178);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.704 0.14 182.503);
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
--sidebar: oklch(0.19 0.01 65);
--sidebar-foreground: oklch(0.93 0.008 80);
--sidebar-primary: oklch(0.62 0.1 178);
--sidebar-primary-foreground: oklch(0.15 0.03 178);
--sidebar-accent: oklch(0.24 0.01 65);
--sidebar-accent-foreground: oklch(0.93 0.008 80);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.62 0.1 178);
}
@layer base {

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono, Manrope } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { ConvexClientProvider } from "@/components/ui/convex-prover";
const manrope = Manrope({subsets:['latin'],variable:'--font-sans'});
@@ -30,7 +31,9 @@ export default function RootLayout({
lang="en"
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", manrope.variable)}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}

View File

@@ -1,65 +1,38 @@
import Image from "next/image";
// src/app/page.tsx — minimaler Test
"use client";
import { authClient } from "@/lib/auth-client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export default function Home() {
const user = useQuery(api.auth.getCurrentUser);
// user === undefined → Query lädt noch
// user === null → Nicht eingeloggt
// user === { id, name, email, ... } → Eingeloggt
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div>
<h1>LemonSpace</h1>
{user ? (
<div>
<p>Eingeloggt als: {user.name}</p>
<button onClick={() => authClient.signOut()}>Logout</button>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
) : (
<button
onClick={() =>
authClient.signUp.email({
email: "test@lemonspace.io",
password: "test1234",
name: "Test User",
})
}
>
Test Signup
</button>
)}
</div>
);
}