Auth fertig erstellt. Aktuell läuft review durch KI

This commit is contained in:
2026-08-29 14:07:44 +02:00
commit 625d026f59
70 changed files with 17032 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"projectName": "webkulisse-saas",
"mode": "file-router",
"typescript": true,
"packageManager": "npm",
"includeExamples": false,
"tailwind": true,
"projectPreset": "default",
"addOnOptions": {
"drizzle": {
"database": "postgresql"
}
},
"git": true,
"install": true,
"intent": true,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"biome",
"nitro",
"compiler",
"drizzle",
"shadcn",
"better-auth"
]
}
+7
View File
@@ -0,0 +1,7 @@
# shadcn instructions
Use the latest version of Shadcn to install new components, like this command to add a button component:
```bash
pnpm dlx shadcn@latest add button
```
+14
View File
@@ -0,0 +1,14 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
scripts
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/webkulisse_saas.iml" filepath="$PROJECT_DIR$/.idea/webkulisse_saas.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+35
View File
@@ -0,0 +1,35 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
}
}
+103
View File
@@ -0,0 +1,103 @@
<!-- intent-skills:start -->
# TanStack Intent - before editing files, run the matching guidance command.
tanstackIntent:
- id: "@tanstack/devtools#devtools-app-setup"
run: "npx @tanstack/intent@latest load @tanstack/devtools#devtools-app-setup"
for: "Install TanStack Devtools, pick framework adapter (React/Vue/Solid/Preact), register plugins via plugins prop, configure shell (position, hotkeys, theme, hideUntilHover, requireUrlFlag, eventBusConfig). TanStackDevtools component, defaultOpen, localStorage persistence."
- id: "@tanstack/devtools#devtools-marketplace"
run: "npx @tanstack/intent@latest load @tanstack/devtools#devtools-marketplace"
for: "Publish plugin to npm and submit to TanStack Devtools Marketplace. PluginMetadata registry format, plugin-registry.ts, pluginImport (importName, type), requires (packageName, minVersion), framework tagging, multi-framework submissions, featured plugins."
- id: "@tanstack/devtools#devtools-plugin-panel"
run: "npx @tanstack/intent@latest load @tanstack/devtools#devtools-plugin-panel"
for: "Build devtools panel components that display emitted event data. Listen via EventClient.on(), handle theme (light/dark), use @tanstack/devtools-ui components. Plugin registration (name, render, id, defaultOpen), lifecycle (mount, activate, destroy), max 3 active plugins. Two paths: Solid.js core with devtools-ui for multi-framework support, or framework-specific panels."
- id: "@tanstack/devtools#devtools-production"
run: "npx @tanstack/intent@latest load @tanstack/devtools#devtools-production"
for: "Handle devtools in production vs development. removeDevtoolsOnBuild, devDependency vs regular dependency, conditional imports, NoOp plugin variants for tree-shaking, non-Vite production exclusion patterns."
- id: "@tanstack/devtools-event-client#devtools-bidirectional"
run: "npx @tanstack/intent@latest load @tanstack/devtools-event-client#devtools-bidirectional"
for: "Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only."
- id: "@tanstack/devtools-event-client#devtools-event-client"
run: "npx @tanstack/intent@latest load @tanstack/devtools-event-client#devtools-event-client"
for: "Create typed EventClient for a library. Define event maps with typed payloads, pluginId auto-prepend namespacing, emit()/on()/onAll()/onAllPluginEvents() API. Connection lifecycle (5 retries, 300ms), event queuing, enabled/disabled state, SSR fallbacks, singleton pattern. Unique pluginId requirement to avoid event collisions."
- id: "@tanstack/devtools-event-client#devtools-instrumentation"
run: "npx @tanstack/intent@latest load @tanstack/devtools-event-client#devtools-instrumentation"
for: "Analyze library codebase for critical architecture and debugging points, add strategic event emissions. Identify middleware boundaries, state transitions, lifecycle hooks. Consolidate events (1 not 15), debounce high-frequency updates, DRY shared payload fields, guard emit() for production. Transparent server/client event bridging."
- id: "@tanstack/devtools-vite#devtools-vite-plugin"
run: "npx @tanstack/intent@latest load @tanstack/devtools-vite#devtools-vite-plugin"
for: "Configure @tanstack/devtools-vite for source inspection (data-tsd-source, inspectHotkey, ignore patterns), console piping (client-to-server, server-to-client, levels), enhanced logging, server event bus (port, host, HTTPS), production stripping (removeDevtoolsOnBuild), editor integration (launch-editor, custom editor.open). Must be FIRST plugin in Vite config. Vite ^6 || ^7 only."
- id: "@tanstack/react-start#lifecycle/migrate-from-nextjs"
run: "npx @tanstack/intent@latest load @tanstack/react-start#lifecycle/migrate-from-nextjs"
for: "Step-by-step migration from Next.js App Router to TanStack Start: route definition conversion, API mapping, server function conversion from Server Actions, middleware conversion, data fetching pattern changes."
- id: "@tanstack/react-start#react-start"
run: "npx @tanstack/intent@latest load @tanstack/react-start#react-start"
for: "React bindings for TanStack Start: createStart, StartClient, StartServer, React-specific imports, re-exports from @tanstack/react-router, full project setup with React, useServerFn hook."
- id: "@tanstack/react-start#react-start/server-components"
run: "npx @tanstack/intent@latest load @tanstack/react-start#react-start/server-components"
for: "Implement, review, debug, and refactor TanStack Start React Server Components in React 19 apps. Use when tasks mention @tanstack/react-start/rsc, renderServerComponent, createCompositeComponent, CompositeComponent, renderToReadableStream, createFromReadableStream, createFromFetch, Composite Components, React Flight streams, loader or query owned RSC caching, router.invalidate, structuralSharing: false, selective SSR, stale names like renderRsc or .validator, or migration from Next App Router RSC patterns. Do not use for generic SSR or non-TanStack RSC frameworks except brief comparison."
- id: "@tanstack/router-core#router-core"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core"
for: "Framework-agnostic core concepts for TanStack Router: route trees, createRouter, createRoute, createRootRoute, createRootRouteWithContext, addChildren, Register type declaration, route matching, route sorting, file naming conventions. Entry point for all router skills."
- id: "@tanstack/router-core#router-core/auth-and-guards"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/auth-and-guards"
for: "Route protection with beforeLoad, redirect()/throw redirect(), isRedirect helper, authenticated layout routes (_authenticated), non-redirect auth (inline login), RBAC with roles and permissions, auth provider integration (Auth0, Clerk, Supabase), router context for auth state."
- id: "@tanstack/router-core#router-core/code-splitting"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/code-splitting"
for: "Automatic code splitting (autoCodeSplitting), .lazy.tsx convention, createLazyFileRoute, createLazyRoute, lazyRouteComponent, getRouteApi for typed hooks in split files, codeSplitGroupings per-route override, splitBehavior programmatic config, critical vs non-critical properties."
- id: "@tanstack/router-core#router-core/data-loading"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/data-loading"
for: "Route loader option, loaderDeps for cache keys, staleTime/gcTime/ defaultPreloadStaleTime SWR caching, pendingComponent/pendingMs/ pendingMinMs, errorComponent/onError/onCatch, beforeLoad, router context and createRootRouteWithContext DI pattern, router.invalidate, Await component, deferred data loading with unawaited promises."
- id: "@tanstack/router-core#router-core/navigation"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/navigation"
for: "Link component, useNavigate, Navigate component, router.navigate, ToOptions/NavigateOptions/LinkOptions, from/to relative navigation, activeOptions/activeProps, preloading (intent/viewport/render), preloadDelay, navigation blocking (useBlocker, Block), createLink, linkOptions helper, scroll restoration, MatchRoute."
- id: "@tanstack/router-core#router-core/not-found-and-errors"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/not-found-and-errors"
for: "notFound() function, notFoundComponent, defaultNotFoundComponent, notFoundMode (fuzzy/root), errorComponent, CatchBoundary, CatchNotFound, isNotFound, NotFoundRoute (deprecated), route masking (mask option, createRouteMask, unmaskOnReload)."
- id: "@tanstack/router-core#router-core/path-params"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/path-params"
for: "Dynamic path segments ($paramName), splat routes ($ / _splat), optional params ({-$paramName}), prefix/suffix patterns ({$param}.ext), useParams, params.parse/stringify, pathParamsAllowedCharacters, i18n locale patterns."
- id: "@tanstack/router-core#router-core/search-params"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/search-params"
for: "validateSearch, search param validation with Zod/Valibot/ArkType adapters, fallback(), search middlewares (retainSearchParams, stripSearchParams), custom serialization (parseSearch, stringifySearch), search param inheritance, loaderDeps for cache keys, reading and writing search params."
- id: "@tanstack/router-core#router-core/ssr"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/ssr"
for: "Non-streaming and streaming SSR, RouterClient/RouterServer, renderRouterToString/renderRouterToStream, createRequestHandler, defaultRenderHandler/defaultStreamHandler, HeadContent/Scripts components, head route option (meta/links/styles/scripts), ScriptOnce, automatic loader dehydration/hydration, memory history on server, data serialization, document head management."
- id: "@tanstack/router-core#router-core/type-safety"
run: "npx @tanstack/intent@latest load @tanstack/router-core#router-core/type-safety"
for: "Full type inference philosophy (never cast, never annotate inferred values), Register module declaration, from narrowing on hooks and Link, strict:false for shared components, getRouteApi for code-split typed access, addChildren with object syntax for TS perf, LinkProps and ValidateLinkOptions type utilities, as const satisfies pattern."
- id: "@tanstack/router-plugin#router-plugin"
run: "npx @tanstack/intent@latest load @tanstack/router-plugin#router-plugin"
for: "TanStack Router bundler plugin for route generation and automatic code splitting. Supports Vite, Webpack, Rspack, and esbuild. Configures autoCodeSplitting, routesDirectory, target framework, and code split groupings."
- id: "@tanstack/start-client-core#start-core"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core"
for: "Core overview for TanStack Start: tanstackStart() Vite plugin, getRouter() factory, root route document shell (HeadContent, Scripts, Outlet), client/server entry points, routeTree.gen.ts, tsconfig configuration. Entry point for all Start skills."
- id: "@tanstack/start-client-core#start-core/auth-server-primitives"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/auth-server-primitives"
for: "Server-side authentication primitives for TanStack Start: session cookies (HttpOnly, Secure, SameSite, __Host- prefix), session read/issue/destroy via createServerFn and middleware, OAuth authorization-code flow with state and PKCE, password-reset enumeration defense, CSRF for non-GET RPCs, rate limiting auth endpoints, session rotation on privilege change. Pairs with router-core/auth-and-guards for the routing side."
- id: "@tanstack/start-client-core#start-core/deployment"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/deployment"
for: "Deploy to Cloudflare Workers, Netlify, Vercel, Node.js/Docker, Bun, Railway. Selective SSR (ssr option per route), SPA mode, static prerendering, ISR with Cache-Control headers, SEO and head management."
- id: "@tanstack/start-client-core#start-core/execution-model"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/execution-model"
for: "Isomorphic-by-default principle, environment boundary functions (createServerFn, createServerOnlyFn, createClientOnlyFn, createIsomorphicFn), ClientOnly component, useHydrated hook, import protection, dead code elimination, environment variable safety (VITE_ prefix, process.env)."
- id: "@tanstack/start-client-core#start-core/middleware"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/middleware"
for: "createMiddleware, request middleware (.server only), server function middleware (.client + .server), context passing via next({ context }), sendContext for client-server transfer, global middleware via createStart in src/start.ts, middleware factories, method order enforcement, fetch override precedence."
- id: "@tanstack/start-client-core#start-core/server-functions"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/server-functions"
for: "createServerFn (GET/POST), validator (Zod or function), useServerFn hook, server context utilities (getRequest, getRequestHeader, setResponseHeader, setResponseStatus), error handling (throw errors, redirect, notFound), streaming, FormData handling, file organization (.functions.ts, .server.ts)."
- id: "@tanstack/start-client-core#start-core/server-routes"
run: "npx @tanstack/intent@latest load @tanstack/start-client-core#start-core/server-routes"
for: "Server-side API endpoints using the server property on createFileRoute, HTTP method handlers (GET, POST, PUT, DELETE), createHandlers for per-handler middleware, handler context (request, params, context), request body parsing, response helpers, file naming for API routes."
- id: "@tanstack/start-server-core#start-server-core"
run: "npx @tanstack/intent@latest load @tanstack/start-server-core#start-server-core"
for: "Server-side runtime for TanStack Start: createStartHandler, request/response utilities (getRequest, setResponseHeader, setCookie, getCookie, useSession), three-phase request handling, AsyncLocalStorage context."
- id: "@tanstack/virtual-file-routes#virtual-file-routes"
run: "npx @tanstack/intent@latest load @tanstack/virtual-file-routes#virtual-file-routes"
for: "Programmatic route tree building as an alternative to filesystem conventions: rootRoute, index, route, layout, physical, defineVirtualSubtreeConfig. Use with TanStack Router plugin's virtualRouteConfig option."
- id: "dotenv#dotenv"
run: "npx @tanstack/intent@latest load dotenv#dotenv"
for: "Load environment variables from a .env file into process.env for Node.js applications. Use when configuring apps with secrets, setting up local development environments, managing API keys and database uRLs, parsing .env file contents, or populating environment variables programmatically. Always use this skill when the user mentions .env, even for simple tasks like \"set up dotenv\" — the skill contains critical gotchas (encrypted keys, variable expansion, command substitution) that prevent common production issues."
- id: "dotenv#dotenvx"
run: "npx @tanstack/intent@latest load dotenv#dotenvx"
for: "Use dotenvx to run commands with environment variables, manage multiple .env files, expand variables, and encrypt env files for safe commits and CI/CD."
<!-- intent-skills:end -->
+251
View File
@@ -0,0 +1,251 @@
Welcome to your new TanStack Start app!
# Getting Started
To run this application:
```bash
npm install
npm run dev
```
# Building For Production
To build this application for production:
```bash
npm run build
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Removing Tailwind CSS
If you prefer not to use Tailwind CSS:
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Remove `@tailwindcss/vite` and `tailwindcss` from `package.json`
## Linting & Formatting
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
```bash
npm run lint
npm run format
npm run check
```
## Deploy with Nitro
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
```bash
npm run build
node dist/server/index.mjs
```
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
```bash
pnpm dlx shadcn@latest add button
```
## Setting up Better Auth
1. Generate and set the `BETTER_AUTH_SECRET` environment variable in your `.env.local`:
```bash
npx -y @better-auth/cli secret
```
2. Visit the [Better Auth documentation](https://www.better-auth.com) to unlock the full potential of authentication in your app.
### Adding a Database (Optional)
Better Auth can work in stateless mode, but to persist user data, add a database:
```typescript
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
// ... rest of config
});
```
Then run migrations:
```bash
npx -y @better-auth/cli migrate
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</body>
</html>
),
})
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Server Functions
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
const getServerTime = createServerFn({
method: 'GET',
}).handler(async () => {
return new Date().toISOString()
})
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
}
```
## API Routes
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
},
},
})
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/people')({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
},
component: PeopleComponent,
})
function PeopleComponent() {
const data = Route.useLoaderData()
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/src/**/*",
"**/.vscode/**/*",
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "#/components",
"utils": "#/lib/utils",
"ui": "#/components/ui",
"lib": "#/lib",
"hooks": "#/hooks"
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+14
View File
@@ -0,0 +1,14 @@
import { config } from 'dotenv'
import { defineConfig } from 'drizzle-kit'
config({ path: ['.env.local', '.env'] })
/*@ts-ignore*/
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
}
})
+62
View File
@@ -0,0 +1,62 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"issuer" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"impersonated_by" text,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "todos" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"role" text DEFAULT 'user',
"banned" boolean DEFAULT false,
"ban_reason" text,
"ban_expires" timestamp,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
+398
View File
@@ -0,0 +1,398 @@
{
"id": "8206e050-62f7-4c74-8444-d0b09d794dc2",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.todos": {
"name": "todos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "'user'"
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787995179811,
"tag": "0000_majestic_hammerhead",
"breakpoints": true
}
]
}
+12283
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
{
"name": "webkulisse-saas",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx scripts/seed-admin.ts"
},
"dependencies": {
"@calcom/embed-react": "^1.5.3",
"@fontsource-variable/nunito-sans": "^5.3.0",
"@fontsource-variable/public-sans": "^5.3.0",
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-start": "latest",
"better-auth": "^1.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.1",
"lucide-react": "^0.577.0",
"nitro": "3.0.260610-beta",
"pg": "^8.16.3",
"radix-ui": "^1.6.7",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"three": "^0.185.1",
"tw-animate-css": "^1.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/router-cli": "^1.132.0",
"@types/node": "^22.10.2",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.3.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vite": "^8.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" fill="none" viewBox="0 0 160 160"><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 43.48 67.304)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 76.94 46.534)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 128.424 46.535)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 94.957 67.304)"/></svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+97
View File
@@ -0,0 +1,97 @@
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { LogOut, UserCog } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { Button } from '#/components/ui/button.tsx'
import type { Session } from '#/lib/auth.ts'
import { authClient } from '#/lib/auth-client.ts'
type AppShellProps = {
session: Session
nav?: ReactNode
children: ReactNode
}
export function AppShell({ session, nav, children }: AppShellProps) {
const router = useRouter()
const navigate = useNavigate()
const [stopping, setStopping] = useState(false)
const impersonating = Boolean(session.session.impersonatedBy)
async function signOut() {
await authClient.signOut()
await router.invalidate()
await navigate({ to: '/login' })
}
async function stopImpersonation() {
setStopping(true)
await authClient.admin.stopImpersonating()
await router.invalidate()
setStopping(false)
await navigate({ to: '/admin' })
}
return (
<div className="flex min-h-screen flex-col bg-background">
{impersonating ? (
<div className="border-b border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2 md:px-6">
<p className="flex items-center gap-2 text-sm">
<UserCog className="size-4" />
Du bist als <strong>{session.user.name}</strong> ({session.user.email}) eingeloggt.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={stopImpersonation}
disabled={stopping}
>
{stopping ? 'Beende ...' : 'Impersonation beenden'}
</Button>
</div>
</div>
) : null}
<header className="border-b border-border bg-card">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<Link
to={session.user.role === 'admin' ? '/admin' : '/dashboard'}
className="flex items-center gap-2 no-underline"
>
<img
alt="Logo Webkulisse"
src="/img/logo.png"
className="h-8 w-8 rounded-md"
/>
<span className="font-heading text-base font-bold">webkulisse</span>
</Link>
{nav ? <nav className="flex items-center gap-1">{nav}</nav> : null}
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<div className="text-sm font-medium leading-tight">
{session.user.name}
</div>
<div className="text-xs text-muted-foreground leading-tight">
{session.user.email}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={signOut}
>
<LogOut />
Abmelden
</Button>
</div>
</div>
</header>
<main className="flex-1">
<div className="mx-auto max-w-6xl px-4 py-8 md:px-6">{children}</div>
</main>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { lazy, Suspense, useState, type ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx";
// Cal.com-Bundle erst laden, wenn das Modal wirklich geöffnet wird -
// spart auf der Landingpage ~150 KB JS, die 99 % der Besucher nie brauchen.
const BookingEmbed = lazy(() => import("@/components/booking-embed.tsx"));
type BookingDialogProps = {
// Der Trigger (in der Regel ein <Button>). Kommt via asChild an Radix
// durch, muss also ein einzelnes, fokussierbares Element sein.
children: ReactNode;
};
export function BookingDialog({ children }: BookingDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="h-[85vh] w-[95vw] max-w-4xl gap-0 overflow-hidden p-0 sm:h-[80vh]">
<DialogTitle className="sr-only">Erstgespräch buchen</DialogTitle>
<div className="h-full w-full overflow-hidden rounded-2xl">
{open && (
<Suspense
fallback={
<div className="flex h-full w-full items-center justify-center text-sm text-(--sea-ink-soft)">
Kalender wird geladen ...
</div>
}
>
<BookingEmbed />
</Suspense>
)}
</div>
</DialogContent>
</Dialog>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import Cal, { getCalApi } from "@calcom/embed-react";
import { CAL_CONFIG } from "@/lib/config.ts";
// Rein den cal.com-Embed - keine Umgebung, kein Wrapper.
// Wird sowohl vom Modal (booking-dialog.tsx) als auch von der Route
// /termin genutzt, damit Konfiguration und Verhalten identisch bleiben.
export default function BookingEmbed() {
useEffect(() => {
(async () => {
const cal = await getCalApi({
namespace: CAL_CONFIG.namespace,
embedJsUrl: CAL_CONFIG.embedJsUrl,
});
cal("ui", {
hideEventTypeDetails: false,
layout: "month_view",
});
})();
}, []);
return (
<Cal
namespace={CAL_CONFIG.namespace}
calLink={CAL_CONFIG.calLink}
style={{ width: "100%", height: "100%", overflow: "scroll" }}
config={{
layout: "month_view",
useSlotsViewOnSmallScreen: "true",
}}
calOrigin={CAL_CONFIG.origin}
embedJsUrl={CAL_CONFIG.embedJsUrl}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Gauge, Lock, Palette } from "lucide-react";
const features = [
{
icon: Gauge,
title: "Ladezeit unter einer Sekunde",
text: "Unsere Seiten sind auf dem Smartphone in unter einer Sekunde da. Der Besucher bleibt bei Ihnen, statt zum nächsten Google-Treffer weiterzuziehen.",
},
{
icon: Lock,
title: "Ohne WordPress, ohne Update-Pflicht",
text: "Kein Content-Management-System, keine Plugins, keine Datenbank. Nichts, was gehackt werden könnte oder das Sie regelmäßig aktualisieren müssen.",
},
{
icon: Palette,
title: "Design nach Maß",
text: "Kein Baukasten-Look. Ihre Seite bekommt einen Auftritt, den Besucher sofort mit Ihrem Betrieb verbinden - nicht mit tausend anderen.",
},
];
export const Features = () => {
return (
<section id="warum-wir" className="relative py-16 md:py-24">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<figure className="relative overflow-hidden rounded-3xl border border-[var(--line)]">
<img
alt="Aufgeräumter Arbeitsplatz mit Notizen zur Projektplanung"
className="h-64 w-full object-cover sm:h-80 md:h-96"
src="/img/laptop-planning.webp"
width="5184"
height="3456"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/40 to-black/10" />
<figcaption className="absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
<span className="island-kicker !text-white/80">Unser Anspruch</span>
<h2 className="mt-3 max-w-3xl font-heading text-3xl font-bold leading-tight text-white sm:text-4xl md:text-5xl">
Echtes Handwerk statt Baukasten-Kompromisse
</h2>
</figcaption>
</figure>
<p className="mx-auto mt-10 max-w-2xl text-center text-[var(--sea-ink-soft)] sm:text-lg">
Viele Agenturen setzen auf schwerfällige Standard-Systeme. Wir schreiben den
Code für Ihre Webseite selbst. Der Unterschied für Sie:
</p>
<ul className="mt-10 grid gap-5 md:grid-cols-3">
{features.map(({ icon: Icon, title, text }) => (
<li
key={title}
className="feature-card rounded-2xl border border-[var(--line)] p-6"
>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--lagoon)]/15 text-[var(--lagoon-deep)]">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-[var(--sea-ink)]">
{title}
</h3>
<p className="mt-2 text-sm text-[var(--sea-ink-soft)]">{text}</p>
</li>
))}
</ul>
</div>
</section>
);
};
+125
View File
@@ -0,0 +1,125 @@
import { ArrowRight, Mail, MapPin, Phone } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
export const Footer = () => {
const year = new Date().getFullYear();
return (
<footer id="kontakt" className="site-footer mt-8">
<div className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<section className="island-shell rounded-3xl p-8 sm:p-12">
<div className="grid gap-8 lg:grid-cols-[1.6fr_1fr] lg:items-center">
<div className="space-y-4">
<span className="island-kicker">Kontakt aufnehmen</span>
<h2 className="font-heading text-3xl font-bold leading-tight text-(--sea-ink) sm:text-4xl">
Sprechen wir über Ihr Projekt.
</h2>
<p className="max-w-xl text-(--sea-ink-soft) sm:text-lg">
Buchen Sie sich einen Termin direkt online - 30 Minuten,
kostenlos, keine Warteschleifen. Wenn Sie lieber telefonieren,
melde ich mich innerhalb eines Werktags zurück.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<BookingDialog>
<Button data-type="accent" size="lg" className="gap-2">
Erstgespräch buchen
<ArrowRight className="h-4 w-4" />
</Button>
</BookingDialog>
<Button variant="outline" size="lg" asChild>
<a href="tel:+4917622222222" className="no-underline">
+49 176 22222222
</a>
</Button>
</div>
</div>
</section>
<div
id="about"
className="mt-14 grid gap-10 border-t border-(--line) pt-10 md:grid-cols-3"
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<img
alt="Placeholder Logo"
height="32"
width="32"
src="https://placehold.co/64"
className="h-8 w-8 rounded-md"
/>
<span className="font-heading text-lg font-bold text-(--sea-ink)">
webkulisse
</span>
</div>
<p className="text-sm text-(--sea-ink-soft)">
Handgemachte Webseiten für Handwerk, Praxis und Therapie schnell,
sicher und persönlich betreut.
</p>
</div>
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-widest text-(--sea-ink-soft)">
Anbieter
</h3>
<address className="mt-3 text-sm not-italic text-(--sea-ink)">
Mathias Hurler Webdesign
<br />
Untermagerbein 30
<br />
86751 Mönchsdeggingen
</address>
</div>
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-widest text-(--sea-ink-soft)">
So erreichen Sie uns
</h3>
<ul className="mt-3 space-y-2 text-sm">
<li>
<a
href="mailto:info@webkulisse.de"
className="inline-flex items-center gap-2 no-underline hover:underline"
>
<Mail className="h-4 w-4" aria-hidden />
info@webkulisse.de
</a>
</li>
<li>
<a
href="tel:+4917622222222"
className="inline-flex items-center gap-2 no-underline hover:underline"
>
<Phone className="h-4 w-4" aria-hidden />
+49 176 22222222
</a>
</li>
<li className="inline-flex items-center gap-2 text-(--sea-ink-soft)">
<MapPin className="h-4 w-4" aria-hidden />
Bayerisch-Schwaben
</li>
</ul>
</div>
</div>
<div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-(--line) pt-6 text-xs text-(--sea-ink-soft) sm:flex-row sm:items-center">
<p>© {year} webkulisse.de · Alle Rechte vorbehalten.</p>
<ul className="flex gap-4">
<li>
<a href="#" className="no-underline hover:underline">
Impressum
</a>
</li>
<li>
<a href="#" className="no-underline hover:underline">
Datenschutz
</a>
</li>
</ul>
</div>
</div>
</footer>
);
};
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Menu, Moon, Sun, X } from "lucide-react";
import { getTheme, setTheme, type Theme } from "@/lib/theme.ts";
import { Button } from "@/components/ui/button.tsx";
import { authClient } from "@/lib/auth-client.ts";
const navLinks = [
{ href: "#zielgruppen", label: "Für wen" },
{ href: "#warum-wir", label: "Warum wir" },
{ href: "#ablauf", label: "Ablauf" },
{ href: "#pricing", label: "Angebote" },
{ href: "#kontakt", label: "Kontakt" },
];
export default function Header() {
const [theme, setThemeState] = useState<Theme>("light");
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const { data: session } = authClient.useSession();
const loginTarget = session?.user
? session.user.role === "admin"
? "/admin"
: "/dashboard"
: "/login";
const loginLabel = session?.user ? "Zum Dashboard" : "Kundenlogin";
useEffect(() => {
setThemeState(getTheme());
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
function toggleTheme() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
}
return (
<header
className={[
"sticky top-0 z-40 w-full transition-all",
scrolled
? "border-b border-[var(--line)] backdrop-blur-md bg-[var(--header-bg)]"
: "border-b border-transparent",
].join(" ")}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<a href="#" aria-label="Startseite" className="flex items-center gap-2 no-underline">
<img
alt="Logo Webkulisse"
height="520"
width="690"
src="/img/logo.png"
className="h-10 w-10 rounded-lg"
/>
<span className="hidden font-heading text-lg font-bold tracking-tight sm:inline">
webkulisse
</span>
</a>
<nav aria-label="Hauptnavigation" className="hidden md:block">
<ul className="flex items-center gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="nav-link rounded-md px-3 py-2 text-sm font-medium no-underline"
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Zu hellem Design wechseln" : "Zu dunklem Design wechseln"}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink)] transition hover:bg-[var(--link-bg-hover)]"
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<Button asChild variant="default" className="hidden sm:inline-flex">
<Link to={loginTarget}>{loginLabel}</Link>
</Button>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
aria-label="Menü öffnen"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink)] md:hidden"
>
{mobileOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</button>
</div>
</div>
{mobileOpen && (
<nav
id="mobile-nav"
aria-label="Mobile Navigation"
className="md:hidden border-t border-[var(--line)] bg-[var(--header-bg)] backdrop-blur-md"
>
<ul className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-3">
{navLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={() => setMobileOpen(false)}
className="nav-link block rounded-md px-3 py-2 text-base font-medium no-underline"
>
{link.label}
</a>
</li>
))}
<li className="pt-2">
<Button asChild variant="default" className="w-full">
<Link to={loginTarget} onClick={() => setMobileOpen(false)}>
{loginLabel}
</Link>
</Button>
</li>
</ul>
</nav>
)}
</header>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { ArrowRight, HeartHandshake, ShieldCheck, Zap } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
const trustPoints = [
{ icon: Zap, label: "Unter 1 Sekunde Ladezeit" },
{ icon: ShieldCheck, label: "Ohne Update-Pflicht" },
{ icon: HeartHandshake, label: "Persönlich betreut" },
];
export default function Hero() {
return (
<section className="mx-auto max-w-6xl px-4 pt-12 pb-16 md:px-6 md:pt-20 md:pb-24">
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
<div className="space-y-6 rise-in">
<span className="island-kicker inline-flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--lagoon)]" aria-hidden />
Für Handwerk, Praxis und Therapie in Bayern
</span>
<h1 className="font-heading text-4xl font-bold leading-[1.05] tracking-tight text-[var(--sea-ink)] sm:text-5xl lg:text-6xl">
Eine Webseite, die{" "}
<span className="text-[var(--lagoon-deep)]">neue Kunden bringt</span> - nicht nur gut aussieht.
</h1>
<p className="max-w-xl text-lg text-[var(--sea-ink-soft)] sm:text-xl">
Damit neue Kunden Sie zuerst finden - nicht die Konkurrenz in der Nachbarschaft.
</p>
<div className="flex flex-wrap items-center gap-3">
<BookingDialog>
<Button data-type="accent" size="lg" className="gap-2">
Erstgespräch buchen
<ArrowRight className="h-4 w-4" />
</Button>
</BookingDialog>
<Button variant="ghost" size="lg" asChild>
<a href="#pricing" className="no-underline">Pakete ansehen</a>
</Button>
</div>
<p className="text-sm text-(--sea-ink-soft)">
30 Minuten, kostenlos - direkt online buchen, keine Warteschleifen.
</p>
<ul className="flex flex-wrap items-center gap-x-6 gap-y-3 pt-2 text-sm text-[var(--sea-ink-soft)]">
{trustPoints.map(({ icon: Icon, label }) => (
<li key={label} className="inline-flex items-center gap-2">
<Icon className="h-4 w-4 text-[var(--lagoon-deep)]" aria-hidden />
{label}
</li>
))}
</ul>
</div>
<figure className="island-shell relative overflow-hidden rounded-3xl">
<img
className="block h-full w-full object-cover"
src="https://placehold.co/400x600"
alt="Aufgeräumter Schreibtisch aus Holz mit Laptop"
width="400"
height="600"
loading="eager"
/>
</figure>
</div>
</section>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { Check, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
type PricingPlan = {
id: number;
name: string;
description: string;
price: number;
features: string[];
priceNote: string;
highlighted?: boolean;
};
const offerlist: PricingPlan[] = [
{
id: 1,
name: "Das Kompakt-Paket",
description:
"Perfekt für die schnelle, moderne Präsentation. Ideal für Therapeuten, Berater und regionale Handwerker.",
price: 900,
features: [
"Alle wichtigen Infos auf einer Seite",
"Kontaktformular & Anfahrt",
"Für Smartphone optimiert",
"SSL-Verschlüsselung inklusive",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
],
priceNote: "Aufpreise z. B. für mehrsprachige Seiten oder Terminbuchung.",
},
{
id: 2,
name: "Das Klassik-Paket",
description:
"Für Praxen und Betriebe mit breitem Angebot. Maximale Übersicht für Ihre Besucher.",
price: 1700,
features: [
"Separate Leistungs- und Teamseiten",
"Bildergalerie & Referenzen",
"Erweiterte SEO-Optimierung",
"Rechtstexte-Assistenz",
"3 Monate kostenloser Support",
],
priceNote: "Aufpreise z. B. für zusätzliche Sprachen oder Shop-Anbindung.",
highlighted: true,
},
{
id: 3,
name: "Ihre neue Webapp",
description:
"Für digitale Lösungen mit echtem Mehrwert. Ideal für Unternehmen, die mehr als eine Website brauchen.",
price: 2500,
features: [
"Login- und Nutzerbereich",
"Individuelle Funktionen",
"Terminbuchung oder Kundenportal",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
"Skalierbare Infrastruktur",
],
priceNote: "Endpreis abhängig vom Funktionsumfang - Details im Erstgespräch.",
},
];
const priceFormatter = new Intl.NumberFormat("de-DE");
export const Pricing = () => {
return (
<section id="pricing" className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Preise</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Feste Preise. Keine Überraschungen.
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Wählen Sie das Paket, das zu Ihrem Betrieb passt. Nach dem Erstgespräch
bekommen Sie einen festen Preis - schriftlich, ohne Überraschungen.
</p>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3 lg:items-stretch">
{offerlist.map((offer) => {
const isHighlighted = offer.highlighted;
return (
<article
key={offer.id}
className={[
"relative flex flex-col gap-5 rounded-3xl border p-6 sm:p-7",
isHighlighted
? "border-(--lagoon-deep) bg-(--surface-strong) shadow-[0_20px_50px_rgba(30,90,72,0.18)] lg:-translate-y-2"
: "feature-card border-(--line)",
].join(" ")}
>
{isHighlighted && (
<span className="absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 rounded-full bg-(--lagoon-deep) px-3 py-1 text-xs font-semibold text-white shadow">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
Empfehlung
</span>
)}
<header className="space-y-2">
<h3 className="font-heading text-xl font-bold text-(--sea-ink)">
{offer.name}
</h3>
<p className="text-sm text-(--sea-ink-soft)">{offer.description}</p>
</header>
<div className="flex items-baseline gap-1">
<span className="text-sm text-(--sea-ink-soft)">ab</span>
<span className="font-heading text-4xl font-bold text-(--sea-ink)">
{priceFormatter.format(offer.price)}
</span>
</div>
<div className="-mt-3 space-y-1 text-xs text-(--sea-ink-soft)">
<p>zzgl. gesetzl. Umsatzsteuer</p>
<p>{offer.priceNote}</p>
</div>
<ul className="space-y-2.5 text-sm text-(--sea-ink)">
{offer.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-(--lagoon-deep)"
aria-hidden
/>
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto pt-2">
<BookingDialog>
<Button
variant={isHighlighted ? "default" : "outline"}
className="w-full"
>
Erstgespräch buchen
</Button>
</BookingDialog>
</div>
</article>
);
})}
</div>
</section>
);
};
+64
View File
@@ -0,0 +1,64 @@
import { FileText, MessageSquare, Rocket, Wrench } from "lucide-react";
const steps = [
{
icon: MessageSquare,
title: "Erstgespräch",
text: "30 Minuten, kostenlos, per Telefon oder vor Ort. Wir klären Ihre Ziele, Ihre Zielgruppe und den Umfang.",
},
{
icon: FileText,
title: "Angebot & Konzept",
text: "Sie bekommen einen festen Preis und einen Zeitplan. Erst nach Ihrer Freigabe geht es los.",
},
{
icon: Wrench,
title: "Umsetzung",
text: "Ich baue Ihre Seite. Zwischenstände zeige ich Ihnen regelmäßig, Anpassungen sind normaler Teil des Prozesses.",
},
{
icon: Rocket,
title: "Übergabe & Betreuung",
text: "Sie bekommen alle Zugänge, ein kurzes Handbuch und drei Monate kostenlosen Support nach dem Launch.",
},
];
export const Process = () => {
return (
<section id="ablauf" className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">So arbeite ich</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
In vier Schritten zur neuen Webseite
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Keine Überraschungen im Prozess. Sie wissen jederzeit, wo Ihr Projekt steht und
was als Nächstes passiert.
</p>
</div>
<ol className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{steps.map(({ icon: Icon, title, text }, index) => (
<li
key={title}
className="feature-card relative rounded-2xl border border-(--line) p-6"
>
<span
aria-hidden
className="absolute right-5 top-4 font-heading text-4xl font-bold text-(--sea-ink-soft)/20"
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-(--lagoon)/15 text-(--lagoon-deep)">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-(--sea-ink)">
{title}
</h3>
<p className="mt-2 text-sm text-(--sea-ink-soft)">{text}</p>
</li>
))}
</ol>
</section>
);
};
+56
View File
@@ -0,0 +1,56 @@
import { Brain, HardHat, Stethoscope } from "lucide-react";
const audiences = [
{
icon: HardHat,
title: "Handwerksbetriebe",
text: "Anfragen aus der Region, ohne dass Sie ständig Marketing machen müssen. Referenzen, Leistungen und Kontaktdaten stehen dort, wo Kunden sie suchen.",
},
{
icon: Stethoscope,
title: "Praxen & Ärzte",
text: "Neue Patienten und weniger Rückfragen am Telefon. Sprechzeiten, Leistungsspektrum und Anfahrt sofort auf einen Blick - auch am Smartphone.",
},
{
icon: Brain,
title: "Therapeuten & Berater",
text: "Ein Auftritt, der Vertrauen weckt - bevor der Erstkontakt zustande kommt. Persönlich, ruhig, ohne Marketing-Lärm.",
},
];
export const Services = () => {
return (
<section
id="zielgruppen"
className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24"
>
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Für wen ist das?</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Finden Sie sich wieder?
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Meine Kunden verbindet eines: Sie sind Meister ihres Fachs -
und wollen sich nicht mit ihrer Webseite herumärgern.
</p>
</div>
<ul className="mt-12 grid gap-5 md:grid-cols-3">
{audiences.map(({ icon: Icon, title, text }) => (
<li
key={title}
className="feature-card rounded-2xl border border-(--line) p-6"
>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-(--lagoon)/15 text-(--lagoon-deep)">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-(--sea-ink)">
{title}
</h3>
<p className="mt-2 text-sm text-(--sea-ink-soft)">{text}</p>
</li>
))}
</ul>
</section>
);
};
+55
View File
@@ -0,0 +1,55 @@
import { Sparkles } from "lucide-react";
const placeholders = [
{ name: "Beispiel: Handwerksbetrieb", type: "Firmen-Webseite" },
{ name: "Beispiel: Arztpraxis", type: "Praxis-Webseite" },
{ name: "Beispiel: Therapie", type: "Landingpage" },
];
export const Showcase = () => {
return (
<section
id="beispiele"
className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24"
>
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Beispielprojekte</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
So kann Ihr Ergebnis aussehen
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Demo-Projekte für Handwerk, Praxis und Therapie - in Kürze hier zu sehen.
</p>
</div>
<ul className="mt-12 grid gap-5 md:grid-cols-3">
{placeholders.map((p) => (
<li
key={p.name}
className="feature-card relative overflow-hidden rounded-2xl border border-(--line)"
>
<div className="relative aspect-[4/3] bg-linear-to-br from-(--sand) via-(--foam) to-(--lagoon)/25">
<div className="absolute inset-0 flex items-center justify-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-(--line) bg-(--surface-strong) px-3 py-1 text-xs font-semibold text-(--sea-ink) shadow-sm backdrop-blur-sm">
<Sparkles
className="h-3.5 w-3.5 text-(--lagoon-deep)"
aria-hidden
/>
In Kürze
</span>
</div>
</div>
<div className="p-5">
<p className="text-xs uppercase tracking-widest text-(--sea-ink-soft)">
{p.type}
</p>
<p className="mt-1 font-heading text-base font-semibold text-(--sea-ink)">
{p.name}
</p>
</div>
</li>
))}
</ul>
</section>
);
};
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "#/lib/utils.ts";
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger(
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogClose(
props: React.ComponentProps<typeof DialogPrimitive.Close>,
) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogPortal(
props: React.ComponentProps<typeof DialogPrimitive.Portal>,
) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-[var(--line)] bg-[var(--surface,var(--background))] p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
aria-label="Schließen"
className="absolute right-3 top-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-lg font-semibold text-(--sea-ink)",
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-(--sea-ink-soft)", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+5
View File
@@ -0,0 +1,5 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import * as schema from './schema.ts'
export const db = drizzle(process.env.DATABASE_URL!, { schema })
+57
View File
@@ -0,0 +1,57 @@
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull().default(false),
image: text('image'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
role: text('role').default('user'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
})
export const session = pgTable('session', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonated_by'),
})
export const account = pgTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
issuer: text('issuer').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState, type ReactNode, type CSSProperties } from 'react';
interface CrosshairProps {
children: ReactNode;
/** Optionales Label, das vor den Dimensionen angezeigt wird, z. B. "Header" */
label?: string;
/** Beliebige CSS-Farbe für Rahmen/Kreuz, überschreibt --crosshair-color */
color?: string;
/** Schraffur-Hintergrund aktivieren (.hatch Utility) */
hatch?: boolean;
/** Größe der Schraffur in px, überschreibt --hatch-size */
hatchSize?: number;
/** Live-Breite/Höhe oben links einblenden (per ResizeObserver) */
showDimensions?: boolean;
className?: string;
}
export function Crosshair({
children,
label,
color,
hatch = false,
hatchSize,
showDimensions = true,
className = '',
}: CrosshairProps) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el || !showDimensions) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ w: Math.round(width), h: Math.round(height) });
});
ro.observe(el);
return () => ro.disconnect();
}, [showDimensions]);
const style: CSSProperties = {
...(color ? { ['--crosshair-color' as string]: color } : {}),
...(hatchSize ? { ['--hatch-size' as string]: `${hatchSize}px` } : {}),
};
return (
<div
ref={ref}
className={`crosshair ${hatch ? 'hatch' : ''} ${className}`.trim()}
style={style}
>
{showDimensions && (
<span className="pointer-events-none absolute left-1 top-1 z-50 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] leading-none text-white">
{label ? `${label} · ` : ''}
{size.w}×{size.h}
</span>
)}
{children}
</div>
);
}
/** Zeigt den berechneten z-index eines Elements als Badge oben rechts an. */
export function ZIndexBadge({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [z, setZ] = useState('auto');
useEffect(() => {
const el = ref.current;
if (!el) return;
setZ(getComputedStyle(el).zIndex);
}, []);
return (
<div ref={ref} className={`relative ${className}`.trim()}>
<span className="pointer-events-none absolute right-1 top-1 z-[9999] rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] leading-none text-white">
z: {z}
</span>
{children}
</div>
);
}
/**
* Definiert die SVG-Filter für die colorblind-* Utilities aus dev-utilities.css.
* Einmal im App-Root rendern (z. B. direkt unter <body>/<App>) — erzeugt selbst
* kein sichtbares Element, nur die <filter>-Definitionen im DOM.
*/
export function ColorblindFilters() {
return (
<svg aria-hidden="true" className="absolute h-0 w-0 overflow-hidden">
<defs>
<filter id="protanopia">
<feColorMatrix
type="matrix"
values="0.567 0.433 0 0 0
0.558 0.442 0 0 0
0 0.242 0.758 0 0
0 0 0 1 0"
/>
</filter>
<filter id="deuteranopia">
<feColorMatrix
type="matrix"
values="0.625 0.375 0 0 0
0.7 0.3 0 0 0
0 0.3 0.7 0 0
0 0 0 1 0"
/>
</filter>
<filter id="tritanopia">
<feColorMatrix
type="matrix"
values="0.95 0.05 0 0 0
0 0.433 0.567 0 0
0 0.475 0.525 0 0
0 0 0 1 0"
/>
</filter>
</defs>
</svg>
);
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Debug-Utilities für Tailwind v4
* Einfach in dein Haupt-CSS importieren (dort, wo auch @import "tailwindcss"; steht):
*
* @import "tailwindcss";
* @import "./crosshair-utilities.css";
*
* Funktioniert 1:1 in React- und Vanilla-Projekten, da es reine CSS-Klassen sind.
*/
/* Mittelkreuz + dünner Rahmen, um Container-Grenzen sichtbar zu machen */
@utility crosshair {
--crosshair-color: var(--color-fuchsia-500);
position: relative;
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--crosshair-color) 50%, transparent);
&::before,
&::after {
content: '';
position: absolute;
pointer-events: none;
z-index: 40;
background: color-mix(in oklab, var(--crosshair-color) 70%, transparent);
}
/* vertikale Linie */
&::before {
top: 0;
left: 50%;
width: 1px;
height: 100%;
transform: translateX(-50%);
}
/* horizontale Linie */
&::after {
top: 50%;
left: 0;
width: 100%;
height: 1px;
transform: translateY(-50%);
}
}
/* Schraffierter Hintergrund zur Orientierung (z. B. für leere/transparente Wrapper) */
@utility hatch {
--hatch-color: color-mix(in oklab, currentColor 15%, transparent);
--hatch-size: 8px;
background-image: repeating-linear-gradient(
45deg,
var(--hatch-color) 0,
var(--hatch-color) 1px,
transparent 1px,
transparent var(--hatch-size)
);
}
/* Modifier: gröbere Schraffur — zusammen mit .hatch verwenden */
@utility hatch-lg {
--hatch-size: 16px;
}
/* Modifier: Schraffur in Gegenrichtung — zusammen mit .hatch verwenden */
@utility hatch-r {
background-image: repeating-linear-gradient(
-45deg,
var(--hatch-color) 0,
var(--hatch-color) 1px,
transparent 1px,
transparent var(--hatch-size)
);
}
/*
* Verwendung (Vanilla oder React className):
*
* <div class="crosshair">...</div>
* <div class="crosshair hatch">...</div>
* <div class="crosshair hatch hatch-lg">...</div>
*
* Farbe pro Instanz überschreiben (inline style oder eigene Utility-Klasse):
*
* <div class="crosshair" style="--crosshair-color: theme(colors.sky.400)">...</div>
*/
/**
* Weitere Debug-Utilities für Tailwind v4
* Reihenfolge: Breakpoint-Indicator, Grid-Overlay, Outline-all,
* Spacing-Visualizer, Baseline-Grid, Colorblind-Filter.
* (Z-Index-Badge ist eine React-Komponente, siehe DevTools.tsx)
*
* Import zusammen mit crosshair-utilities.css:
* @import "tailwindcss";
* @import "./crosshair-utilities.css";
* @import "./dev-utilities.css";
*/
/* 1) Breakpoint-Indicator --------------------------------------------- */
/* Auf ein beliebiges Element setzen, meist einmal im App-Root, fixed positioniert. */
@utility breakpoint-indicator {
position: fixed;
right: 0.5rem;
bottom: 0.5rem;
z-index: 9999;
pointer-events: none;
border-radius: 0.25rem;
background: black;
padding: 2px 6px;
font-family: ui-monospace, monospace;
font-size: 10px;
line-height: 1.4;
color: white;
&::after {
content: 'base';
}
/* Entspricht Tailwinds Standard-Breakpoints — bei custom @theme Werten hier
anpassen, @media kann keine CSS-Variablen aus @theme lesen. */
@media (width >= 640px) { &::after { content: 'sm'; } }
@media (width >= 768px) { &::after { content: 'md'; } }
@media (width >= 1024px) { &::after { content: 'lg'; } }
@media (width >= 1280px) { &::after { content: 'xl'; } }
@media (width >= 1536px) { &::after { content: '2xl'; } }
}
/* 2) Grid-Overlay -------------------------------------------------------- */
/* Quadratisches Pixelraster für einen Container, analog zu .hatch. */
@utility grid-overlay {
--grid-size: 8px;
background-image:
repeating-linear-gradient(to right, color-mix(in oklab, var(--color-red-500) 12%, transparent) 0 1px, transparent 1px var(--grid-size)),
repeating-linear-gradient(to bottom, color-mix(in oklab, var(--color-red-500) 12%, transparent) 0 1px, transparent 1px var(--grid-size));
}
@utility grid-overlay-lg {
--grid-size: 16px;
}
/* 3) Outline-all ----------------------------------------------------------- */
/* Alle Nachfahren bekommen einen Outline, farblich nach Kategorie: Layout (blau),
Medien (pink), interaktive Elemente (amber). Keine echte Nesting-Tiefe — dafür
bräuchte es JS —, für schnelles "wo sitzt was" reicht das meistens. */
@utility outline-all {
& * {
outline: 1px solid color-mix(in oklab, var(--color-sky-500) 50%, transparent);
outline-offset: -1px;
}
& img, & svg, & video, & picture {
outline-color: color-mix(in oklab, var(--color-pink-500) 60%, transparent);
}
& button, & a, & input, & select, & textarea, & label {
outline-color: color-mix(in oklab, var(--color-amber-500) 60%, transparent);
}
}
/* 4) Spacing-Visualizer ------------------------------------------------------ */
/* Content-Box (grün) und Padding-Bereich (orange) per background-clip-Trick.
Margin lässt sich in reinem CSS nicht einfärben (liegt außerhalb der Border-Box) —
dafür notfalls zusätzlich einen äußeren Wrapper mit .hatch verwenden. */
@utility spacing-visualizer {
background-image:
linear-gradient(color-mix(in oklab, var(--color-emerald-500) 25%, transparent), color-mix(in oklab, var(--color-emerald-500) 25%, transparent)),
linear-gradient(color-mix(in oklab, var(--color-orange-500) 25%, transparent), color-mix(in oklab, var(--color-orange-500) 25%, transparent));
background-clip: content-box, padding-box;
}
/* 5) Baseline-Grid ------------------------------------------------------------ */
/* Horizontale Linien im Line-Height-Raster, für sauber ausgerichtete Typografie. */
@utility baseline-grid {
--baseline: 24px;
background-image: repeating-linear-gradient(
to bottom,
color-mix(in oklab, var(--color-blue-500) 15%, transparent) 0 1px,
transparent 1px var(--baseline)
);
}
/* 6) Colorblind-Filter --------------------------------------------------------- */
/* Setzt eine SVG-Filter-Matrix ein, um Farbfehlsichtigkeit zu simulieren.
Die <filter>-Definitionen kommen aus DevTools.tsx (<ColorblindFilters />) —
einmal irgendwo im App-Root rendern. */
@utility colorblind-protanopia { filter: url(#protanopia); }
@utility colorblind-deuteranopia { filter: url(#deuteranopia); }
@utility colorblind-tritanopia { filter: url(#tritanopia); }
@utility colorblind-achromatopsia { filter: grayscale(1); }
+6
View File
@@ -0,0 +1,6 @@
import { adminClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
plugins: [adminClient()],
})
+67
View File
@@ -0,0 +1,67 @@
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin } from 'better-auth/plugins'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { db } from '#/db/index.ts'
import * as schema from '#/db/schema.ts'
const isProd = process.env.NODE_ENV === 'production'
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
secret: process.env.BETTER_AUTH_SECRET,
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: undefined,
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
},
}),
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 8,
maxPasswordLength: 128,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
},
rateLimit: {
enabled: true,
window: 60,
max: 100,
storage: 'memory',
customRules: {
'/sign-in/email': { window: 60, max: 5 },
'/sign-in/*': { window: 60, max: 5 },
'/forget-password': { window: 60 * 15, max: 3 },
'/reset-password': { window: 60 * 15, max: 5 },
},
},
advanced: {
defaultCookieAttributes: {
secure: isProd,
sameSite: 'lax',
httpOnly: true,
},
},
plugins: [
admin({
defaultRole: 'user',
adminRoles: ['admin'],
}),
tanstackStartCookies(),
],
})
export type Session = typeof auth.$Infer.Session
+13
View File
@@ -0,0 +1,13 @@
// Ziel-Route der eigenen Termin-Seite. Wird von allen primären CTAs
// (Hero, Pricing, Footer) genutzt - und ist gleichzeitig der teilbare
// Link fürs Erstgespräch (Mail-Signatur, Visitenkarte, Social).
export const BOOKING_URL = "/termin";
// Konfiguration für das cal.com-Embed (self-hosted).
// Der Namespace muss zwischen getCalApi-Init und der Cal-Instanz identisch sein.
export const CAL_CONFIG = {
namespace: "erstberatung",
calLink: "kalender/erstberatung",
origin: "https://termin.hurler-webdesign.de",
embedJsUrl: "https://termin.hurler-webdesign.de/embed/embed.js",
} as const;
+10
View File
@@ -0,0 +1,10 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { auth } from '#/lib/auth.ts'
export const getSessionFn = createServerFn({ method: 'GET' }).handler(
async () => {
const request = getRequest()
return await auth.api.getSession({ headers: request.headers })
},
)
+16
View File
@@ -0,0 +1,16 @@
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
export function getTheme(): Theme {
if (typeof document === 'undefined') return 'light'
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
export function setTheme(next: Theme): void {
if (typeof document === 'undefined') return
document.documentElement.classList.toggle('dark', next === 'dark')
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {/*ignorieren*/}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+298
View File
@@ -0,0 +1,298 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminRouteImport } from './routes/_admin'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as LoginRouteImport } from './routes/login'
import { Route as TerminRouteImport } from './routes/termin'
import { Route as AdminAdminRouteImport } from './routes/_admin/admin'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AdminAdminIndexRouteImport } from './routes/_admin/admin.index'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AdminAdminUsersUserIdRouteImport } from './routes/_admin/admin.users.$userId'
import { Route as AdminAdminUsersNewRouteImport } from './routes/_admin/admin.users.new'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/_admin',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const TerminRoute = TerminRouteImport.update({
id: '/termin',
path: '/termin',
getParentRoute: () => rootRouteImport,
} as any)
const AdminAdminRoute = AdminAdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => AdminRoute,
} as any)
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AdminAdminIndexRoute = AdminAdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AdminAdminRoute,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const AdminAdminUsersUserIdRoute = AdminAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
getParentRoute: () => AdminAdminRoute,
} as any)
const AdminAdminUsersNewRoute = AdminAdminUsersNewRouteImport.update({
id: '/users/new',
path: '/users/new',
getParentRoute: () => AdminAdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/admin': typeof AdminAdminRouteWithChildren
'/dashboard': typeof AuthenticatedDashboardRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/admin/': typeof AdminAdminIndexRoute
'/admin/users/$userId': typeof AdminAdminUsersUserIdRoute
'/admin/users/new': typeof AdminAdminUsersNewRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/admin': typeof AdminAdminIndexRoute
'/admin/users/$userId': typeof AdminAdminUsersUserIdRoute
'/admin/users/new': typeof AdminAdminUsersNewRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_admin': typeof AdminRouteWithChildren
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/_admin/admin': typeof AdminAdminRouteWithChildren
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/_admin/admin/': typeof AdminAdminIndexRoute
'/_admin/admin/users/$userId': typeof AdminAdminUsersUserIdRoute
'/_admin/admin/users/new': typeof AdminAdminUsersNewRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/termin'
| '/admin'
| '/dashboard'
| '/api/auth/$'
| '/admin/'
| '/admin/users/$userId'
| '/admin/users/new'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/termin'
| '/dashboard'
| '/api/auth/$'
| '/admin'
| '/admin/users/$userId'
| '/admin/users/new'
id:
| '__root__'
| '/'
| '/_admin'
| '/_authenticated'
| '/login'
| '/termin'
| '/_admin/admin'
| '/_authenticated/dashboard'
| '/api/auth/$'
| '/_admin/admin/'
| '/_admin/admin/users/$userId'
| '/_admin/admin/users/new'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
TerminRoute: typeof TerminRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_admin': {
id: '/_admin'
path: ''
fullPath: '/'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/termin': {
id: '/termin'
path: '/termin'
fullPath: '/termin'
preLoaderRoute: typeof TerminRouteImport
parentRoute: typeof rootRouteImport
}
'/_admin/admin': {
id: '/_admin/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminAdminRouteImport
parentRoute: typeof AdminRoute
}
'/_authenticated/dashboard': {
id: '/_authenticated/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_admin/admin/': {
id: '/_admin/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AdminAdminIndexRouteImport
parentRoute: typeof AdminAdminRoute
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_admin/admin/users/$userId': {
id: '/_admin/admin/users/$userId'
path: '/users/$userId'
fullPath: '/admin/users/$userId'
preLoaderRoute: typeof AdminAdminUsersUserIdRouteImport
parentRoute: typeof AdminAdminRoute
}
'/_admin/admin/users/new': {
id: '/_admin/admin/users/new'
path: '/users/new'
fullPath: '/admin/users/new'
preLoaderRoute: typeof AdminAdminUsersNewRouteImport
parentRoute: typeof AdminAdminRoute
}
}
}
interface AdminAdminRouteChildren {
AdminAdminIndexRoute: typeof AdminAdminIndexRoute
AdminAdminUsersUserIdRoute: typeof AdminAdminUsersUserIdRoute
AdminAdminUsersNewRoute: typeof AdminAdminUsersNewRoute
}
const AdminAdminRouteChildren: AdminAdminRouteChildren = {
AdminAdminIndexRoute: AdminAdminIndexRoute,
AdminAdminUsersUserIdRoute: AdminAdminUsersUserIdRoute,
AdminAdminUsersNewRoute: AdminAdminUsersNewRoute,
}
const AdminAdminRouteWithChildren = AdminAdminRoute._addFileChildren(
AdminAdminRouteChildren,
)
interface AdminRouteChildren {
AdminAdminRoute: typeof AdminAdminRouteWithChildren
}
const AdminRouteChildren: AdminRouteChildren = {
AdminAdminRoute: AdminAdminRouteWithChildren,
}
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
TerminRoute: TerminRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+22
View File
@@ -0,0 +1,22 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
context: {
session: null,
},
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
+109
View File
@@ -0,0 +1,109 @@
import { HeadContent, Scripts, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import type { Session } from '#/lib/auth.ts'
import { getSessionFn } from '#/lib/session.ts'
import appCss from '../styles.css?url'
export interface RouterAppContext {
session: Session | null
}
const themeItnitScript = `
(function() {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`
export const Route = createRootRouteWithContext<RouterAppContext>()({
beforeLoad: async () => {
const session = await getSessionFn()
return { session }
},
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
title:
'webkulisse Professionelles Webdesign für Handwerk, Praxis & Therapie',
},
{
name: 'description',
content:
'Handgemachte Webseiten für Handwerk, Praxis und Therapie. Blitzschnell, sicher und individuell ohne technischen Ballast.',
},
// Suchmaschinen: Seite indexieren und Links folgen.
{ name: 'robots', content: 'index,follow' },
// Adressleisten-Farbe in mobilen Browsern (hell/dunkel getrennt).
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#e7f3ec' },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0a1418' },
// Open Graph (Vorschau in WhatsApp, Slack, LinkedIn …)
{ property: 'og:type', content: 'website' },
{ property: 'og:locale', content: 'de_DE' },
{ property: 'og:site_name', content: 'webkulisse' },
{
property: 'og:title',
content: 'webkulisse Webdesign für Handwerk, Praxis & Therapie',
},
{
property: 'og:description',
content:
'Handgemachte Webseiten blitzschnell, sicher und individuell.',
},
{ property: 'og:image', content: '/img/logo.png' },
// Twitter/X Card
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:title', content: 'webkulisse' },
{
name: 'twitter:description',
content:
'Handgemachte Webseiten blitzschnell, sicher und individuell.',
},
{ name: 'twitter:image', content: '/img/logo.png' },
],
links: [
{ rel: 'stylesheet', href: appCss },
// Favicon wird in Browser-Tabs, Bookmarks und PWA-Icons genutzt.
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{ rel: 'apple-touch-icon', href: '/img/logo.png' },
// Kanonische URL: verhindert Duplicate-Content zwischen www./ohne, http/https,
// Query-Varianten. Pro Route in eigener head()-Funktion überschreiben,
// z. B. im Blog: { rel: 'canonical', href: `${SITE_URL}/blog/${slug}` }.
{ rel: 'canonical', href: 'https://webkulisse.de/' },
],
scripts: [{ children: themeItnitScript }]
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="de" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_admin')({
beforeLoad: ({ context, location }) => {
if (!context.session) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
if (context.session.user.role !== 'admin') {
throw redirect({ to: '/dashboard' })
}
return { session: context.session }
},
component: () => <Outlet />,
})
+168
View File
@@ -0,0 +1,168 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { Plus, ShieldCheck, UserMinus, UserPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '#/components/ui/button.tsx'
import { authClient } from '#/lib/auth-client.ts'
export const Route = createFileRoute('/_admin/admin/')({
component: AdminUsersPage,
})
type AdminUser = {
id: string
email: string
name: string
role?: string | null
banned?: boolean | null
createdAt: string | Date
}
function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [busyId, setBusyId] = useState<string | null>(null)
async function load() {
setError(null)
const res = await authClient.admin.listUsers({
query: { limit: 100, sortBy: 'createdAt', sortDirection: 'desc' },
})
if (res.error) {
setError(res.error.message ?? 'Kunden konnten nicht geladen werden.')
return
}
setUsers((res.data?.users ?? []) as AdminUser[])
}
useEffect(() => {
void load()
}, [])
async function toggleBan(user: AdminUser) {
setBusyId(user.id)
if (user.banned) {
await authClient.admin.unbanUser({ userId: user.id })
} else {
await authClient.admin.banUser({ userId: user.id })
}
setBusyId(null)
await load()
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-wide text-muted-foreground">
Adminbereich
</p>
<h1 className="mt-1 font-heading text-3xl font-bold">Kunden</h1>
<p className="mt-1 text-sm text-muted-foreground">
Verwalte Zugaenge und lege neue Kunden an.
</p>
</div>
<Button asChild>
<Link to="/admin/users/new">
<Plus />
Neuer Kunde
</Link>
</Button>
</div>
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
) : null}
<div className="overflow-hidden rounded-xl border border-border bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">E-Mail</th>
<th className="px-4 py-3 font-medium">Rolle</th>
<th className="px-4 py-3 font-medium">Angelegt</th>
<th className="px-4 py-3 font-medium text-right">Aktion</th>
</tr>
</thead>
<tbody>
{users === null ? (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-muted-foreground">
Lade Kunden ...
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-muted-foreground">
Noch keine Kunden angelegt.
</td>
</tr>
) : (
users.map((u) => (
<tr key={u.id} className="border-t border-border hover:bg-muted/30">
<td className="px-4 py-3 font-medium">
<Link
to="/admin/users/$userId"
params={{ userId: u.id }}
className="hover:underline"
>
{u.name}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{u.email}</td>
<td className="px-4 py-3">
{u.role === 'admin' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<ShieldCheck className="size-3" />
Admin
</span>
) : (
<span className="text-xs text-muted-foreground">Kunde</span>
)}
{u.banned ? (
<span className="ml-2 inline-flex items-center rounded-md bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
gesperrt
</span>
) : null}
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(u.createdAt).toLocaleDateString('de-DE')}
</td>
<td className="px-4 py-3 text-right">
{u.role === 'admin' ? (
<span className="text-xs text-muted-foreground">-</span>
) : (
<Button
variant={u.banned ? 'outline' : 'destructive'}
size="sm"
disabled={busyId === u.id}
onClick={() => toggleBan(u)}
>
{u.banned ? (
<>
<UserPlus />
Entsperren
</>
) : (
<>
<UserMinus />
Sperren
</>
)}
</Button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell.tsx'
export const Route = createFileRoute('/_admin/admin')({
component: AdminLayout,
})
function AdminLayout() {
const { session } = Route.useRouteContext()
return (
<AppShell
session={session}
nav={
<>
<Link
to="/admin"
activeOptions={{ exact: true }}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground [&.active]:bg-muted [&.active]:text-foreground"
>
Kunden
</Link>
<Link
to="/admin/users/new"
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground [&.active]:bg-muted [&.active]:text-foreground"
>
Neuer Kunde
</Link>
</>
}
>
<Outlet />
</AppShell>
)
}
+489
View File
@@ -0,0 +1,489 @@
import { Link, createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'
import { ArrowLeft, KeyRound, LogOut, ShieldCheck, Trash2, UserCog } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '#/components/ui/button.tsx'
import { authClient } from '#/lib/auth-client.ts'
export const Route = createFileRoute('/_admin/admin/users/$userId')({
component: UserDetailPage,
})
type AdminUser = {
id: string
email: string
name: string
role?: string | null
banned?: boolean | null
banReason?: string | null
banExpires?: string | Date | null
createdAt: string | Date
updatedAt: string | Date
}
function UserDetailPage() {
const { userId } = Route.useParams()
const { session } = Route.useRouteContext()
const navigate = useNavigate()
const [user, setUser] = useState<AdminUser | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const isSelf = session.user.id === userId
async function load() {
setLoading(true)
setError(null)
const res = await authClient.admin.listUsers({
query: {
limit: 1,
filterField: 'id',
filterOperator: 'eq',
filterValue: userId,
},
})
setLoading(false)
if (res.error) {
setError(res.error.message ?? 'Kunde konnte nicht geladen werden.')
return
}
const found = (res.data?.users ?? [])[0] as AdminUser | undefined
if (!found) {
setError('Kunde nicht gefunden.')
return
}
setUser(found)
}
useEffect(() => {
void load()
}, [userId])
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<Link
to="/admin"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Zurueck zur Liste
</Link>
<h1 className="mt-2 font-heading text-3xl font-bold">
{user ? user.name : 'Kunde'}
</h1>
{user ? (
<p className="text-sm text-muted-foreground">{user.email}</p>
) : null}
</div>
</div>
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
) : null}
{notice ? (
<p className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-primary">
{notice}
</p>
) : null}
{loading || !user ? (
<p className="text-sm text-muted-foreground">Lade Kunde ...</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
<UserSummaryCard user={user} isSelf={isSelf} />
<SetPasswordCard
userId={user.id}
onDone={(msg) => {
setNotice(msg)
setError(null)
}}
onError={(msg) => {
setError(msg)
setNotice(null)
}}
/>
<RoleCard
userId={user.id}
role={user.role ?? 'user'}
disabled={isSelf}
onDone={async (msg) => {
setNotice(msg)
setError(null)
await load()
}}
onError={(msg) => {
setError(msg)
setNotice(null)
}}
/>
<ImpersonateCard
userId={user.id}
userName={user.name}
disabled={isSelf || user.role === 'admin'}
reason={
isSelf
? 'Du kannst dich nicht selbst impersonieren.'
: user.role === 'admin'
? 'Andere Admins koennen nicht impersoniert werden.'
: null
}
onError={(msg) => {
setError(msg)
setNotice(null)
}}
/>
<DangerZoneCard
userId={user.id}
userName={user.name}
disabled={isSelf}
onDeleted={() => navigate({ to: '/admin' })}
onSessionsRevoked={(msg) => {
setNotice(msg)
setError(null)
}}
onError={(msg) => {
setError(msg)
setNotice(null)
}}
/>
</div>
)}
</div>
)
}
function Card({
title,
icon,
children,
tone = 'default',
}: {
title: string
icon?: React.ReactNode
children: React.ReactNode
tone?: 'default' | 'danger'
}) {
return (
<section
className={
tone === 'danger'
? 'rounded-xl border border-destructive/30 bg-card p-5'
: 'rounded-xl border border-border bg-card p-5'
}
>
<h2 className="flex items-center gap-2 font-heading text-lg font-semibold">
{icon}
{title}
</h2>
<div className="mt-3 space-y-3">{children}</div>
</section>
)
}
function UserSummaryCard({ user, isSelf }: { user: AdminUser; isSelf: boolean }) {
return (
<Card title="Stammdaten">
<dl className="grid grid-cols-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">Name</dt>
<dd className="col-span-2 font-medium">{user.name}</dd>
<dt className="text-muted-foreground">E-Mail</dt>
<dd className="col-span-2">{user.email}</dd>
<dt className="text-muted-foreground">Rolle</dt>
<dd className="col-span-2">
{user.role === 'admin' ? 'Admin' : 'Kunde'}
{isSelf ? (
<span className="ml-2 text-xs text-muted-foreground">(du)</span>
) : null}
</dd>
<dt className="text-muted-foreground">Status</dt>
<dd className="col-span-2">
{user.banned ? (
<span className="text-destructive">
Gesperrt{user.banReason ? ` - ${user.banReason}` : ''}
</span>
) : (
'Aktiv'
)}
</dd>
<dt className="text-muted-foreground">Angelegt</dt>
<dd className="col-span-2">
{new Date(user.createdAt).toLocaleString('de-DE')}
</dd>
</dl>
</Card>
)
}
function SetPasswordCard({
userId,
onDone,
onError,
}: {
userId: string
onDone: (msg: string) => void
onError: (msg: string) => void
}) {
const [pending, setPending] = useState(false)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const newPassword = String(data.get('newPassword') ?? '')
if (newPassword.length < 8) {
onError('Das Passwort muss mindestens 8 Zeichen lang sein.')
return
}
setPending(true)
const res = await authClient.admin.setUserPassword({
userId,
newPassword,
})
setPending(false)
if (res.error) {
onError(res.error.message ?? 'Passwort konnte nicht gesetzt werden.')
return
}
form.reset()
onDone('Neues Passwort gesetzt. Teile es sicher mit dem Kunden.')
}
return (
<Card title="Passwort setzen" icon={<KeyRound className="size-4" />}>
<form onSubmit={onSubmit} className="space-y-3">
<input
type="text"
name="newPassword"
minLength={8}
required
placeholder="Neues Passwort"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<p className="text-xs text-muted-foreground">
Ueberschreibt das aktuelle Passwort sofort. Der Kunde muss sich neu
anmelden.
</p>
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={pending}>
{pending ? 'Speichern ...' : 'Passwort setzen'}
</Button>
</div>
</form>
</Card>
)
}
function RoleCard({
userId,
role,
disabled,
onDone,
onError,
}: {
userId: string
role: string
disabled: boolean
onDone: (msg: string) => Promise<void> | void
onError: (msg: string) => void
}) {
const [pending, setPending] = useState(false)
const [selected, setSelected] = useState(role)
async function apply() {
if (selected === role) return
setPending(true)
const res = await authClient.admin.setRole({
userId,
role: selected as 'user' | 'admin',
})
setPending(false)
if (res.error) {
onError(res.error.message ?? 'Rolle konnte nicht geaendert werden.')
return
}
await onDone(`Rolle auf ${selected === 'admin' ? 'Admin' : 'Kunde'} gesetzt.`)
}
return (
<Card title="Rolle" icon={<ShieldCheck className="size-4" />}>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={disabled || pending}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
>
<option value="user">Kunde</option>
<option value="admin">Admin</option>
</select>
{disabled ? (
<p className="text-xs text-muted-foreground">
Du kannst deine eigene Rolle nicht aendern.
</p>
) : null}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={apply}
disabled={disabled || pending || selected === role}
>
{pending ? 'Speichern ...' : 'Uebernehmen'}
</Button>
</div>
</Card>
)
}
function ImpersonateCard({
userId,
userName,
disabled,
reason,
onError,
}: {
userId: string
userName: string
disabled: boolean
reason: string | null
onError: (msg: string) => void
}) {
const router = useRouter()
const navigate = useNavigate()
const [pending, setPending] = useState(false)
async function impersonate() {
setPending(true)
const res = await authClient.admin.impersonateUser({ userId })
if (res.error) {
setPending(false)
onError(res.error.message ?? 'Impersonation fehlgeschlagen.')
return
}
await router.invalidate()
setPending(false)
await navigate({ to: '/dashboard' })
}
return (
<Card title="Support-Zugang" icon={<UserCog className="size-4" />}>
<p className="text-sm text-muted-foreground">
Melde dich voruebergehend als {userName} an, um Support-Faelle direkt zu
pruefen. Ein Banner zeigt die Impersonation an, du kannst jederzeit zurueck.
</p>
{reason ? (
<p className="text-xs text-muted-foreground">{reason}</p>
) : null}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || pending}
onClick={impersonate}
>
<UserCog />
{pending ? 'Wechsle ...' : 'Als Kunde einloggen'}
</Button>
</div>
</Card>
)
}
function DangerZoneCard({
userId,
userName,
disabled,
onDeleted,
onSessionsRevoked,
onError,
}: {
userId: string
userName: string
disabled: boolean
onDeleted: () => void
onSessionsRevoked: (msg: string) => void
onError: (msg: string) => void
}) {
const [pending, setPending] = useState<'delete' | 'revoke' | null>(null)
async function revokeSessions() {
setPending('revoke')
const res = await authClient.admin.revokeUserSessions({ userId })
setPending(null)
if (res.error) {
onError(res.error.message ?? 'Sitzungen konnten nicht beendet werden.')
return
}
onSessionsRevoked('Alle aktiven Sitzungen wurden beendet.')
}
async function removeUser() {
if (!confirm(`Kunde "${userName}" wirklich unwiderruflich loeschen?`)) {
return
}
setPending('delete')
const res = await authClient.admin.removeUser({ userId })
setPending(null)
if (res.error) {
onError(res.error.message ?? 'Kunde konnte nicht geloescht werden.')
return
}
onDeleted()
}
return (
<Card title="Gefahrenzone" icon={<Trash2 className="size-4" />} tone="danger">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Aktive Sitzungen beenden</p>
<p className="text-xs text-muted-foreground">
Der Kunde wird sofort abgemeldet.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={pending !== null}
onClick={revokeSessions}
>
<LogOut />
{pending === 'revoke' ? 'Abmelden ...' : 'Abmelden'}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Kunde loeschen</p>
<p className="text-xs text-muted-foreground">
Loescht Account, Sitzungen und Zugangsdaten.
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={disabled || pending !== null}
onClick={removeUser}
>
<Trash2 />
{pending === 'delete' ? 'Loeschen ...' : 'Loeschen'}
</Button>
</div>
{disabled ? (
<p className="text-xs text-muted-foreground">
Du kannst dich nicht selbst loeschen oder ausloggen.
</p>
) : null}
</div>
</Card>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '#/components/ui/button.tsx'
import { authClient } from '#/lib/auth-client.ts'
export const Route = createFileRoute('/_admin/admin/users/new')({
component: NewUserPage,
})
function NewUserPage() {
const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState<'user' | 'admin'>('user')
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
if (password.length < 8) {
setError('Das Passwort muss mindestens 8 Zeichen lang sein.')
return
}
setPending(true)
const res = await authClient.admin.createUser({
name,
email,
password,
role,
})
setPending(false)
if (res.error) {
setError(res.error.message ?? 'Kunde konnte nicht angelegt werden.')
return
}
await navigate({ to: '/admin' })
}
return (
<div className="mx-auto max-w-xl space-y-6">
<div>
<p className="text-sm uppercase tracking-wide text-muted-foreground">
Adminbereich
</p>
<h1 className="mt-1 font-heading text-3xl font-bold">Neuer Kunde</h1>
<p className="mt-1 text-sm text-muted-foreground">
Lege einen neuen Zugang an. Der Kunde meldet sich anschliessend mit
E-Mail und Passwort an.
</p>
</div>
<form
onSubmit={onSubmit}
className="space-y-4 rounded-xl border border-border bg-card p-6"
>
<div className="space-y-1.5">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<input
id="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium">
E-Mail
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-sm font-medium">
Initiales Passwort
</label>
<input
id="password"
type="text"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<p className="text-xs text-muted-foreground">
Mindestens 8 Zeichen. Teile das Passwort sicher mit dem Kunden.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="role" className="text-sm font-medium">
Rolle
</label>
<select
id="role"
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="user">Kunde</option>
<option value="admin">Admin</option>
</select>
</div>
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
) : null}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: '/admin' })}
>
Abbrechen
</Button>
<Button type="submit" disabled={pending}>
{pending ? 'Anlegen ...' : 'Anlegen'}
</Button>
</div>
</form>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.session) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { session: context.session }
},
component: () => <Outlet />,
})
+53
View File
@@ -0,0 +1,53 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell.tsx'
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardPage,
})
function DashboardPage() {
const { session } = Route.useRouteContext()
return (
<AppShell session={session}>
<div className="space-y-6">
<div>
<p className="text-sm uppercase tracking-wide text-muted-foreground">
Kundenbereich
</p>
<h1 className="mt-1 font-heading text-3xl font-bold">
Willkommen, {session.user.name}.
</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
Hier findest du bald deine Projekte, Rechnungen und Nachrichten.
Dieser Bereich wird gerade eingerichtet.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<PlaceholderCard title="Projekte" description="Aktueller Stand deiner Webseite." />
<PlaceholderCard title="Nachrichten" description="Direkter Draht zu deinem Ansprechpartner." />
<PlaceholderCard title="Dokumente" description="Vertraege, Angebote und Rechnungen." />
</div>
</div>
</AppShell>
)
}
function PlaceholderCard({
title,
description,
}: {
title: string
description: string
}) {
return (
<div className="rounded-xl border border-border bg-card p-5">
<h2 className="font-heading text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<p className="mt-4 text-xs uppercase tracking-wide text-muted-foreground">
In Vorbereitung
</p>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { auth } from '#/lib/auth'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})
+28
View File
@@ -0,0 +1,28 @@
import { createFileRoute } from '@tanstack/react-router'
import Header from '#/components/header.tsx'
import Hero from '#/components/hero.tsx'
import { Services } from '#/components/services.tsx'
import { Features } from '#/components/features.tsx'
import { Showcase } from '#/components/showcase.tsx'
import { Process } from '#/components/process.tsx'
import { Pricing } from '#/components/pricing.tsx'
import { Footer } from '#/components/footer.tsx'
export const Route = createFileRoute('/')({ component: Home })
function Home() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Hero />
<Services />
<Features />
<Showcase />
<Process />
<Pricing />
</main>
<Footer />
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { createFileRoute, redirect, useNavigate, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '#/components/ui/button.tsx'
import { authClient } from '#/lib/auth-client.ts'
export const Route = createFileRoute('/login')({
beforeLoad: ({ context }) => {
if (context.session) {
throw redirect({
to: context.session.user.role === 'admin' ? '/admin' : '/dashboard',
})
}
},
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const email = String(data.get('email') ?? '').trim()
const password = String(data.get('password') ?? '')
setError(null)
setPending(true)
const res = await authClient.signIn.email({ email, password })
setPending(false)
if (res.error) {
const code = res.error.code
if (code === 'INVALID_EMAIL_OR_PASSWORD') {
setError('E-Mail oder Passwort ist falsch.')
} else if (code === 'INVALID_EMAIL') {
setError('Bitte eine gueltige E-Mail-Adresse eingeben.')
} else if (code === 'USER_BANNED') {
setError('Dieser Zugang ist gesperrt. Bitte kontaktiere uns.')
} else {
setError(res.error.message ?? 'Anmeldung fehlgeschlagen.')
}
return
}
const role = res.data?.user?.role
await navigate({ to: role === 'admin' ? '/admin' : '/dashboard' })
}
return (
<main className="flex min-h-screen items-center justify-center px-4 py-12">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 shadow-sm">
<div className="mb-6 flex items-center gap-3">
<Link to="/">
<img
alt="Logo Webkulisse"
src="/img/logo.png"
className="h-10 w-10 rounded-lg"
/>
</Link>
<div>
<h1 className="font-heading text-2xl font-bold leading-tight">
Kundenlogin
</h1>
<p className="text-sm text-muted-foreground">
Melde dich mit deinen Zugangsdaten an.
</p>
</div>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium">
E-Mail
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue=""
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-sm font-medium">
Passwort
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
defaultValue=""
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
) : null}
<Button type="submit" disabled={pending} className="w-full">
{pending ? 'Anmelden ...' : 'Anmelden'}
</Button>
<p className="text-center text-xs text-muted-foreground">
Kein Konto? Neue Zugaenge legt dein Ansprechpartner bei webkulisse an.
</p>
</form>
</div>
</main>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
import BookingEmbed from "@/components/booking-embed.tsx";
export const Route = createFileRoute("/termin")({
component: TerminPage,
head: () => ({
meta: [
{ title: "Termin buchen - webkulisse" },
{
name: "description",
content:
"Buchen Sie ein 30-minütiges, kostenloses Erstgespräch mit webkulisse - direkt online, keine Warteschleifen.",
},
],
links: [{ rel: "canonical", href: "https://webkulisse.de/termin" }],
}),
});
function TerminPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-6xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Erstgespräch</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Termin online buchen.
</h1>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
30 Minuten, kostenlos - suchen Sie sich einfach einen freien
Slot aus. Sie bekommen die Termin-Bestätigung direkt per Mail.
</p>
</div>
</section>
<section className="mx-auto max-w-6xl px-4 pb-16 md:px-6 md:pb-24">
<div className="island-shell rounded-3xl p-2 sm:p-4">
<div className="h-[80vh] min-h-[600px] w-full overflow-hidden rounded-2xl">
<BookingEmbed />
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/nunito-sans";
@import "@fontsource-variable/public-sans";
@import "dev-component/devtool.css";
@font-face {
font-family: Vend Sans;
src: url('/public/fonts/VendSans-VariableFont_wght.woff2');
font-weight: normal;
font-style: normal;
}
@plugin '@tailwindcss/typography';
@custom-variant dark (&:is(.dark *));
:root {
--sea-ink: #173a40;
--sea-ink-soft: #416166;
--lagoon: #4fb8b2;
--lagoon-deep: #328f97;
--palm: #2f6a4a;
--sand: #e7f0e8;
--foam: #f3faf5;
--surface: rgba(255, 255, 255, 0.74);
--surface-strong: rgba(255, 255, 255, 0.9);
--line: rgba(23, 58, 64, 0.14);
--inset-glint: rgba(255, 255, 255, 0.82);
--kicker: rgba(47, 106, 74, 0.9);
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
--chip-bg: rgba(255, 255, 255, 0.8);
--chip-line: rgba(47, 106, 74, 0.18);
--link-bg-hover: rgba(255, 255, 255, 0.9);
--hero-a: rgba(79, 184, 178, 0.36);
--hero-b: rgba(47, 106, 74, 0.2);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.508 0.118 165.612);
--primary-foreground: oklch(0.979 0.021 166.113);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--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 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.596 0.145 163.225);
--sidebar-primary-foreground: oklch(0.979 0.021 166.113);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.432 0.095 166.913);
--primary-foreground: oklch(0.979 0.021 166.113);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--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.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.696 0.17 162.48);
--sidebar-primary-foreground: oklch(0.262 0.051 172.552);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--font-sans: 'Vend Sans', sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-heading: 'Public Sans Variable', sans-serif;
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
@layer base {
a {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover {
color: #246f76;
}
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.prose pre {
border: 1px solid var(--line);
border-radius: 12px;
background: #1d2e45;
color: #e8efff;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: 'Fraunces', Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
button,
.island-shell,
a {
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
transform 180ms ease;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
.nav-link {
position: relative;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: '';
position: absolute;
left: 0;
bottom: -8px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
}
/* Accent-Variante: farbstarker Call-to-Action-Button. */
button[data-type='accent'],
a[data-type='accent'] {
background: linear-gradient(135deg, var(--lagoon), var(--palm));
color: white;
border-color: transparent;
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 12px 24px rgba(30, 90, 72, 0.22);
}
button[data-type='accent']:hover,
a[data-type='accent']:hover {
background: linear-gradient(135deg, var(--lagoon-deep), var(--palm));
color: white;
}
/* Bilder sollen ihr Alignment behalten, ohne die Layoutbreite zu sprengen. */
img {
max-width: 100%;
height: auto;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
background-color: var(--background);
color: var(--foreground);
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* Theme Toggle */
.theme-toggle {
display: none;
}
.theme-toggle__label {
/* Groesse ueber diese zwei Variablen steuern -- alles andere wird berechnet.
Overriden per Style-Attribut, Elternklasse, oder Utility (--tta-size: ...). */
--tta-size: 2em; /* Hoehe des Switches — ggf. an euren size(10)-Wert anpassen */
--tta-ratio: 2; /* Breite = Hoehe × Ratio (2 = Pill 2:1) */
/* Abgeleitete Werte -- nicht ueberschreiben. */
--tta-width: calc(var(--tta-size) * var(--tta-ratio));
--tta-pad: calc(var(--tta-size) * 0.1);
--tta-knob: calc(var(--tta-size) - var(--tta-pad) * 2);
--tta-icon: calc(var(--tta-size) * 0.6);
--tta-travel: calc(var(--tta-width) - var(--tta-knob) - var(--tta-pad) * 2);
width: var(--tta-width);
height: var(--tta-size);
position: relative;
display: block;
background: white;
border-radius: var(--tta-size);
box-shadow: inset 0 5px 15px rgba(0, 0, 0, 0.4),
inset 0 -5px 15px rgba(0, 0, 0, 0.4);
cursor: pointer;
transition: background-color 0.3s ease;
}
.theme-toggle__label::after {
content: '';
width: var(--tta-knob);
height: var(--tta-knob);
position: absolute;
top: var(--tta-pad);
left: var(--tta-pad);
background: linear-gradient(180deg, #ffcc89, #d8860b);
border-radius: var(--tta-knob);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease;
}
.theme-toggle__label svg {
position: absolute;
width: var(--tta-icon);
top: calc((var(--tta-size) - var(--tta-icon)) / 2);
z-index: 1;
}
/* Icon-Positionierung symmetrisch: mittig im jeweiligen "Knubbel-Slot". */
.theme-toggle__sun {
left: calc(var(--tta-pad) + (var(--tta-knob) - var(--tta-icon)) / 2);
fill: gold;
transition: fill 0.3s ease;
}
.theme-toggle__moon {
right: calc(var(--tta-pad) + (var(--tta-knob) - var(--tta-icon)) / 2);
fill: dimgray;
transition: fill 0.3s ease;
}
.theme-toggle:checked + .theme-toggle__label {
background: #242424;
}
.theme-toggle:checked + .theme-toggle__label::after {
transform: translateX(var(--tta-travel));
background: linear-gradient(180deg, #777, #3a3a3a);
}
.theme-toggle:checked + .theme-toggle__label .theme-toggle__sun {
fill: dimgray;
}
.theme-toggle:checked + .theme-toggle__label .theme-toggle__moon {
fill: deepskyblue;
}
+27
View File
@@ -0,0 +1,27 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"target": "react"
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart(),
viteReact(),
babel({ presets: [reactCompilerPreset()] }),
],
})
export default config