Starterpacket von FSC & Shadcn eingebunden

This commit is contained in:
Admin committed 2026-10-28 16:59:03 +01:00
1 parent 6cf7985a44
commit 872bce2cfe
25 files changed
+5407

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# Frontend-Starter (Next.js 16 + React 19 + Tailwind 4)
Kleine, lauffähige Oberfläche für die Schulung. Sie ruft einen Webhook deines eigenen n8n-Workflows auf.
## Auf einen Blick
- **Was sie tut:** Ein Formular schickt einen Namen an deinen Webhook und zeigt die Antwort.
- **Wohin:** `https://n8n.ep-reisen.app/webhook/<dein-pfad>`, zum Beispiel `anna-hello`.
- **Aussehen:** E&P-Layout wie auf ep-reisen.de. Kopf mit Logo und Fuß kommen aus `app/layout.tsx`, Schrift, Farben und Bausteine (`ep-karte`, `ep-feld`, `ep-knopf` …) aus `app/globals.css`. Den Namen der Oberfläche setzt `lib/app.ts`.
- **Veröffentlichen:** Sag Claude „Veröffentliche meine Oberfläche“. Claude baut sie, pusht auf `main` und ruft den Hub auf. Du bekommst eine Adresse wie `https://app-xxxxxxxx.friedemannschuetz.de`.
- **Erster Aufbau:** etwa drei Minuten. Bis dahin zeigt die Seite einen Fehler wie „no available server“. Das ist normal.
## So hängt es zusammen
1. In n8n baut Claude den Workflow aus `../examples/workflows/hello-webhook.json`, mit deinem Kürzel im Pfad, zum Beispiel `anna-hello`.
2. Beim ersten Veröffentlichen gibt Claude `NEXT_PUBLIC_HELLO_PATH=anna-hello` als Variable mit. Ohne diesen Pfad ruft die Seite nichts auf.
3. `NEXT_PUBLIC_N8N_BASE` ist schon auf `https://n8n.ep-reisen.app` eingestellt.
Variablen gelten nur beim **ersten** Veröffentlichen. Später ändert sie nur Friedemann.
## Der KI-Chat bleibt aus
`/chat` und `app/api/chat/route.ts` zeigen, wie ein KI-Chat mit Streaming aussieht. Der Endpoint hat **keinen Login**. Mit einem echten Schlüssel könnte jede Person, die die Adresse kennt, auf dessen Kosten chatten. Deshalb ist er aus (`CHAT_ENABLED`) und bleibt in der Schulung aus. KI läuft in n8n.
## Struktur
```
app/page.tsx Formular, ruft den Webhook auf
app/chat/page.tsx KI-Chat (aus)
app/api/chat/route.ts Streaming-Endpoint (ohne Login, standardmäßig aus)
lib/n8n.ts Webhook-Helfer mit Standardadresse der n8n
lib/ai/model.ts Modell für den Chat
.env.local.example Liste der Variablen (nur Platzhalter)
```
## Vor dem Veröffentlichen
`npm install` und `npm run build` müssen grün sein. Danach committen, auf `main` pushen und über den Hub veröffentlichen (`/deploy` mit `base_dir=/frontend-starter`). Den Ablauf kennt Claude aus `CLAUDE.md`.
+52
View File
@@ -0,0 +1,52 @@
import { streamText, convertToModelMessages, type UIMessage } from "ai";
import { getModel } from "@/lib/ai/model";
// Streaming-Chat-Endpoint (Muster C). Wird von useChat (Default /api/chat) aufgerufen.
//
// ACHTUNG: Dieser Endpoint hat KEINEN Login. Wer die Adresse der App kennt, kann hier
// auf Kosten des hinterlegten ANTHROPIC_API_KEY chatten (offener KI-Proxy).
// Deshalb ist er standardmäßig AUS und läuft nur mit CHAT_ENABLED=true.
// Nie mit echtem Key öffentlich veröffentlichen, solange die App keinen Schutz hat
// (Login, Zugangscode). Die Grenzen unten dämpfen Missbrauch nur, sie verhindern ihn nicht.
const MAX_MESSAGES = 40;
const MAX_REQUEST_CHARS = 60000;
const MAX_OUTPUT_TOKENS = 1024;
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export async function POST(req: Request) {
if (process.env.CHAT_ENABLED !== "true") {
return new Response(
"KI-Chat ist deaktiviert. Zum Einschalten CHAT_ENABLED=true setzen (nur mit Schutz öffentlich betreiben).",
{ status: 403 },
);
}
const raw = await req.text();
if (raw.length > MAX_REQUEST_CHARS) {
return new Response("Anfrage zu groß.", { status: 413 });
}
const body = parseJson(raw) as { messages?: unknown } | null;
const messages = body?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Ungültige Anfrage.", { status: 400 });
}
if (messages.length > MAX_MESSAGES) {
return new Response("Der Chat ist zu lang. Bitte die Seite neu laden.", { status: 413 });
}
const result = streamText({
model: getModel(),
messages: await convertToModelMessages(messages as UIMessage[]),
maxOutputTokens: MAX_OUTPUT_TOKENS,
});
return result.toUIMessageStreamResponse();
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
// Muster C: KI-Chat mit Token-Streaming. Ruft POST /api/chat auf.
// Achtung: /api/chat hat keinen Login und ist nur mit CHAT_ENABLED=true aktiv (siehe route.ts).
// In der Schulung bleibt der Chat aus. KI läuft über einen n8n-Workflow.
export default function ChatPage() {
const { messages, sendMessage, status, error } = useChat();
const [input, setInput] = useState("");
const busy = status === "submitted" || status === "streaming";
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput("");
}
return (
<div className="flex flex-col gap-4">
<h1 className="ep-ueberschrift text-3xl">KI-Chat (Muster C)</h1>
<p className="text-sm text-ink-muted">
Streamt Antworten von Claude. Braucht <code>ANTHROPIC_API_KEY</code> und <code>CHAT_ENABLED=true</code>. Ohne
Login nie mit echtem Key öffentlich veröffentlichen.
</p>
<div className="flex flex-col gap-3">
{messages.map((message) => (
<div
key={message.id}
className={
message.role === "user"
? "self-end bg-accent px-3 py-2 text-on-accent"
: "self-start border border-hairline bg-surface px-3 py-2"
}
>
{message.parts.map((part, i) => (part.type === "text" ? <span key={i}>{part.text}</span> : null))}
</div>
))}
</div>
{error && <div className="ep-fehler">{error.message}</div>}
<form onSubmit={onSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Frag mich etwas …"
className="ep-feld flex-1"
/>
<button type="submit" disabled={busy} className="ep-knopf">
Senden
</button>
</form>
</div>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+363
View File
@@ -0,0 +1,363 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
/* ------------------------------------------------------------------ *
* E&P-Layout wie auf ep-reisen.de und im EP-Budget-Tool:
* Lato, Tiefblau #18527b als Akzent, Magenta-Verlauf nur für die
* Hauptaktion (.ep-cta), eckige Formen, hellblauer Seitenhintergrund.
* Jede Oberfläche der Schulung nutzt genau diese Werte und Bausteine.
* Keine weiteren Farben, keine anderen Schriften, keine runden Ecken.
* ------------------------------------------------------------------ */
@theme inline {
--color-ground: var(--ground);
--color-surface: var(--surface);
--color-hairline: var(--hairline);
--color-ink: var(--ink);
--color-ink-muted: var(--ink-muted);
--color-accent: var(--accent);
--color-accent-press: var(--accent-press);
--color-accent-soft: var(--accent-soft);
--color-on-accent: var(--on-accent);
--color-strich: var(--strich);
--color-fehler: var(--fehler);
/* alter Name aus früheren Versionen des Starters, zeigt jetzt auf das Tiefblau */
--color-brand: var(--accent);
--font-heading: var(--font-sans);
--font-body: var(--body-font);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* Eckig wie ep-reisen.de: alle Tailwind-Radien auf 0 */
@theme {
--radius-xs: 0px;
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
--radius-2xl: 0px;
--radius-3xl: 0px;
}
:root {
--ground: #ebf3fa; /* Seitenhintergrund */
--surface: #ffffff; /* Flächen, Karten, Kopfleiste */
--hairline: #d8e3ed; /* Linien */
--ink: #27272a; /* Text */
--ink-muted: #52525b; /* gedämpfter Text */
--accent: oklch(0.97 0 0); /* E&P-Tiefblau */
--accent-press: #103c5b;
--accent-soft: #e3eef8;
--on-accent: #ffffff;
--strich: #009fff; /* nur Zierstrich unter Überschriften, nie für Text */
--fehler: #c0261b; /* Fehlermeldungen */
/* --font-lato setzt next/font auf <html>, siehe app/layout.tsx */
--heading-font: var(--font-lato), "Lato", ui-sans-serif, system-ui, sans-serif;
--body-font: var(--font-lato), "Lato", ui-sans-serif, system-ui, sans-serif;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@layer base {
html {
color-scheme: light;
background: var(--ground);
@apply font-sans;
}
body {
background: var(--ground);
color: var(--ink);
font-family: var(--body-font);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4 {
font-family: var(--heading-font);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.2;
}
a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
code {
background: var(--accent-soft);
padding: 0 0.3em;
font-size: 0.92em;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
* {
@apply border-border outline-ring/50;
}
}
/* Bausteine für alle Oberflächen. Neue Seiten setzen sich daraus zusammen. */
@layer components {
/* Skip-Link (erstes Element im Body) */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
background: var(--accent);
color: var(--on-accent);
padding: 0.75rem 1.25rem;
font-weight: 700;
}
.skip-link:focus {
left: 0;
}
/* Überschrift mit kurzem Zierstrich darunter */
.ep-ueberschrift {
color: var(--ink);
font-weight: 900;
}
.ep-ueberschrift::after {
content: "";
display: block;
width: 3rem;
height: 3px;
margin-top: 0.6rem;
background: var(--strich);
}
/* Weiße Fläche mit feiner Linie, für Formulare und Ergebnisse */
.ep-karte {
background: var(--surface);
border: 1px solid var(--hairline);
padding: 1.25rem;
}
.ep-label {
font-size: 0.875rem;
font-weight: 700;
color: var(--ink);
}
/* Eingabefelder, Auswahllisten und Textfelder */
.ep-feld {
width: 100%;
background: var(--surface);
border: 1px solid #b9c9d8;
color: var(--ink);
padding: 0.6rem 0.75rem;
}
.ep-feld:focus {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 0;
}
/* Knopf in Tiefblau. Für die eine Hauptaktion zusätzlich .ep-cta */
.ep-knopf {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: var(--accent);
color: var(--on-accent);
font-weight: 700;
padding: 0.7rem 1.4rem;
cursor: pointer;
}
.ep-knopf:hover {
background-color: var(--accent-press);
}
.ep-knopf:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Hauptaktion: Magenta-Verlauf wie „Alle Reisen anzeigen“ auf ep-reisen.de. Nur einmal je Seite. */
.ep-cta {
background-image: linear-gradient(120deg, #960167, #9e036a 40%, #fa1a8c);
color: #ffffff;
}
.ep-cta:hover {
background-image: linear-gradient(120deg, #7d0156, #8a035d 40%, #e3107c);
}
/* Hellblauer Kasten für Ergebnisse und Hinweise */
.ep-hinweis {
background: var(--accent-soft);
border-left: 4px solid var(--accent);
padding: 0.9rem 1.1rem;
color: var(--ink);
}
/* Fehlermeldung */
.ep-fehler {
background: #fdf1f0;
border-left: 4px solid var(--fehler);
padding: 0.9rem 1.1rem;
color: var(--fehler);
font-size: 0.925rem;
}
/* Tabelle für Ergebnisse */
.ep-tabelle {
width: 100%;
border-collapse: collapse;
background: var(--surface);
font-size: 0.95rem;
}
.ep-tabelle th {
background: var(--accent);
color: var(--on-accent);
text-align: left;
font-weight: 700;
padding: 0.55rem 0.75rem;
}
.ep-tabelle td {
border-bottom: 1px solid var(--hairline);
padding: 0.55rem 0.75rem;
vertical-align: top;
}
/* Pfeil-Aufzählung */
.arrow-list {
list-style: none;
padding: 0;
margin: 0;
}
.arrow-list > li {
position: relative;
padding-left: 1.4rem;
}
.arrow-list > li::before {
content: "→";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 700;
}
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { EpKopf } from "@/components/EpKopf";
import { EpFuss } from "@/components/EpFuss";
import { APP_BESCHREIBUNG, APP_TITEL } from "@/lib/app";
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
// Hausschrift von ep-reisen.de (Lato, freie Lizenz OFL). Die Dateien liegen im Projekt,
// damit der Build ohne Internet klappt. Lato hat keine Stärke 600, font-semibold erscheint fett.
const lato = localFont({
src: [
{ path: "./fonts/lato-400.woff2", weight: "400", style: "normal" },
{ path: "./fonts/lato-700.woff2", weight: "700", style: "normal" },
{ path: "./fonts/lato-900.woff2", weight: "900", style: "normal" },
],
variable: "--font-lato",
display: "swap",
});
export const metadata: Metadata = {
title: `${APP_TITEL} · E&P Reisen`,
description: APP_BESCHREIBUNG,
robots: { index: false, follow: false },
icons: { icon: "/favicon.svg" },
};
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
};
// E&P-Layout für jede Seite: Kopf mit Logo, Inhalt, Fuß. Seiten liefern nur ihren Inhalt, kein eigenes <main>.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" className={cn("font-sans", inter.variable)}>
<body className="flex min-h-screen flex-col bg-ground text-ink">
<a href="#inhalt" className="skip-link">
Zum Inhalt springen
</a>
<EpKopf />
<main id="inhalt" className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6 sm:py-12">
{children}
</main>
<EpFuss />
</body>
</html>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { postWebhook } from "@/lib/n8n";
type HelloResponse = { message?: string };
// Pfad des eigenen Webhooks mit Kürzel, zum Beispiel "anna-hello" (ohne /webhook/).
// In der E&P-n8n gehört jeder Pfad genau einem Workflow. Ohne Angabe ruft die Seite nichts auf.
const HELLO_PATH = (process.env.NEXT_PUBLIC_HELLO_PATH || "").trim();
// Beispielseite im E&P-Layout. Kopf und Fuß kommen aus app/layout.tsx,
// Farben und Bausteine (ep-karte, ep-feld, ep-knopf, ep-cta, ep-hinweis, ep-fehler) aus app/globals.css.
export default function Home() {
const [name, setName] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setAnswer(null);
if (!HELLO_PATH) {
setError(
"NEXT_PUBLIC_HELLO_PATH fehlt. Beim ersten Veröffentlichen den eigenen Pfad mitgeben, zum Beispiel anna-hello.",
);
return;
}
setLoading(true);
try {
const res = await postWebhook<HelloResponse>(HELLO_PATH, { name });
setAnswer(res.message ?? JSON.stringify(res));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="ep-ueberschrift text-3xl">Hallo aus n8n</h1>
<p className="mt-4 text-ink-muted">
Das Formular ruft einen n8n-Webhook deines eigenen Workflows auf (Vorlage <code>hello-webhook.json</code>,
Pfad <code>/webhook/{HELLO_PATH || "anna-hello"}</code>).
</p>
</div>
<form onSubmit={onSubmit} className="ep-karte flex flex-col gap-3">
<label htmlFor="name" className="ep-label">
Dein Name
</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max"
className="ep-feld"
required
/>
<button type="submit" disabled={loading} className="ep-knopf ep-cta self-start">
{loading ? "Sende …" : "An n8n senden"}
</button>
</form>
{answer && <div className="ep-hinweis">{answer}</div>}
{error && (
<div role="alert" className="ep-fehler">
{error}
</div>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-luma",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+10
View File
@@ -0,0 +1,10 @@
/** Fuß im E&P-Layout. Gehört zum festen Layout, bitte nicht umbauen. */
export function EpFuss() {
return (
<footer className="mt-12 border-t border-hairline bg-surface">
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-ink-muted sm:px-6">
E&P Reisen & Events · entstanden in der KI-Schulung
</div>
</footer>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { APP_TITEL } from "@/lib/app";
/**
* Kopf im E&P-Layout wie auf ep-reisen.de: weiße Leiste mit dem E&P-Logo (nur auf hellem Grund),
* daneben ein Strich und der Name der Oberfläche aus lib/app.ts. Darunter ein tiefblauer Balken.
* Gehört zum festen Layout, bitte nicht umbauen.
*/
export function EpKopf() {
return (
<header>
<div className="border-b border-hairline bg-surface">
<div className="mx-auto flex max-w-5xl items-center px-4 py-3 sm:px-6 sm:py-4">
<Link href="/" className="flex items-center gap-3 no-underline">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/ep-logo.svg"
alt="E&P Reisen & Events"
width={157}
height={48}
className="h-9 w-auto sm:h-12"
loading="eager"
/>
<span className="border-l border-hairline pl-3 text-sm font-black uppercase tracking-wide text-accent">
{APP_TITEL}
</span>
</Link>
</div>
</div>
<div className="h-2 bg-accent" aria-hidden="true" />
</header>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl 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/30 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 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
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-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+6
View File
@@ -0,0 +1,6 @@
// Muster C: Provider und Modell an einer Stelle.
// Der Standard-Provider liest ANTHROPIC_API_KEY aus der Umgebung.
import { anthropic } from "@ai-sdk/anthropic";
// Ein günstiges Modell, weil jeder Aufruf Geld kostet (Claude Sonnet 5: 2 $ Input / 10 $ Output pro 1 Mio. Tokens).
export const getModel = () => anthropic("claude-sonnet-5");
+6
View File
@@ -0,0 +1,6 @@
/**
* Name und Kurzbeschreibung der Oberfläche. Der Name steht im Kopf neben dem E&P-Logo
* und im Browser-Tab. Für jede neue Oberfläche passend setzen, zum Beispiel "Hotel-Steckbriefe".
*/
export const APP_TITEL = "Meine Oberfläche";
export const APP_BESCHREIBUNG = "Eine kleine Oberfläche aus der KI-Schulung bei E&P Reisen.";
+30
View File
@@ -0,0 +1,30 @@
// Muster A: n8n-Webhook aus dem Frontend aufrufen.
// Die Basis-Adresse wird beim Bauen eingesetzt (NEXT_PUBLIC_*). Ohne Angabe gilt die E&P-n8n.
const BASE = (process.env.NEXT_PUBLIC_N8N_BASE || "https://n8n.ep-reisen.app").replace(/\/+$/, "");
export function webhookUrl(path: string): string {
const clean = path.replace(/^\/+/, "");
return `${BASE}/webhook/${clean}`;
}
export async function postWebhook<T = unknown>(
path: string,
body: unknown,
): Promise<T> {
const res = await fetch(webhookUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Webhook-Fehler ${res.status}: ${await res.text()}`);
}
// n8n kann JSON oder Text zurückgeben:
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch {
return text as T;
}
}
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn"
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Muster A (n8n-Webhook) braucht keine Rewrites: Das Frontend ruft den Webhook direkt auf.
};
export default nextConfig;
+4508
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "frontend-starter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/react": "^3.0.0",
"@base-ui/react": "^1.9.0",
"ai": "^6.0.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.0",
"lucide-react": "^1.63.0",
"next": "^16.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.23.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+1
View File
@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 500 153" xmlns="http://www.w3.org/2000/svg"><g fill="#0084d4"><path d="m332.922 85.597c4.527 0 7.819 3.292 7.819 7.819 0 4.115-2.675 6.79-5.762 7.613l6.791 11.934h-3.704l-6.379-11.523h-7.613v11.523h-3.292v-27.366zm-8.642 12.963h8.642c2.469 0 4.527-2.058 4.527-5.144 0-3.293-2.264-5.144-5.35-5.144h-7.819z" fill-rule="nonzero"/><path d="m374.28 85.597v2.88h-15.432v9.877h13.374v2.881h-13.374v9.053h15.843v2.675h-19.135v-27.366z" fill-rule="nonzero"/><path d="m389.095 85.597h3.292v27.366h-3.292z"/><path d="m406.79 111.317.823-2.675c1.029.411 4.733 1.852 8.436 1.852 4.527 0 6.791-1.852 6.791-4.527 0-3.498-3.498-4.321-6.996-5.35-4.321-1.234-8.848-2.675-8.848-8.024 0-4.939 3.704-7.408 9.259-7.408 3.292 0 6.79 1.029 8.642 1.852l-.823 2.675s-4.115-1.646-7.613-1.646c-3.704 0-6.173 1.852-6.173 4.527 0 3.292 3.704 4.321 6.996 5.349 4.321 1.235 8.848 2.675 8.848 8.025 0 5.144-4.116 7.407-9.671 7.407-4.321 0-8.025-1.234-9.671-2.057z" fill-rule="nonzero"/><path d="m458.848 85.597v2.88h-15.432v9.877h13.374v2.881h-13.374v9.053h15.843v2.675h-19.136v-27.366z" fill-rule="nonzero"/><path d="m476.955 85.597 8.642 12.963 5.967 9.259-.206-9.259v-12.963h3.292v27.366h-3.498l-8.642-13.375-5.761-9.053.206 9.259v13.169h-3.292v-27.366z" fill-rule="nonzero"/><path d="m339.712 123.663v2.88h-15.432v9.877h13.374v2.88h-13.374v9.054h15.843v2.881h-19.135v-27.572z" fill-rule="nonzero"/><path d="m352.263 123.663 8.231 23.251 8.23-23.251h3.498l-9.876 27.366h-3.704l-9.877-27.366z" fill-rule="nonzero"/><path d="m401.44 123.663v2.88h-15.432v9.877h13.375v2.88h-13.375v9.054h15.844v2.881h-19.136v-27.572z" fill-rule="nonzero"/><path d="m416.872 123.663 8.642 12.963 5.967 9.259-.205-9.259v-12.963h3.292v27.366h-3.498l-8.642-13.375-5.761-9.053.205 9.259v13.169h-3.292v-27.366z" fill-rule="nonzero"/><path d="m466.872 123.457v3.086h-9.259v24.486h-3.292v-24.692h-9.259v-2.88z" fill-rule="nonzero"/><path d="m475.309 149.177.823-2.675c1.028.412 4.732 1.852 8.436 1.852 4.527 0 6.79-1.852 6.79-4.527 0-3.498-3.498-4.321-6.996-5.35-4.321-1.234-8.848-2.675-8.848-8.024 0-4.939 3.704-7.408 9.26-7.408 3.292 0 6.79 1.029 8.642 1.852l-.823 2.675s-4.116-1.646-7.614-1.646c-3.703 0-6.172 1.852-6.172 4.527 0 3.292 3.703 4.321 6.995 5.349 4.321 1.235 8.848 2.675 8.848 8.025 0 5.144-4.115 7.408-9.671 7.408-4.115 0-8.024-1.235-9.67-2.058z" fill-rule="nonzero"/><path d="m367.695 1.852v10.905h-33.333v17.078h28.395v11.112h-28.395v15.637h33.951v10.906h-47.12v-65.638z" fill-rule="nonzero"/><path d="m418.93 16.255c0 5.556-3.292 11.729-10.7 16.255l11.317 13.169s2.264-4.115 2.264-9.465h9.876c-.206 10.082-4.938 17.901-4.938 17.901l12.14 13.992h-14.609l-4.938-5.761c-4.527 3.909-10.494 6.173-17.284 6.173-12.346 0-23.457-7.202-23.457-20.782 0-8.848 5.761-14.404 11.728-18.519-3.292-4.321-5.761-7.202-5.761-12.963 0-11.317 9.465-15.638 16.872-15.638 7.408 0 17.49 4.321 17.49 15.638zm-28.189 31.276c0 6.173 4.526 10.082 11.728 10.082 3.704 0 6.996-1.029 9.671-3.292l-14.403-16.667c-4.116 2.264-6.996 5.35-6.996 9.877zm5.967-30.659c0 3.293 2.057 5.144 4.321 7.614 3.704-1.646 6.378-4.527 6.378-7.819 0-3.91-2.674-5.35-5.349-5.35-3.498 0-5.35 2.263-5.35 5.555z" fill-rule="nonzero"/><path d="m479.424 1.852c11.111 0 20.164 9.259 20.164 20.988 0 12.551-9.053 21.399-19.958 21.399h-15.844v23.456h-13.169v-65.843zm-15.638 31.276h13.169c6.173 0 9.671-4.527 9.671-10.288 0-7.202-5.556-9.877-10.7-9.877h-12.14z" fill-rule="nonzero"/><path d="m220.165.617c-74.898 11.317-167.902 39.301-167.902 78.601 0 36.42 86.009 27.161 86.009 27.161s-137.86 45.884-137.86-14.404c0-68.312 173.868-91.358 219.753-91.358z" fill-rule="nonzero"/></g><path d="m34.979 151.235c74.898-11.112 167.902-39.301 167.902-78.601 0-36.42-86.009-27.161-86.009-27.161s137.861-45.885 137.861 14.404c0 68.518-173.869 91.358-219.754 91.358z" fill="#f8c62f" fill-rule="nonzero"/><path d="m124.897 70.576-26.337 8.231 25.103 1.646-.823-4.939z" fill="#0084d4" fill-rule="nonzero"/><path d="m156.173 72.428-18.93 5.967-7.202 19.959-3.498-23.251 8.231-22.017 3.498 16.256z" fill="#f8c62f" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#18527b"/></svg>

After

Width:  |  Height:  |  Size: 112 B

+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}