diff --git a/app/admin/dashboard.tsx b/app/admin/dashboard.tsx index 348a09e..de900c8 100644 --- a/app/admin/dashboard.tsx +++ b/app/admin/dashboard.tsx @@ -5,8 +5,10 @@ import { useRouter } from "next/navigation" import { EventType } from "@/lib/generated/prisma" import { MetricsTab } from "./metrics-tab" import { ChatTab } from "./chat-tab" +import { SessionsTab } from "./sessions-tab" +import type { SessionData } from "./sessions-tab" import { cn } from "@/lib/utils" -import { BarChart2Icon, MessageSquareIcon } from "lucide-react" +import { BarChart2Icon, MessageSquareIcon, UsersIcon } from "lucide-react" export type SeriesMeta = { name: string; total: number; type: EventType } @@ -24,21 +26,22 @@ export type ChatSession = { messages: ChatMessage[] } -type Tab = "metrics" | "chat" +type Tab = "metrics" | "chat" | "sessions" interface Props { days: number chartData: Record[] series: SeriesMeta[] chatSessions: ChatSession[] + sessionData: SessionData[] } -export function AdminDashboard({ days, chartData, series, chatSessions }: Props) { +export function AdminDashboard({ days, chartData, series, chatSessions, sessionData }: Props) { const [tab, setTab] = useState("metrics") const router = useRouter() function setDays(d: number) { - router.push(`/admin?key=${new URLSearchParams(window.location.search).get("key") ?? ""}&days=${d}`) + router.push(`/admin?days=${d}`) } return ( @@ -81,12 +84,21 @@ export function AdminDashboard({ days, chartData, series, chatSessions }: Props) )} + setTab("sessions")} icon={}> + Sessions + {sessionData.length > 0 && ( + + {sessionData.length} + + )} + {/* Content */}
{tab === "metrics" && } {tab === "chat" && } + {tab === "sessions" && }
) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index fb3092a..50b3002 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -17,7 +17,7 @@ export default async function AdminPage({ prisma.trackingEvent.findMany({ where: { createdAt: { gte: since } }, orderBy: { createdAt: "asc" }, - select: { createdAt: true, type: true, name: true }, + select: { id: true, createdAt: true, type: true, name: true, sessionId: true, url: true, fileId: true }, }), prisma.chatSession.findMany({ where: { createdAt: { gte: since } }, @@ -71,6 +71,66 @@ export default async function AdminPage({ .map(([name, { total, type }]) => ({ name, total, type })) .sort((a, b) => b.total - a.total) + // Build per-session data (merge tracking events + chat messages by sessionId) + type SessionEntry = { + sessionId: string + events: typeof rawEvents + chatMessages: { id: string; role: string; content: string; createdAt: Date }[] + } + const sessionMap = new Map() + + const getOrCreate = (sid: string) => { + if (!sessionMap.has(sid)) sessionMap.set(sid, { sessionId: sid, events: [], chatMessages: [] }) + return sessionMap.get(sid)! + } + + for (const e of rawEvents) getOrCreate(e.sessionId).events.push(e) + for (const cs of chatSessions) { + const entry = getOrCreate(cs.sessionId) + for (const m of cs.messages) { + entry.chatMessages.push({ id: m.id, role: m.role, content: m.content, createdAt: m.createdAt }) + } + } + + const sessionData = [...sessionMap.values()].map((s) => { + const allTimes = [ + ...s.events.map((e) => e.createdAt.getTime()), + ...s.chatMessages.map((m) => m.createdAt.getTime()), + ] + const firstSeen = new Date(Math.min(...allTimes)).toISOString() + const lastSeen = new Date(Math.max(...allTimes)).toISOString() + + const timeline = [ + ...s.events.map((e) => ({ + kind: "event" as const, + id: e.id, + type: e.type as string, + name: e.name, + ...(e.url ? { url: e.url } : {}), + ...(e.fileId ? { fileId: e.fileId } : {}), + createdAt: e.createdAt.toISOString(), + })), + ...s.chatMessages.map((m) => ({ + kind: "chat" as const, + id: m.id, + role: m.role as string, + content: m.content, + createdAt: m.createdAt.toISOString(), + })), + ].sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + + return { + summary: { + sessionId: s.sessionId, + firstSeen, + lastSeen, + eventCount: s.events.length, + chatMessageCount: s.chatMessages.length, + }, + timeline, + } + }).sort((a, b) => b.summary.lastSeen.localeCompare(a.summary.lastSeen)) + // Serialize dates for client const serializedSessions = chatSessions.map((s) => ({ id: s.id, @@ -90,6 +150,7 @@ export default async function AdminPage({ chartData={chartData} series={series} chatSessions={serializedSessions} + sessionData={sessionData} /> ) } diff --git a/app/admin/sessions-tab.tsx b/app/admin/sessions-tab.tsx new file mode 100644 index 0000000..2624b8b --- /dev/null +++ b/app/admin/sessions-tab.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useState } from "react" +import { MousePointerClickIcon, ExternalLinkIcon, LayoutIcon, FileIcon, BotIcon, UserIcon, UsersIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +export type SessionSummary = { + sessionId: string + firstSeen: string + lastSeen: string + eventCount: number + chatMessageCount: number +} + +export type TimelineItem = + | { kind: "event"; id: string; type: string; name: string; url?: string; fileId?: string; createdAt: string } + | { kind: "chat"; id: string; role: string; content: string; createdAt: string } + +export type SessionData = { + summary: SessionSummary + timeline: TimelineItem[] +} + +interface Props { + sessions: SessionData[] +} + +export function SessionsTab({ sessions }: Props) { + const [selected, setSelected] = useState(sessions[0] ?? null) + + if (sessions.length === 0) { + return ( +
+ +

No sessions yet.

+
+ ) + } + + return ( +
+ + +
+ {selected ? ( + <> +
+
+

+ {formatDateTimeLong(selected.summary.firstSeen)} + {selected.summary.firstSeen !== selected.summary.lastSeen && ( + <> — {formatDateTime(selected.summary.lastSeen)} + )} +

+

+ {selected.summary.sessionId} +

+
+ + {selected.summary.eventCount + selected.summary.chatMessageCount} items + +
+
+ {selected.timeline.map((item) => + item.kind === "event" ? ( + + ) : ( + + ) + )} +
+ + ) : ( +
+ Select a session +
+ )} +
+
+ ) +} + +function EventItem({ item }: { item: Extract }) { + const { icon, color } = eventMeta(item.type) + return ( +
+
+ {icon} +
+
+

{item.name}

+ {item.url && ( +

{item.url}

+ )} + {item.fileId && ( +

{item.fileId}

+ )} +
+ + {formatTime(item.createdAt)} + +
+ ) +} + +function ChatItem({ item }: { item: Extract }) { + const isUser = item.role === "USER" + return ( +
+
+ {isUser + ? + : + } +
+
+
+

{item.content}

+
+ {formatTime(item.createdAt)} +
+
+ ) +} + +function eventMeta(type: string) { + switch (type) { + case "FILE_OPEN": + return { icon: , color: "bg-blue-500/10" } + case "EXTERNAL_LINK": + return { icon: , color: "bg-green-500/10" } + case "SIDEBAR_TAB": + return { icon: , color: "bg-orange-500/10" } + case "BUTTON_CLICK": + return { icon: , color: "bg-purple-500/10" } + default: + return { icon: , color: "bg-muted" } + } +} + +function formatDateTime(iso: string) { + const d = new Date(iso) + return d.toLocaleDateString("de", { day: "2-digit", month: "2-digit" }) + + " · " + d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }) +} + +function formatDateTimeLong(iso: string) { + return new Date(iso).toLocaleString("de", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + }) +} + +function formatTime(iso: string) { + return new Date(iso).toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) +} diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts index 8ef0492..c72cd34 100644 --- a/app/api/analytics/track/route.ts +++ b/app/api/analytics/track/route.ts @@ -4,9 +4,9 @@ import { EventType } from "@/lib/generated/prisma" export async function POST(req: Request) { try { - const { type, name, url, fileId, sessionId } = await req.json() + const { type, name, url, fileId } = await req.json() - if (!type || !name || !sessionId) { + if (!type || !name) { return NextResponse.json({ error: "Missing fields" }, { status: 400 }) } @@ -14,6 +14,8 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid event type" }, { status: 400 }) } + const sessionId = await computeSessionId(req) + await prisma.trackingEvent.create({ data: { type, name, url: url ?? null, fileId: fileId ?? null, sessionId }, }) @@ -23,3 +25,29 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }) } + +async function computeSessionId(req: Request): Promise { + const forwarded = req.headers.get("x-forwarded-for") ?? "" + const rawIp = forwarded.split(",")[0].trim() || "unknown" + const anonymizedIp = anonymizeIp(rawIp) + const userAgent = req.headers.get("user-agent") ?? "unknown" + const dailySalt = new Date().toISOString().slice(0, 10) + + const input = `${anonymizedIp}-${userAgent}-${dailySalt}` + const encoded = new TextEncoder().encode(input) + const hashBuffer = await crypto.subtle.digest("SHA-256", encoded) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") +} + +function anonymizeIp(ip: string): string { + if (ip.includes(":")) { + const parts = ip.split(":") + return [...parts.slice(0, 6), "0", "0"].join(":") + } + const parts = ip.split(".") + if (parts.length === 4) { + return `${parts[0]}.${parts[1]}.${parts[2]}.0` + } + return "unknown" +} diff --git a/app/api/consent/route.ts b/app/api/consent/route.ts new file mode 100644 index 0000000..df7ee4b --- /dev/null +++ b/app/api/consent/route.ts @@ -0,0 +1,19 @@ +import { prisma } from "@/lib/prisma" +import { NextResponse } from "next/server" + +export async function POST(req: Request) { + try { + const { sessionId, policyVersion } = await req.json() + if (!sessionId || typeof sessionId !== "string") { + return NextResponse.json({ error: "Missing sessionId" }, { status: 400 }) + } + await prisma.chatSession.upsert({ + where: { sessionId }, + create: { sessionId, policyVersion: policyVersion ?? "unknown", consentGivenAt: new Date() }, + update: { policyVersion: policyVersion ?? "unknown", consentGivenAt: new Date() }, + }) + return NextResponse.json({ ok: true }) + } catch { + return NextResponse.json({ ok: true }) + } +} diff --git a/components/pages/impressum-page.tsx b/components/pages/impressum-page.tsx deleted file mode 100644 index 07aa3ac..0000000 --- a/components/pages/impressum-page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { FileContentContainer } from "@/components/portfolio/file-content-container"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -export default function ImpressumPage() { - return ( - - Imprint - - } - subtitle="Legal Information" - > -
- - - Imprint - - -

- Merten Dieckmann
- Memmingerstrasse 35
- 89231 Neu-Ulm -

-
-
- - - - Contact - - -

- Email: merten.dieckmann@web.de -

-
-
- - - - Responsible for content - - -

- Merten Dieckmann
- Memmingerstrasse 35
- 89231 Neu-Ulm -

-
-
- - - - EU Dispute Resolution - - -

- The European Commission provides a platform for online dispute resolution (OS):{" "} - - https://ec.europa.eu/consumers/odr/ - - .
- You can find our email address in the imprint above. -

-
-
- - - - Consumer dispute resolution / Universal arbitration board - - -

- We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board. -

-
-
- -

- Source: https://www.e-recht24.de -

-
-
- ); -} diff --git a/components/pages/imprint-page.tsx b/components/pages/imprint-page.tsx new file mode 100644 index 0000000..4fac29c --- /dev/null +++ b/components/pages/imprint-page.tsx @@ -0,0 +1,183 @@ +"use client" + +import { useState } from "react"; +import { FileContentContainer } from "@/components/portfolio/file-content-container"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LangToggle, type Lang } from "@/components/portfolio/lang-toggle"; + +export default function ImprintPage() { + const [lang, setLang] = useState("en") + + return ( + Imprint} + subtitle={lang === "en" ? "Legal Information" : "Rechtliche Angaben"} + headerRight={} + > +
+ {lang === "en" ? : } +
+
+ ) +} + +function EnglishContent() { + return ( + <> + + + Imprint + + +

+ Merten Dieckmann
+ Memmingerstrasse 35
+ 89231 Neu-Ulm, Germany +

+
+
+ + + + Contact + + +

+ Email:{" "} + + merten.dieckmann@web.de + +

+
+
+ + + + Responsible for content + + +

+ Merten Dieckmann
+ Memmingerstrasse 35
+ 89231 Neu-Ulm, Germany +

+
+
+ + + + EU Dispute Resolution + + +

+ The European Commission provides a platform for online dispute resolution (OS):{" "} + + https://ec.europa.eu/consumers/odr/ + + .
+ You can find our email address in the imprint above. +

+
+
+ + + + Consumer dispute resolution / Universal arbitration board + + +

+ We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board. +

+
+
+ +

+ Source:{" "} + + e-recht24.de + +

+ + ) +} + +function GermanContent() { + return ( + <> + + + Impressum + + +

+ Merten Dieckmann
+ Memmingerstraße 35
+ 89231 Neu-Ulm +

+
+
+ + + + Kontakt + + +

+ E-Mail:{" "} + + merten.dieckmann@web.de + +

+
+
+ + + + Verantwortlich für den Inhalt + + +

+ Merten Dieckmann
+ Memmingerstraße 35
+ 89231 Neu-Ulm +

+
+
+ + + + EU-Streitschlichtung + + +

+ Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:{" "} + + https://ec.europa.eu/consumers/odr/ + + .
+ Unsere E-Mail-Adresse finden Sie oben im Impressum. +

+
+
+ + + + Verbraucherstreitbeilegung / Universalschlichtungsstelle + + +

+ Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. +

+
+
+ +

+ Quelle:{" "} + + e-recht24.de + +

+ + ) +} diff --git a/components/pages/privacy-policy-page.tsx b/components/pages/privacy-policy-page.tsx new file mode 100644 index 0000000..dbadbfa --- /dev/null +++ b/components/pages/privacy-policy-page.tsx @@ -0,0 +1,549 @@ +"use client" + +import { useState } from "react"; +import { FileContentContainer } from "@/components/portfolio/file-content-container"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LangToggle, type Lang } from "@/components/portfolio/lang-toggle"; + +export default function PrivacyPolicyPage() { + const [lang, setLang] = useState("en") + + return ( + Privacy} + subtitle={lang === "en" ? "Privacy Policy" : "Datenschutzerklärung"} + headerRight={} + > +
+ {lang === "en" ? : } +
+
+ ) +} + +function EnglishContent() { + return ( + <> + + + 1. Controller + + +

+ Merten Dieckmann
+ Memmingerstrasse 35
+ 89231 Neu-Ulm, Germany
+ Email:{" "} + + merten.dieckmann@web.de + +

+
+
+ + + + 2. Hosting + + +

+ This website is self-hosted on a dedicated server at Hetzner Online GmbH + (data center Helsinki, Finland, EU). The reverse proxy (Traefik) is configured + without access logging — no visitor IP addresses, User-Agents, or request + details are written to log files by this website's application layer. +

+

+ Hetzner processes connection metadata at the infrastructure level (e.g. for + DDoS protection). This includes the visitor's IP address, which is + transmitted to Hetzner's infrastructure upon every connection by technical + necessity, independently of application-level logging. Hetzner acts as a data + processor under Art. 28 GDPR; a Data Processing Agreement (DPA) is in place. + All data processing takes place exclusively within the European Union. See + Hetzner's{" "} + + privacy policy + + {" "}for details. +

+
+
+ + + + 3. Cookieless Analytics (Privacy by Design) + + +

+ This website uses a custom privacy-friendly analytics system.{" "} + No cookies and no client-side storage{" "} + (localStorage, sessionStorage — except as noted in Section 5) are used for + analytics purposes. +

+

+ When you interact with certain elements, your browser sends a pseudonymous + request to our server. The server computes a pseudonymous analytics + session ID (distinct from the chat session identifier described in + Section 4) using the following method: +

+
    +
  • Your IP address is anonymized: for IPv4 the last octet is zeroed (e.g. 192.168.1.0/24); for IPv6 the last two groups are removed.
  • +
  • The anonymized IP is combined with your browser's User-Agent string and a daily salt that rotates at midnight UTC.
  • +
  • A SHA-256 hash is computed from this combination and used as the analytics session ID.
  • +
  • The hash changes every day, making cross-day correlation technically difficult — but not provably impossible, in particular for stable IP subnets and User-Agents.
  • +
+

+ The User-Agent string is read exclusively from the HTTP request header + transmitted by your browser. No JavaScript-based device fingerprinting or + active reading of end-device data is performed. This passive processing does + not constitute access to information stored on your end device within the + meaning of § 25 TDDDG. +

+

+ The resulting session ID is pseudonymous personal data within the meaning of + Art. 4(5) GDPR and Recital 26 GDPR. The full GDPR applies. +

+

+ Legal basis: Art. 6(1)(f) GDPR (legitimate interest in understanding how this + website is used in order to improve it). You have the right to object to this + processing at any time on grounds relating to your particular situation + (Art. 21 GDPR). To exercise this right, contact{" "} + + merten.dieckmann@web.de + + . +

+
+
+ + + + 4. AI Chat + + +

+ This website offers an optional AI chat feature. It is only activated after + you give explicit consent (see the consent notice displayed in the chat + interface). When you use it, your messages are forwarded to external service + providers for processing: +

+
    +
  • + OpenRouter Inc. (USA) — API gateway that routes requests to + the AI model. Zero Data Retention is enabled for our account: OpenRouter + does not store requests persistently. Privacy policy:{" "} + + openrouter.ai/privacy + +
  • +
  • + Google LLC (USA) — AI model: google/gemini-3.1-flash-lite, + operated via OpenRouter. Google's data processing terms for API usage + govern retention at this layer. Privacy policy:{" "} + + policies.google.com/privacy + +
  • +
+

+ Chat histories are stored in pseudonymized form in a local SQLite database on + our server, linked to a chat session ID (a random identifier + held in your browser's memory for the duration of the session, distinct + from the analytics session ID described in Section 3). +

+

+ Since OpenRouter and Google are based in the USA, this constitutes a transfer + to a third country (Art. 44 ff. GDPR): +

+
    +
  • + Google LLC is certified under the EU–US Data Privacy + Framework (DPF). The transfer is based on the European Commission's + adequacy decision of 10 July 2023 (Art. 45 GDPR, Implementing Decision + (EU) 2023/1795). +
  • +
  • + OpenRouter Inc. is not certified under the DPF. The + transfer is based on Standard Contractual Clauses adopted by the European + Commission (Art. 46(2)(c) GDPR), as provided in OpenRouter's published + Data Processing Terms. +
  • +
+

+ Please do not enter any personal data (name, email address, etc.) in the chat. +

+

+ In compliance with Art. 50(1) of the EU AI Act (Regulation (EU) 2024/1689), + you are informed before each chat session that you are interacting with an AI + system via the consent notice and the introductory message in the chat + interface. +

+

+ Legal basis: Art. 6(1)(a) GDPR — explicit consent given before first use of + the chat feature. To comply with Art. 7(1) GDPR (burden of proof), a + pseudonymous consent record (timestamp, policy version, and session ID — no IP + address) is stored server-side when you click the consent button. You may + withdraw your consent at any time by clicking "Withdraw consent" in + the chat interface; this does not affect the lawfulness of processing carried + out before withdrawal (Art. 7(3) GDPR). +

+
+
+ + + + 5. Cookies and Local Storage + + +

+ This website sets no cookies. Neither first-party nor + third-party cookies are used. No advertising technologies, social media pixels, + or external tracking services are deployed. +

+

The following data is stored in your browser's local storage:

+
    +
  • + Theme preference (portfolio-theme, localStorage): + stores your selected colour scheme (dark/light/system). This value never + leaves your browser and is solely used to restore your display preference on + subsequent visits. Legal basis: § 25(2)(2) TDDDG (strictly necessary for + the service you requested). +
  • +
  • + Chat consent (portfolio-chat-consent, + sessionStorage): remembers whether you consented to use the chat during + the current browser session. This value is tab-scoped, never transmitted to + any server, and deleted automatically when you close the tab. Legal basis: + § 25(2)(2) TDDDG (strictly necessary to operate the consent mechanism for + the chat service you explicitly requested). +
  • +
+
+
+ + + + 6. Data Retention + + +
    +
  • Analytics events: stored for 12 months, then deleted by an automated cleanup process.
  • +
  • Chat messages: deleted after 90 days by an automated cleanup process, or earlier upon request.
  • +
  • Chat session records (which contain the consent evidence — policy version and consent timestamp): retained for 3 years, then deleted by an automated cleanup process. This ensures the consent can be demonstrated for the standard civil-law limitation period even after the messages themselves are gone.
  • +
  • Theme preference / chat consent state: browser-local only — never stored server-side.
  • +
+
+
+ + + + 7. Your Rights + + +

Under the GDPR you have the following rights:

+
    +
  • Access (Art. 15 GDPR)
  • +
  • Rectification (Art. 16 GDPR)
  • +
  • Erasure (Art. 17 GDPR)
  • +
  • Restriction of processing (Art. 18 GDPR)
  • +
  • Data portability (Art. 20 GDPR) — applies to chat data (consent basis); not applicable to analytics data (legitimate interest basis)
  • +
  • Objection (Art. 21 GDPR) — in particular for processing based on Art. 6(1)(f) (analytics)
  • +
  • Withdrawal of consent (Art. 7(3) GDPR) — for processing based on consent (chat)
  • +
+

+ Analytics data is stored using a pseudonymous session ID. To exercise your + rights regarding analytics data, please state the approximate date(s) of your + visit and your IP address range in your request — this allows us to reconstruct + the session ID as it would have been computed on that day. Note that + identification across multiple days is not technically feasible due to the + daily-rotating salt. +

+

+ For all other requests, please contact:{" "} + + merten.dieckmann@web.de + +

+

+ You also have the right to lodge a complaint with the competent supervisory + authority: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA),{" "} + + www.lda.bayern.de + +

+
+
+ +

Last updated: May 2026

+ + ) +} + +function GermanContent() { + return ( + <> + + + 1. Verantwortlicher + + +

+ Merten Dieckmann
+ Memmingerstraße 35
+ 89231 Neu-Ulm, Deutschland
+ E-Mail:{" "} + + merten.dieckmann@web.de + +

+
+
+ + + + 2. Hosting + + +

+ Diese Website wird auf einem dedizierten Server bei der Hetzner Online GmbH + (Rechenzentrum Helsinki, Finnland, EU) selbst gehostet. Der Reverse-Proxy + (Traefik) ist ohne Zugriffs-Logging konfiguriert — es werden keine + IP-Adressen, User-Agents oder sonstige Anfragedaten auf Anwendungsebene in + Logdateien geschrieben. +

+

+ Hetzner verarbeitet Verbindungsmetadaten auf Infrastrukturebene (z. B. zum + DDoS-Schutz). Dazu gehört die IP-Adresse der Besucher, die bei jeder + Verbindung technisch bedingt an die Hetzner-Infrastruktur übermittelt wird, + unabhängig vom anwendungsseitigen Logging. Hetzner handelt als + Auftragsverarbeiter gemäß Art. 28 DSGVO; ein Auftragsverarbeitungsvertrag + (AVV) ist abgeschlossen. Alle Datenverarbeitungen finden ausschließlich + innerhalb der Europäischen Union statt. Weitere Informationen finden sich in + der{" "} + + Datenschutzerklärung von Hetzner + + . +

+
+
+ + + + 3. Cookiefreie Analyse (Datenschutz durch Technikgestaltung) + + +

+ Diese Website verwendet ein eigenes datenschutzfreundliches Analysesystem. + Für Analysezwecke werden keine Cookies und{" "} + kein clientseitiger Speicher (localStorage, sessionStorage — + mit Ausnahme der in Abschnitt 5 beschriebenen Zwecke) eingesetzt. +

+

+ Beim Interagieren mit bestimmten Elementen sendet Ihr Browser eine + pseudonyme Anfrage an unseren Server. Der Server berechnet eine pseudonyme + Analyse-Session-ID (verschieden von der in Abschnitt 4 + beschriebenen Chat-Session-ID) nach folgendem Verfahren: +

+
    +
  • Ihre IP-Adresse wird anonymisiert: Bei IPv4 wird das letzte Oktett auf 0 gesetzt (z. B. 192.168.1.0/24); bei IPv6 werden die letzten beiden Gruppen entfernt.
  • +
  • Die anonymisierte IP wird mit dem User-Agent Ihres Browsers und einem täglich rotierenden Salt kombiniert, der um Mitternacht UTC wechselt.
  • +
  • Aus dieser Kombination wird ein SHA-256-Hash berechnet, der als Analyse-Session-ID dient.
  • +
  • Der Hash ändert sich täglich, was eine tagesübergreifende Korrelation technisch erschwert — aber nicht mit mathematischer Sicherheit ausschließt, insbesondere bei stabilen IP-Subnetzen und User-Agents.
  • +
+

+ Der User-Agent wird ausschließlich passiv aus dem HTTP-Anfrage-Header + ausgelesen, der vom Browser übermittelt wird. Es findet kein JavaScript-basiertes + Geräte-Fingerprinting oder aktives Auslesen von Endgerätedaten statt. Dieses + passive Auslesen stellt keinen Zugriff auf Informationen, die im Endgerät + gespeichert sind, im Sinne von § 25 TDDDG dar. +

+

+ Die entstehende Session-ID ist pseudonymes personenbezogenes Datum im Sinne + von Art. 4 Nr. 5 DSGVO und Erwägungsgrund 26 DSGVO. Die DSGVO findet in + vollem Umfang Anwendung. +

+

+ Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der + statistischen Auswertung zur Verbesserung dieser Website). Sie haben das + Recht, dieser Verarbeitung jederzeit aus Gründen, die sich aus Ihrer + besonderen Situation ergeben, zu widersprechen (Art. 21 DSGVO). Zur Ausübung + dieses Rechts wenden Sie sich an{" "} + + merten.dieckmann@web.de + + . +

+
+
+ + + + 4. KI-Chat + + +

+ Diese Website bietet eine optionale KI-Chat-Funktion an. Diese wird erst + aktiviert, nachdem Sie ausdrücklich eingewilligt haben (siehe den + Einwilligungshinweis in der Chat-Oberfläche). Bei der Nutzung werden Ihre + Nachrichten zur Verarbeitung an externe Dienstleister weitergeleitet: +

+
    +
  • + OpenRouter Inc. (USA) — API-Gateway, das Anfragen an das + KI-Modell weiterleitet. Für unser Konto ist Zero Data Retention aktiviert: + OpenRouter speichert Anfragen nicht dauerhaft. Datenschutzerklärung:{" "} + + openrouter.ai/privacy + +
  • +
  • + Google LLC (USA) — KI-Modell: google/gemini-3.1-flash-lite, + betrieben über OpenRouter. Die Speicherdauer auf dieser Ebene richtet sich + nach den Datenverarbeitungsbedingungen von Google für die API-Nutzung. + Datenschutzerklärung:{" "} + + policies.google.com/privacy + +
  • +
+

+ Chatverläufe werden in pseudonymisierter Form in einer lokalen SQLite-Datenbank + auf unserem Server gespeichert, verknüpft mit einer Chat-Session-ID{" "} + (einer zufällig generierten Kennung, die für die Dauer der Sitzung im + Arbeitsspeicher des Browsers gehalten wird und von der in Abschnitt 3 + beschriebenen Analyse-Session-ID verschieden ist). +

+

+ Da OpenRouter und Google ihren Sitz in den USA haben, handelt es sich um eine + Übermittlung in ein Drittland (Art. 44 ff. DSGVO): +

+
    +
  • + Google LLC ist nach dem EU-US-Datenschutzrahmen (Data + Privacy Framework, DPF) zertifiziert. Die Übermittlung stützt sich auf den + Angemessenheitsbeschluss der Europäischen Kommission vom 10. Juli 2023 + (Art. 45 DSGVO, Durchführungsbeschluss (EU) 2023/1795). +
  • +
  • + OpenRouter Inc. ist nicht nach dem DPF zertifiziert. Die + Übermittlung stützt sich auf Standarddatenschutzklauseln der Europäischen + Kommission (Art. 46 Abs. 2 lit. c DSGVO) gemäß den veröffentlichten + Datenverarbeitungsbedingungen von OpenRouter. +
  • +
+

+ Bitte geben Sie keine personenbezogenen Daten (Name, E-Mail-Adresse usw.) in + den Chat ein. +

+

+ Gemäß Art. 50 Abs. 1 der KI-Verordnung (VO (EU) 2024/1689) werden Sie vor + jeder Chat-Sitzung über den Einwilligungshinweis und die einleitende Nachricht + in der Chat-Oberfläche darüber informiert, dass Sie mit einem KI-System + interagieren. +

+

+ Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO — ausdrückliche Einwilligung vor + der ersten Nutzung der Chat-Funktion. Zur Erfüllung der Nachweispflicht gemäß + Art. 7 Abs. 1 DSGVO wird serverseitig ein pseudonymer Einwilligungsnachweis + gespeichert (Zeitstempel, Policy-Version und Session-ID — keine IP-Adresse). + Sie können Ihre Einwilligung jederzeit durch Klick auf „Einwilligung + widerrufen" in der Chat-Oberfläche zurückziehen; dies berührt nicht die + Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung (Art. 7 Abs. 3 + DSGVO). +

+
+
+ + + + 5. Cookies und lokaler Speicher + + +

+ Diese Website setzt keine Cookies. Es werden weder + Erst- noch Drittanbieter-Cookies verwendet. Es kommen keine + Werbetechnologien, Social-Media-Pixel oder externe Tracking-Dienste zum + Einsatz. +

+

Im Browser-Speicher werden folgende Daten abgelegt:

+
    +
  • + Farbschema-Einstellung (portfolio-theme, + localStorage): speichert Ihr gewähltes Farbschema (dunkel/hell/System). Dieser + Wert verlässt Ihren Browser nicht und wird ausschließlich dazu verwendet, Ihre + Anzeigeeinstellung bei späteren Besuchen wiederherzustellen. Rechtsgrundlage: + § 25 Abs. 2 Nr. 2 TDDDG (unbedingt erforderlich für den von Ihnen + angeforderten Dienst). +
  • +
  • + Chat-Einwilligung (portfolio-chat-consent, + sessionStorage): speichert, ob Sie der Chat-Nutzung für die aktuelle + Browser-Sitzung zugestimmt haben. Dieser Wert ist tab-gebunden, wird niemals + an einen Server übertragen und beim Schließen des Tabs automatisch gelöscht. + Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG (unbedingt erforderlich für den + Betrieb des Einwilligungsmechanismus für den von Ihnen ausdrücklich + angeforderten Chat-Dienst). +
  • +
+
+
+ + + + 6. Datenspeicherung und Löschfristen + + +
    +
  • Analyse-Ereignisse: Speicherung für 12 Monate, anschließend automatisierte Löschung.
  • +
  • Chat-Nachrichten: Löschung nach 90 Tagen durch automatisierten Bereinigungsprozess, auf Anfrage auch früher.
  • +
  • Chat-Session-Datensätze (die den Einwilligungsnachweis enthalten — Policy-Version und Einwilligungszeitstempel): Aufbewahrung für 3 Jahre, anschließend automatisierte Löschung. Dies gewährleistet, dass die Einwilligung während der gesetzlichen Regelverjährungsfrist nachgewiesen werden kann, auch nachdem die Nachrichten selbst bereits gelöscht wurden.
  • +
  • Farbschema-Einstellung / Chat-Einwilligungsstatus: ausschließlich im Browser — keine serverseitige Speicherung.
  • +
+
+
+ + + + 7. Ihre Rechte + + +

Ihnen stehen nach der DSGVO folgende Rechte zu:

+
    +
  • Auskunft (Art. 15 DSGVO)
  • +
  • Berichtigung (Art. 16 DSGVO)
  • +
  • Löschung (Art. 17 DSGVO)
  • +
  • Einschränkung der Verarbeitung (Art. 18 DSGVO)
  • +
  • Datenübertragbarkeit (Art. 20 DSGVO) — gilt für Chat-Daten (Einwilligungsgrundlage); nicht anwendbar auf Analysedaten (berechtigtes Interesse)
  • +
  • Widerspruch (Art. 21 DSGVO) — insbesondere für die auf Art. 6 Abs. 1 lit. f DSGVO gestützte Analyse
  • +
  • Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO) — für die einwilligungsbasierte Verarbeitung (Chat)
  • +
