Files
portfolio/app/components/fenster/ChatBody.tsx
denshooter 6c2da49bfa feat: plakat redesign phase 4 (chat window, contact, footer, cleanup)
- AI chat moves into a window/sheet, opened from a new speech-bubble card on
  the desk; it now respects the chatbot consent and asks before sending
  anything to n8n (previously it ignored the consent)
- Contact as red poster section with big mail address and the form on a paper
  card; validation errors are finally shown inline (aria-invalid, role=alert)
- Footer as ink strip with dk0 wordmark, legal links and a button to reopen
  the privacy settings
- "About me" in the header opens the about window on the home page
- Remove the old About section and its parts (BentoChat, ActivityFeed,
  CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid,
  their tests, wrappers, loaders, types and texts
- scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives
  hobbies, tech stack categories and old content pages and deletes four
  unused message overrides after a full JSON backup; run only after the
  redesign is live on production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:43 +02:00

155 lines
5.6 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useConsent } from "../ConsentProvider";
import type { WindowLabels } from "./types";
interface Message {
id: string;
text: string;
from: "user" | "bot";
}
const STORE_ID = "chatSessionId";
const STORE_MESSAGES = "chatMessages";
/**
* KI-Chat im Fenster. Schickt erst etwas an n8n, wenn der Chatbot im
* Datenschutz-Banner (oder hier) erlaubt wurde.
*/
export default function ChatBody({ labels }: { labels: WindowLabels }) {
const { consent, ready, setConsent } = useConsent();
const allowed = !!consent?.chat;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [conversationId, setConversationId] = useState("default");
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!allowed) return;
try {
let id = localStorage.getItem(STORE_ID);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORE_ID, id);
}
setConversationId(id);
const stored = localStorage.getItem(STORE_MESSAGES);
const parsed = stored ? (JSON.parse(stored) as { id: string; text: string; sender?: string; from?: string }[]) : [];
setMessages(
parsed.length
? parsed.map((m) => ({
id: m.id,
// Begrüßung immer in der aktuellen Sprache, auch wenn noch eine alte gespeichert ist
text: m.id === "welcome" ? labels.chat.welcome : m.text,
from: (m.from ?? m.sender) === "user" ? "user" : "bot",
}))
: [{ id: "welcome", text: labels.chat.welcome, from: "bot" }],
);
} catch {
setMessages([{ id: "welcome", text: labels.chat.welcome, from: "bot" }]);
}
inputRef.current?.focus({ preventScroll: true });
}, [allowed, labels.chat.welcome]);
useEffect(() => {
if (!allowed || messages.length === 0) return;
try {
localStorage.setItem(STORE_MESSAGES, JSON.stringify(messages));
} catch {}
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages, allowed]);
const send = async () => {
const text = input.trim();
if (!text || busy) return;
const mine: Message = { id: String(Date.now()), text, from: "user" };
const history = messages.slice(-5).map((m) => ({ role: m.from === "user" ? "user" : "assistant", content: m.text }));
setMessages((prev) => [...prev, mine]);
setInput("");
setBusy(true);
try {
const res = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, conversationId, history }),
});
const data = await res.json();
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: data.reply || labels.chat.error, from: "bot" }]);
} catch {
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: labels.chat.error, from: "bot" }]);
} finally {
setBusy(false);
}
};
if (!ready) return <div className="h-24 animate-pulse rounded-xl bg-paper-2" aria-hidden="true" />;
if (!allowed) {
return (
<>
<p className="text-[15px] leading-relaxed">{labels.chat.consent}</p>
<button
type="button"
onClick={() => setConsent({ ...(consent ?? { chat: false }), chat: true })}
className="w-fit rounded-full bg-ink px-4 py-2.5 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
>
{labels.chat.allow}
</button>
</>
);
}
return (
<div className="flex min-h-[320px] flex-col gap-3">
<div ref={listRef} className="flex max-h-[46vh] flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pr-1" aria-live="polite">
{messages.map((m) => (
<div
key={m.id}
className={`max-w-[88%] rounded-2xl border-2 border-ink px-3.5 py-2 text-[14.5px] leading-snug ${
m.from === "user" ? "self-end rounded-br-md bg-ink text-paper" : "self-start rounded-bl-md bg-paper"
}`}
>
{m.text}
</div>
))}
{busy && (
<div className="self-start rounded-2xl rounded-bl-md border-2 border-ink px-3.5 py-2 font-mono text-xs" aria-label={labels.chat.thinking}>
<span className="inline-block animate-pulse">…</span>
</div>
)}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<label htmlFor="chat-input" className="sr-only">
{labels.chat.placeholder}
</label>
<input
ref={inputRef}
id="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={labels.chat.placeholder}
autoComplete="off"
className="min-w-0 flex-1 rounded-full border-2 border-ink bg-paper px-4 py-2.5 text-base text-ink placeholder:text-ink/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
/>
<button
type="submit"
disabled={!input.trim() || busy}
className="rounded-full bg-ink px-4 text-sm font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-40"
>
{labels.chat.send}
</button>
</form>
<p className="font-mono text-[11.5px] leading-snug opacity-70">{labels.chat.note}</p>
</div>
);
}