Auth fertig erstellt. Aktuell läuft review durch KI
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
__unconfig*
|
||||||
|
todos.json
|
||||||
|
scripts
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 -->
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787995179811,
|
||||||
|
"tag": "0000_majestic_hammerhead",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 })
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { adminClient } from 'better-auth/client/plugins'
|
||||||
|
import { createAuthClient } from 'better-auth/react'
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [adminClient()],
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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*/}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 />,
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 />,
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"target": "react"
|
||||||
|
}
|
||||||
@@ -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
|
||||||