+

+ Analysedaten werden unter einer pseudonymen Session-ID gespeichert. Zur + Geltendmachung Ihrer Rechte bezüglich der Analysedaten teilen Sie bitte den + ungefähren Besuchszeitraum sowie Ihren IP-Adressbereich mit — dies ermöglicht + die Rekonstruktion der Session-ID, die an dem jeweiligen Tag berechnet worden + wäre. Eine tagesübergreifende Identifikation ist aufgrund des täglich + rotierenden Salts technisch nicht möglich. +

+

+ Für alle sonstigen Anfragen wenden Sie sich bitte an:{" "} + + merten.dieckmann@web.de + +

+

+ Sie haben zudem das Recht, sich bei der zuständigen Aufsichtsbehörde zu + beschweren: Bayerisches Landesamt für Datenschutzaufsicht + (BayLDA),{" "} + + www.lda.bayern.de + +

+
+
+ +

Stand: Mai 2026

+ + ) +} \ No newline at end of file diff --git a/components/portfolio/lang-toggle.tsx b/components/portfolio/lang-toggle.tsx new file mode 100644 index 0000000..0044799 --- /dev/null +++ b/components/portfolio/lang-toggle.tsx @@ -0,0 +1,33 @@ +"use client" + +import {Button} from "@/components/ui/button"; + +export type Lang = "en" | "de" + +interface LangToggleProps { + lang: Lang + onChange: (lang: Lang) => void +} + +export function LangToggle({ lang, onChange }: LangToggleProps) { + return ( +
+ + +
+ ) +} diff --git a/components/portfolio/sidebar/chat.tsx b/components/portfolio/sidebar/chat.tsx index 93d61c5..de12d23 100644 --- a/components/portfolio/sidebar/chat.tsx +++ b/components/portfolio/sidebar/chat.tsx @@ -1,6 +1,6 @@ "use client" -import React, {useEffect, useRef} from "react" +import React, {useEffect, useRef, useState} from "react" import ReactMarkdown from "react-markdown" import { BotIcon, @@ -22,14 +22,23 @@ import {useFileSystem} from "@/context/file-system-context" import {useChatContext} from "@/context/chat-context" import type {UIMessage} from "ai" -const WELCOME_TEXT = "Hi! I'm Merten's portfolio assistant.\n\nAsk me anything about him. For example about his **projects**, **tech stack**, **CV**, or **thesis** work. I also know which file you currently have open." +const CONSENT_KEY = "portfolio-chat-consent" +const POLICY_VERSION = "2026-05" + +const WELCOME_TEXT = "Hi! I'm Merten's portfolio assistant — an AI system.\n\nAsk me anything about him. For example about his **projects**, **tech stack**, **CV**, or **thesis** work. I also know which file you currently have open." export default function ChatPanel() { const isMobile = useIsMobile() const { getActiveFile, openFileById } = useFileSystem() - const { chat, resetChat, input, setInput, activeFileRef } = useChatContext() + const { chat, sessionId, resetChat, input, setInput, activeFileRef } = useChatContext() const scrollRef = useRef(null) + const [hasConsented, setHasConsented] = useState(false) + + useEffect(() => { + setHasConsented(sessionStorage.getItem(CONSENT_KEY) === "true") + }, []) + activeFileRef.current = getActiveFile()?.name ?? null const { messages, sendMessage, status } = useChat({ chat }) @@ -41,6 +50,22 @@ export default function ChatPanel() { } }, [messages.length, isLoading]) + const handleConsent = () => { + sessionStorage.setItem(CONSENT_KEY, "true") + setHasConsented(true) + fetch("/api/consent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, policyVersion: POLICY_VERSION }), + }).catch(() => {}) + } + + const handleWithdrawConsent = () => { + sessionStorage.removeItem(CONSENT_KEY) + setHasConsented(false) + resetChat() + } + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!input.trim() || isLoading) return @@ -63,38 +88,92 @@ export default function ChatPanel() {