feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) (#77)
- 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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user