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>
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
|
||||
import ChatBody from "./ChatBody";
|
||||
import type { WindowLabels, WindowRequest } from "./types";
|
||||
|
||||
const EMAIL = "contact@dk0.dev";
|
||||
@@ -28,13 +29,15 @@ export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
|
||||
return labels.about.title;
|
||||
case "contact":
|
||||
return labels.contact.title;
|
||||
case "chat":
|
||||
return labels.chat.title;
|
||||
}
|
||||
}
|
||||
|
||||
/** Farbe des Punkts in der Ablage-Leiste */
|
||||
export function windowAccent(req: WindowRequest): string {
|
||||
if (req.kind === "project") return req.project.color;
|
||||
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))" }[req.kind];
|
||||
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))", chat: "#1d1b3d" }[req.kind];
|
||||
}
|
||||
|
||||
interface WindowContentProps {
|
||||
@@ -128,6 +131,9 @@ export default function WindowContent({ req, labels, onClose }: WindowContentPro
|
||||
|
||||
case "contact":
|
||||
return <ContactBody labels={labels} onClose={onClose} />;
|
||||
|
||||
case "chat":
|
||||
return <ChatBody labels={labels} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@ export async function getWindowLabels(locale: string): Promise<WindowLabels> {
|
||||
value: t(`about.facts.${k}.value`),
|
||||
})),
|
||||
},
|
||||
chat: {
|
||||
title: t("chat.title"),
|
||||
welcome: t("chat.welcome"),
|
||||
consent: t("chat.consent"),
|
||||
allow: t("chat.allow"),
|
||||
placeholder: t("chat.placeholder"),
|
||||
send: t("chat.send"),
|
||||
error: t("chat.error"),
|
||||
thinking: t("chat.thinking"),
|
||||
note: t("chat.note"),
|
||||
},
|
||||
contact: {
|
||||
title: t("contact.title"),
|
||||
body: t("contact.body"),
|
||||
|
||||
@@ -7,7 +7,8 @@ export type WindowRequest =
|
||||
| { kind: "server" }
|
||||
| { kind: "read" }
|
||||
| { kind: "about" }
|
||||
| { kind: "contact" };
|
||||
| { kind: "contact" }
|
||||
| { kind: "chat" };
|
||||
|
||||
export function windowId(req: WindowRequest): string {
|
||||
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
|
||||
@@ -45,6 +46,17 @@ export interface WindowLabels {
|
||||
paragraphs: string[];
|
||||
facts: { label: string; value: string }[];
|
||||
};
|
||||
chat: {
|
||||
title: string;
|
||||
welcome: string;
|
||||
consent: string;
|
||||
allow: string;
|
||||
placeholder: string;
|
||||
send: string;
|
||||
error: string;
|
||||
thinking: string;
|
||||
note: string;
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
body: string;
|
||||
|
||||
Reference in New Issue
Block a user