4f5089ce38
- 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>
155 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|