+
+
${opts.bodyHtml}
-
-
- `.trim();
+`;
+}
+
+function messageCard(label: string, html: string, accentColor: string = B.mint): string {
+ return `
+
+
+ ${label}
+
+
${html}
+
`;
+}
+
+function ctaButton(text: string, href: string): string {
+ return `
+
`;
}
const emailTemplates = {
@@ -85,31 +110,16 @@ const emailTemplates = {
subject: "Vielen Dank für deine Nachricht! 👋",
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
- const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
- subtitle: "Nachricht erhalten",
+ preheader: "Nachricht erhalten",
bodyHtml: `
-
+
Hey ${safeName},
- danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
-
-
-
-
-
- `.trim(),
+ danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. 🙌
+
+${messageCard("Deine Nachricht", nl2br(originalMessage))}
+${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
});
},
},
@@ -117,31 +127,16 @@ const emailTemplates = {
subject: "Projekt-Anfrage erhalten! 🚀",
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
- const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Projekt-Anfrage: danke, ${safeName}!`,
- subtitle: "Ich melde mich zeitnah",
+ preheader: "Ich melde mich zeitnah",
bodyHtml: `
-
+
Hey ${safeName},
- mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
-
-
-
-
-
Deine Projekt-Nachricht
-
-
- ${safeMsg}
-
-
-
-
- `.trim(),
+ mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀
+
+${messageCard("Deine Projekt-Anfrage", nl2br(originalMessage), B.sky)}
+${ctaButton("Mein Portfolio ansehen →", B.siteUrl)}`,
});
},
},
@@ -149,25 +144,15 @@ const emailTemplates = {
subject: "Danke für deine Nachricht! ⚡",
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
- const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
- subtitle: "Kurze Bestätigung",
+ preheader: "Kurze Bestätigung",
bodyHtml: `
-
+
Hey ${safeName},
- kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
-
-
-
- `.trim(),
+ kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. ⚡
+
+${messageCard("Deine Nachricht", nl2br(originalMessage))}`,
});
},
},
@@ -175,35 +160,19 @@ const emailTemplates = {
subject: "Antwort auf deine Nachricht 📧",
template: (name: string, originalMessage: string, responseMessage: string) => {
const safeName = escapeHtml(name);
- const safeOriginal = nl2br(escapeHtml(originalMessage));
- const safeResponse = nl2br(escapeHtml(responseMessage));
return baseEmail({
- title: `Antwort für ${safeName}`,
- subtitle: "Neue Nachricht",
+ title: `Hey ${safeName}!`,
+ preheader: "Antwort von Dennis",
bodyHtml: `
-
+
Hey ${safeName},
- hier ist meine Antwort:
+ ich habe mir deine Nachricht angeschaut — hier ist meine Antwort:
+
+${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)}
+
+${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
-
-
-
-
- ${safeResponse}
-
-
-
-
-
-
Deine ursprüngliche Nachricht
-
-
- ${safeOriginal}
-
-
- `.trim(),
+${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
});
},
},
@@ -231,36 +200,23 @@ export async function POST(request: NextRequest) {
originalMessage: string;
response?: string;
};
-
+
const { to, name, template, originalMessage, response } = body;
- // Validate input
if (!to || !name || !template || !originalMessage) {
- return NextResponse.json(
- { error: "Alle Felder sind erforderlich" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
}
if (template === "reply" && (!response || !response.trim())) {
return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 });
}
- // Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(to)) {
- console.error('❌ Validation failed: Invalid email format');
- return NextResponse.json(
- { error: "Ungültige E-Mail-Adresse" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
}
- // Check if template exists
if (!emailTemplates[template]) {
- return NextResponse.json(
- { error: "Ungültiges Template" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Ungültiges Template" }, { status: 400 });
}
const user = process.env.MY_EMAIL ?? "";
@@ -268,10 +224,7 @@ export async function POST(request: NextRequest) {
if (!user || !pass) {
console.error("❌ Missing email/password environment variables");
- return NextResponse.json(
- { error: "E-Mail-Server nicht konfiguriert" },
- { status: 500 },
- );
+ return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
}
const transportOptions: SMTPTransport.Options = {
@@ -279,86 +232,50 @@ export async function POST(request: NextRequest) {
port: 587,
secure: false,
requireTLS: true,
- auth: {
- type: "login",
- user,
- pass,
- },
+ auth: { type: "login", user, pass },
connectionTimeout: 30000,
greetingTimeout: 30000,
socketTimeout: 60000,
- tls: {
- rejectUnauthorized: false,
- ciphers: 'SSLv3'
- }
+ tls: { rejectUnauthorized: false, ciphers: 'SSLv3' },
};
const transport = nodemailer.createTransport(transportOptions);
- // Verify transport configuration
try {
await transport.verify();
- } catch (_verifyError) {
- return NextResponse.json(
- { error: "E-Mail-Server-Verbindung fehlgeschlagen" },
- { status: 500 },
- );
+ } catch {
+ return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
}
const selectedTemplate = emailTemplates[template];
- let html: string;
- if (template === "reply") {
- html = emailTemplates.reply.template(name, originalMessage, response || "");
- } else {
- // Narrow the template type so TS knows this is not the 3-arg reply template
- const nonReplyTemplate = template as Exclude
;
- html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
- }
+ const html = template === "reply"
+ ? emailTemplates.reply.template(name, originalMessage, response || "")
+ : emailTemplates[template as Exclude].template(name, originalMessage);
+
const mailOptions: Mail.Options = {
from: `"Dennis Konkol" <${user}>`,
- to: to,
- replyTo: "contact@dk0.dev",
+ to,
+ replyTo: B.email,
subject: selectedTemplate.subject,
html,
- text: `
-Hallo ${name}!
-
-Vielen Dank für deine Nachricht:
-${originalMessage}
-
-${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"}
-
-Beste Grüße,
-Dennis Konkol
-Software Engineer & Student
-https://dki.one
-contact@dk0.dev
- `,
+ text: template === "reply"
+ ? `Hey ${name}!\n\nAntwort:\n${response}\n\nDeine ursprüngliche Nachricht:\n${originalMessage}\n\n-- Dennis Konkol\n${B.siteUrl}`
+ : `Hey ${name}!\n\nDanke für deine Nachricht:\n${originalMessage}\n\nIch melde mich bald!\n\n-- Dennis Konkol\n${B.siteUrl}`,
};
- const sendMailPromise = () =>
- new Promise((resolve, reject) => {
- transport.sendMail(mailOptions, function (err, info) {
- if (!err) {
- resolve(info.response);
- } else {
- reject(err.message);
- }
- });
+ const result = await new Promise((resolve, reject) => {
+ transport.sendMail(mailOptions, (err, info) => {
+ if (!err) resolve(info.response);
+ else reject(err.message);
});
-
- const result = await sendMailPromise();
-
- return NextResponse.json({
- message: "Template-E-Mail erfolgreich gesendet",
- template: template,
- messageId: result
});
-
+
+ return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result });
+
} catch (err) {
- return NextResponse.json({
- error: "Fehler beim Senden der Template-E-Mail",
- details: err instanceof Error ? err.message : 'Unbekannter Fehler'
+ return NextResponse.json({
+ error: "Fehler beim Senden der E-Mail",
+ details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 });
}
}
diff --git a/app/api/email/route.tsx b/app/api/email/route.tsx
index 006f0b7..1731a06 100644
--- a/app/api/email/route.tsx
+++ b/app/api/email/route.tsx
@@ -5,12 +5,8 @@ import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
-// Sanitize input to prevent XSS
function sanitizeInput(input: string, maxLength: number = 10000): string {
- return input
- .slice(0, maxLength)
- .replace(/[<>]/g, '') // Remove potential HTML tags
- .trim();
+ return input.slice(0, maxLength).replace(/[<>]/g, '').trim();
}
function escapeHtml(input: string): string {
@@ -22,19 +18,126 @@ function escapeHtml(input: string): string {
.replace(/'/g, "'");
}
+
+function buildNotificationEmail(opts: {
+ name: string;
+ email: string;
+ subject: string;
+ messageHtml: string;
+ initial: string;
+ replyHref: string;
+ sentAt: string;
+}): string {
+ const { name, email, subject, messageHtml, initial, replyHref, sentAt } = opts;
+ return `
+
+
+
+
+ Neue Kontaktanfrage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dk0.dev · Portfolio Kontakt
+
+
+ Neue Kontaktanfrage
+
+
+ ${escapeHtml(sentAt)}
+
+
+
+ dk0.dev
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(initial)}
+
+
+
${escapeHtml(name)}
+
${escapeHtml(email)}
+
+
+
+
+
+
+
+ ${escapeHtml(subject)}
+
+
+
+
+
+
+
+ Nachricht
+
+
+ ${messageHtml}
+
+
+
+
+
+
+
+
+
+
+ Automatisch generiert ·
dk0.dev
+
+
+ contact@dk0.dev
+
+
+
+
+
+
+
+`;
+}
+
export async function POST(request: NextRequest) {
try {
- // Rate limiting (defensive: headers may be undefined in tests)
const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown';
- if (!checkRateLimit(ip, 5, 60000)) { // 5 emails per minute per IP
+ if (!checkRateLimit(ip, 5, 60000)) {
return NextResponse.json(
{ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' },
- {
+ {
status: 429,
- headers: {
- 'Content-Type': 'application/json',
- ...getRateLimitHeaders(ip, 5, 60000)
- }
+ headers: { 'Content-Type': 'application/json', ...getRateLimitHeaders(ip, 5, 60000) },
}
);
}
@@ -45,49 +148,27 @@ export async function POST(request: NextRequest) {
subject: string;
message: string;
};
-
- // Sanitize and validate input
+
const email = sanitizeInput(body.email || '', 255);
const name = sanitizeInput(body.name || '', 100);
const subject = sanitizeInput(body.subject || '', 200);
const message = sanitizeInput(body.message || '', 5000);
- // Email request received
-
- // Validate input
if (!email || !name || !subject || !message) {
- console.error('❌ Validation failed: Missing required fields');
- return NextResponse.json(
- { error: "Alle Felder sind erforderlich" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
}
- // Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
- console.error('❌ Validation failed: Invalid email format');
- return NextResponse.json(
- { error: "Ungültige E-Mail-Adresse" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
}
- // Validate message length
if (message.length < 10) {
- console.error('❌ Validation failed: Message too short');
- return NextResponse.json(
- { error: "Nachricht muss mindestens 10 Zeichen lang sein" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 });
}
- // Validate field lengths
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
- return NextResponse.json(
- { error: "Eingabe zu lang" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 });
}
const user = process.env.MY_EMAIL ?? "";
@@ -95,265 +176,98 @@ export async function POST(request: NextRequest) {
if (!user || !pass) {
console.error("❌ Missing email/password environment variables");
- return NextResponse.json(
- { error: "E-Mail-Server nicht konfiguriert" },
- { status: 500 },
- );
+ return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
}
const transportOptions: SMTPTransport.Options = {
host: "mail.dk0.dev",
port: 587,
- secure: false, // Port 587 uses STARTTLS, not SSL/TLS
+ secure: false,
requireTLS: true,
- auth: {
- type: "login",
- user,
- pass,
- },
- // Increased timeout settings for better reliability
- connectionTimeout: 30000, // 30 seconds
- greetingTimeout: 30000, // 30 seconds
- socketTimeout: 60000, // 60 seconds
- // TLS hardening (allow insecure/self-signed only when explicitly enabled)
+ auth: { type: "login", user, pass },
+ connectionTimeout: 30000,
+ greetingTimeout: 30000,
+ socketTimeout: 60000,
tls:
- process.env.SMTP_ALLOW_INSECURE_TLS === "true" ||
- process.env.SMTP_ALLOW_SELF_SIGNED === "true"
- ? { rejectUnauthorized: false }
- : { rejectUnauthorized: true, minVersion: "TLSv1.2" },
+ process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
+ ? { rejectUnauthorized: false }
+ : { rejectUnauthorized: true, minVersion: "TLSv1.2" },
};
- // Creating transport with configured options
-
const transport = nodemailer.createTransport(transportOptions);
- // Verify transport configuration with retry logic
let verificationAttempts = 0;
- const maxVerificationAttempts = 3;
- let verificationSuccess = false;
-
- while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
+ while (verificationAttempts < 3) {
try {
verificationAttempts++;
await transport.verify();
- verificationSuccess = true;
+ break;
} catch (verifyError) {
if (process.env.NODE_ENV === 'development') {
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
}
-
- if (verificationAttempts >= maxVerificationAttempts) {
- if (process.env.NODE_ENV === 'development') {
- console.error('All SMTP verification attempts failed');
- }
- return NextResponse.json(
- { error: "E-Mail-Server-Verbindung fehlgeschlagen" },
- { status: 500 },
- );
+ if (verificationAttempts >= 3) {
+ return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
}
-
- // Wait before retry
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
- const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
- const safeName = escapeHtml(name);
- const safeEmail = escapeHtml(email);
- const safeSubject = escapeHtml(subject);
- const safeMessageHtml = escapeHtml(message).replace(/\n/g, "
");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
+ const messageHtml = escapeHtml(message).replace(/\n/g, "
");
const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
- to: "contact@dk0.dev", // Send to your contact email
+ to: "contact@dk0.dev",
replyTo: email,
- subject: `Portfolio Kontakt: ${subject}`,
- html: `
-
-
-
-
-
- Neue Kontaktanfrage - Portfolio
-
-
-
-
-
-
-
-
- Dennis Konkol
-
-
- dk0.dev
-
-
-
-
- Neue Kontaktanfrage
-
-
- Eingegangen am ${sentAt}
-
-
-
-
-
-
-
-
-
-
- ${escapeHtml(initial)}
-
-
-
- ${safeName}
-
-
- E-Mail: ${safeEmail}
- Betreff: ${safeSubject}
-
-
-
-
-
-
-
-
- ${safeMessageHtml}
-
-
-
-
-
-
-
-
-
-
- Automatisch generiert von
dk0.dev
-
-
-
-
-
-
- `,
- text: `
-Neue Kontaktanfrage von deinem Portfolio
-
-Von: ${name} (${email})
-Betreff: ${subject}
-
-Nachricht:
-${message}
-
----
-Diese E-Mail wurde automatisch von dk0.dev generiert.
- `,
+ subject: `📬 Neue Anfrage: ${subject}`,
+ html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }),
+ text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`,
};
- // Sending email
-
- // Email sending with retry logic
let sendAttempts = 0;
- const maxSendAttempts = 3;
- let sendSuccess = false;
let result = '';
- while (sendAttempts < maxSendAttempts && !sendSuccess) {
+ while (sendAttempts < 3) {
try {
sendAttempts++;
- // Email send attempt
-
- const sendMailPromise = () =>
- new Promise((resolve, reject) => {
- transport.sendMail(mailOptions, function (err, info) {
- if (!err) {
- // Email sent successfully
- resolve(info.response);
- } else {
- if (process.env.NODE_ENV === 'development') {
- console.error("Error sending email:", err);
- }
- reject(err.message);
- }
- });
+ result = await new Promise((resolve, reject) => {
+ transport.sendMail(mailOptions, (err, info) => {
+ if (!err) resolve(info.response);
+ else {
+ if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err);
+ reject(err.message);
+ }
});
-
- result = await sendMailPromise();
- sendSuccess = true;
- // Email process completed successfully
+ });
+ break;
} catch (sendError) {
- if (process.env.NODE_ENV === 'development') {
- console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
+ if (sendAttempts >= 3) {
+ throw new Error(`Failed to send email after 3 attempts: ${sendError}`);
}
-
- if (sendAttempts >= maxSendAttempts) {
- if (process.env.NODE_ENV === 'development') {
- console.error('All email send attempts failed');
- }
- throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
- }
-
- // Wait before retry
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
-
- // Save contact to database
+
+ // Save to DB
try {
- await prisma.contact.create({
- data: {
- name,
- email,
- subject,
- message,
- responded: false
- }
- });
- // Contact saved to database
+ await prisma.contact.create({ data: { name, email, subject, message, responded: false } });
} catch (dbError) {
- if (process.env.NODE_ENV === 'development') {
- console.error('Error saving contact to database:', dbError);
- }
- // Don't fail the email send if DB save fails
+ if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
}
-
- return NextResponse.json({
- message: "E-Mail erfolgreich gesendet",
- messageId: result
- });
-
+
+ return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result });
+
} catch (err) {
console.error("❌ Unexpected error in email API:", err);
- return NextResponse.json({
+ return NextResponse.json({
error: "Fehler beim Senden der E-Mail",
- details: err instanceof Error ? err.message : 'Unbekannter Fehler'
+ details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 });
}
}
diff --git a/app/api/n8n/hardcover/sync-books/route.ts b/app/api/n8n/hardcover/sync-books/route.ts
new file mode 100644
index 0000000..9742d14
--- /dev/null
+++ b/app/api/n8n/hardcover/sync-books/route.ts
@@ -0,0 +1,125 @@
+/**
+ * POST /api/n8n/hardcover/sync-books
+ *
+ * Called by an n8n workflow whenever books are finished in Hardcover.
+ * Creates new entries in the Directus book_reviews collection.
+ * Deduplicates by hardcover_id — safe to call repeatedly.
+ *
+ * n8n Workflow setup:
+ * 1. Schedule Trigger (every hour)
+ * 2. HTTP Request → Hardcover GraphQL (query: me { books_read(limit: 20) { ... } })
+ * 3. Code Node → transform to array of HardcoverBook objects
+ * 4. HTTP Request → POST https://dk0.dev/api/n8n/hardcover/sync-books
+ * Headers: Authorization: Bearer
+ * Body: [{ hardcover_id, title, author, image, rating, finished_at }, ...]
+ *
+ * Expected body shape (array or single object):
+ * {
+ * hardcover_id: string | number // Hardcover book ID, used for deduplication
+ * title: string
+ * author: string
+ * image?: string // Cover image URL
+ * rating?: number // 1–5
+ * finished_at?: string // ISO date string
+ * }
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getBookReviewByHardcoverId, createBookReview } from '@/lib/directus';
+import { checkRateLimit, getClientIp } from '@/lib/auth';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+interface HardcoverBook {
+ hardcover_id: string | number;
+ title: string;
+ author: string;
+ image?: string;
+ rating?: number;
+ finished_at?: string;
+}
+
+export async function POST(request: NextRequest) {
+ // Auth: require N8N_SECRET_TOKEN or N8N_API_KEY
+ const authHeader = request.headers.get('Authorization');
+ const apiKeyHeader = request.headers.get('X-API-Key');
+ const validToken = process.env.N8N_SECRET_TOKEN;
+ const validApiKey = process.env.N8N_API_KEY;
+
+ const isAuthenticated =
+ (validToken && authHeader === `Bearer ${validToken}`) ||
+ (validApiKey && apiKeyHeader === validApiKey);
+
+ if (!isAuthenticated) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Rate limit: max 10 sync requests per minute
+ const ip = getClientIp(request);
+ if (!checkRateLimit(ip, 10, 60000, 'hardcover-sync')) {
+ return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
+ }
+
+ let books: HardcoverBook[];
+ try {
+ const body = await request.json();
+ books = Array.isArray(body) ? body : [body];
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ if (books.length === 0) {
+ return NextResponse.json({ success: true, created: 0, skipped: 0, errors: 0 });
+ }
+
+ const results = {
+ created: 0,
+ skipped: 0,
+ errors: 0,
+ details: [] as string[],
+ };
+
+ for (const book of books) {
+ if (!book.title || !book.author) {
+ results.errors++;
+ results.details.push(`Skipped (missing title/author): ${JSON.stringify(book).slice(0, 80)}`);
+ continue;
+ }
+
+ const hardcoverId = String(book.hardcover_id);
+
+ // Deduplication: skip if already in Directus
+ const existing = await getBookReviewByHardcoverId(hardcoverId);
+ if (existing) {
+ results.skipped++;
+ results.details.push(`Skipped (exists): "${book.title}"`);
+ continue;
+ }
+
+ // Create new entry in Directus
+ const created = await createBookReview({
+ hardcover_id: hardcoverId,
+ book_title: book.title,
+ book_author: book.author,
+ book_image: book.image,
+ rating: book.rating,
+ finished_at: book.finished_at,
+ status: 'published',
+ });
+
+ if (created) {
+ results.created++;
+ results.details.push(`Created: "${book.title}" → id=${created.id}`);
+ } else {
+ results.errors++;
+ results.details.push(`Error creating: "${book.title}" (Directus unavailable or token missing)`);
+ }
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log('[sync-books]', results);
+ }
+
+ return NextResponse.json({ success: true, source: 'directus', ...results });
+}
diff --git a/app/api/sentry-example-api/route.ts b/app/api/sentry-example-api/route.ts
deleted file mode 100644
index 6958bf4..0000000
--- a/app/api/sentry-example-api/route.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from "@sentry/nextjs";
-import { NextResponse } from "next/server";
-
-export const dynamic = "force-dynamic";
-
-// A faulty API route to test Sentry's error monitoring
-export function GET() {
- const testError = new Error("Sentry Example API Route Error");
- Sentry.captureException(testError);
- return NextResponse.json({ error: "This is a test error from the API route" }, { status: 500 });
-}
diff --git a/app/components/About.tsx b/app/components/About.tsx
index 11eeba4..a6a523f 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -3,7 +3,6 @@
import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
-import type { JSONContent } from "@tiptap/react";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading";
@@ -23,7 +22,7 @@ const iconMap: Record = {
const About = () => {
const locale = useLocale();
const t = useTranslations("home.about");
- const [cmsDoc, setCmsDoc] = useState(null);
+ const [cmsHtml, setCmsHtml] = useState(null);
const [techStack, setTechStack] = useState([]);
const [hobbies, setHobbies] = useState([]);
const [snippets, setSnippets] = useState([]);
@@ -44,7 +43,7 @@ const About = () => {
]);
const cmsData = await cmsRes.json();
- if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
+ if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack);
@@ -80,9 +79,6 @@ const About = () => {
{/* 1. Large Bio Text */}
@@ -96,8 +92,8 @@ const About = () => {
- ) : cmsDoc ? (
-
+ ) : cmsHtml ? (
+
) : (
{t("p1")} {t("p2")}
)}
@@ -113,9 +109,6 @@ const About = () => {
{/* 2. Activity / Status Box */}
@@ -130,9 +123,6 @@ const About = () => {
{/* 3. AI Chat Box */}
@@ -147,9 +137,6 @@ const About = () => {
{/* 4. Tech Stack */}
@@ -186,9 +173,6 @@ const About = () => {
{/* Library - Larger Span */}
@@ -211,9 +195,6 @@ const About = () => {
{/* My Gear (Uses) */}
@@ -244,9 +225,6 @@ const About = () => {
@@ -282,9 +260,6 @@ const About = () => {
{/* 6. Hobbies */}
diff --git a/app/components/ActivityFeed.tsx b/app/components/ActivityFeed.tsx
index b120581..bb0fb27 100644
--- a/app/components/ActivityFeed.tsx
+++ b/app/components/ActivityFeed.tsx
@@ -110,7 +110,7 @@ export default function ActivityFeed({
clearInterval(statusInterval);
clearInterval(quoteInterval);
};
- }, [onActivityChange]);
+ }, [onActivityChange, allQuotes.length]);
if (loading) {
return
diff --git a/app/components/ClientProviders.tsx b/app/components/ClientProviders.tsx
index 93af8e9..7aaa57a 100644
--- a/app/components/ClientProviders.tsx
+++ b/app/components/ClientProviders.tsx
@@ -7,15 +7,9 @@ import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary";
import { ConsentProvider } from "./ConsentProvider";
import { ThemeProvider } from "./ThemeProvider";
-import { motion, AnimatePresence } from "framer-motion";
-const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
- ssr: false,
- loading: () => null,
-});
-
-const ShaderGradientBackground = dynamic(
- () => import("./ShaderGradientBackground"),
+const BackgroundBlobs = dynamic(
+ () => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
{ ssr: false, loading: () => null }
);
@@ -25,66 +19,19 @@ export default function ClientProviders({
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
- const [is404Page, setIs404Page] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
- // Check if we're on a 404 page by looking for the data attribute or pathname
- const check404 = () => {
- try {
- if (typeof window !== "undefined" && typeof document !== "undefined") {
- const has404Component = document.querySelector('[data-404-page]');
- const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
- setIs404Page(!!has404Component || is404Path);
- }
- } catch (error) {
- // Silently fail - 404 detection is not critical
- if (process.env.NODE_ENV === 'development') {
- console.warn('Error checking 404 status:', error);
- }
- }
- };
- // Check immediately and after a short delay
- try {
- check404();
- const timeout = setTimeout(check404, 100);
- const interval = setInterval(check404, 500);
- return () => {
- try {
- clearTimeout(timeout);
- clearInterval(interval);
- } catch {
- // Silently fail during cleanup
- }
- };
- } catch (error) {
- // If setup fails, just return empty cleanup
- if (process.env.NODE_ENV === 'development') {
- console.warn('Error setting up 404 check:', error);
- }
- return () => {};
- }
}, [pathname]);
- // Wrap in multiple error boundaries to isolate failures
return (
-
-
-
-
- {children}
-
-
+
+
+ {children}
@@ -99,13 +46,25 @@ function GatedProviders({
}: {
children: React.ReactNode;
mounted: boolean;
- is404Page: boolean;
}) {
+ // Defer animated background blobs until after LCP
+ const [deferredReady, setDeferredReady] = useState(false);
+ useEffect(() => {
+ if (!mounted) return;
+ let id: ReturnType | number;
+ if (typeof requestIdleCallback !== "undefined") {
+ id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
+ return () => cancelIdleCallback(id as number);
+ } else {
+ id = setTimeout(() => setDeferredReady(true), 200);
+ return () => clearTimeout(id);
+ }
+ }, [mounted]);
+
return (
- {mounted && }
- {mounted && }
+ {deferredReady && }
{children}
diff --git a/app/components/ClientWrappers.tsx b/app/components/ClientWrappers.tsx
index a9566e0..70d6b6e 100644
--- a/app/components/ClientWrappers.tsx
+++ b/app/components/ClientWrappers.tsx
@@ -6,13 +6,15 @@
*/
import { NextIntlClientProvider } from 'next-intl';
-import Hero from './Hero';
-import About from './About';
-import Projects from './Projects';
-import Contact from './Contact';
-import Footer from './Footer';
+import dynamic from 'next/dynamic';
+
+// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
+// SSR stays on (default) so content is in the initial HTML for SEO.
+const About = dynamic(() => import('./About'));
+const Projects = dynamic(() => import('./Projects'));
+const Contact = dynamic(() => import('./Contact'));
+const Footer = dynamic(() => import('./Footer'));
import type {
- HeroTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
@@ -27,23 +29,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
return locale.startsWith('de') ? 'de' : 'en';
}
-export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
- const normalLocale = getNormalizedLocale(locale);
- const baseMessages = messageMap[normalLocale];
-
- const messages = {
- home: {
- hero: baseMessages.home.hero
- }
- };
-
- return (
-
-
-
- );
-}
-
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
diff --git a/app/components/ConsentBanner.tsx b/app/components/ConsentBanner.tsx
index 22f09dd..1ea4007 100644
--- a/app/components/ConsentBanner.tsx
+++ b/app/components/ConsentBanner.tsx
@@ -54,8 +54,6 @@ export default function ConsentBanner() {
type="button"
onClick={() => setMinimized(true)}
className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors"
- aria-label="Minimize privacy banner"
- title="Minimize"
>
{s.hide}
diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx
index 80af0af..0fed48b 100644
--- a/app/components/Contact.tsx
+++ b/app/components/Contact.tsx
@@ -5,7 +5,6 @@ import { motion } from "framer-motion";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl";
-import type { JSONContent } from "@tiptap/react";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
@@ -15,7 +14,7 @@ const Contact = () => {
const t = useTranslations("home.contact");
const tForm = useTranslations("home.contact.form");
const tInfo = useTranslations("home.contact.info");
- const [cmsDoc, setCmsDoc] = useState(null);
+ const [cmsHtml, setCmsHtml] = useState(null);
useEffect(() => {
(async () => {
@@ -25,14 +24,14 @@ const Contact = () => {
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
- if (data?.content?.content && data?.content?.locale === locale) {
- setCmsDoc(data.content.content as JSONContent);
+ if (data?.content?.html && data?.content?.locale === locale) {
+ setCmsHtml(data.content.html as string);
} else {
- setCmsDoc(null);
+ setCmsHtml(null);
}
} catch {
// ignore; fallback to static
- setCmsDoc(null);
+ setCmsHtml(null);
}
})();
}, [locale]);
@@ -163,17 +162,14 @@ const Contact = () => {
{/* Header Card */}
{t("title")}.
- {cmsDoc ? (
-
+ {cmsHtml ? (
+
) : (
{t("subtitle")}
@@ -184,9 +180,6 @@ const Contact = () => {
{/* Info Side (Unified Connect Box) */}
@@ -252,9 +245,6 @@ const Contact = () => {
{/* Form Side */}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 0cdfdee..607239c 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
-import { motion, AnimatePresence } from "framer-motion";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
@@ -26,11 +25,7 @@ const Header = () => {
return (
<>
-
+
-
+
{/* Mobile Menu Overlay */}
-
- {isOpen && (
-
-
- {navItems.map((item) => (
- setIsOpen(false)}
- className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
- >
- {item.name}
-
- ))}
-
-
- )}
-
+
+
+ {navItems.map((item) => (
+ setIsOpen(false)}
+ className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
+ >
+ {item.name}
+
+ ))}
+
+
>
);
};
diff --git a/app/components/HeaderClient.tsx b/app/components/HeaderClient.tsx
index d8fd09c..55bef47 100644
--- a/app/components/HeaderClient.tsx
+++ b/app/components/HeaderClient.tsx
@@ -1,13 +1,22 @@
"use client";
import { useState, useEffect } from "react";
-import { motion, AnimatePresence } from "framer-motion";
-import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
+// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
+const MenuIcon = ({ size = 24 }: { size?: number }) => (
+
+);
+const XIcon = ({ size = 24 }: { size?: number }) => (
+
+);
+const MailIcon = ({ size = 20 }: { size?: number }) => (
+
+);
+
interface HeaderClientProps {
locale: string;
translations: NavTranslations;
@@ -44,7 +53,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
href: "https://linkedin.com/in/dkonkol",
label: "LinkedIn",
},
- { icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
+ { icon: MailIcon, href: "mailto:contact@dk0.dev", label: "Email" },
];
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
@@ -55,53 +64,38 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
return (
<>
-
+
-
-
+
dk0
-
+
- setIsOpen(!isOpen)}
- className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors"
+ className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95"
aria-label="Toggle menu"
>
- {isOpen ? : }
-
-
+ {isOpen ? : }
+
+
-
+
-
- {isOpen && (
- setIsOpen(false)}
- />
- )}
-
+ {/* Mobile menu overlay */}
+ setIsOpen(false)}
+ />
-
- {isOpen && (
-
-
-
- setIsOpen(false)}
- >
- dk0
-
-
-
+ {/* Mobile menu panel */}
+
+
+
+ setIsOpen(false)}
+ >
+ dk0
+
+
+
-
+
>
);
}
diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx
index a6a821d..9337184 100644
--- a/app/components/Hero.tsx
+++ b/app/components/Hero.tsx
@@ -1,135 +1,73 @@
-"use client";
-
-import { motion } from "framer-motion";
-import { useLocale, useTranslations } from "next-intl";
+import { getTranslations } from "next-intl/server";
import Image from "next/image";
-import { useEffect, useState } from "react";
-const Hero = () => {
- const locale = useLocale();
- const t = useTranslations("home.hero");
- const [cmsMessages, setCmsMessages] = useState
>({});
+interface HeroProps {
+ locale: string;
+}
- useEffect(() => {
- (async () => {
- try {
- const res = await fetch(`/api/messages?locale=${locale}`);
- if (res.ok) {
- const data = await res.json();
- setCmsMessages(data.messages || {});
- }
- } catch {}
- })();
- }, [locale]);
-
- // Helper to get CMS text or fallback
- const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
+export default async function Hero({ locale: _locale }: HeroProps) {
+ const t = await getTranslations("home.hero");
return (
-
- {/* Liquid Ambient Background */}
-
-
-
+
+ {/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
+
-
-
+
+
{/* Left: Text Content */}
-
-
+
+
- {getLabel("hero.badge", "Student & Self-Hoster")}
-
+ {t("badge")}
+
-
- {getLabel("hero.line1", "Building")}
-
-
- {getLabel("hero.line2", "Stuff.")}
-
+
+ {t("line1")}
+
+
+ {t("line2")}
+
-
+
{t("description")}
-
+
{/* Right: The Photo */}
-
+
-
+
);
-};
-
-export default Hero;
+}
diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx
index 1149366..80cecc5 100644
--- a/app/components/Projects.tsx
+++ b/app/components/Projects.tsx
@@ -74,13 +74,14 @@ const Projects = () => {
))
+ ) : projects.length === 0 ? (
+
+ No projects yet.
+
) : (
projects.map((project) => (
diff --git a/app/components/ReadBooks.tsx b/app/components/ReadBooks.tsx
index 8573f54..d231dda 100644
--- a/app/components/ReadBooks.tsx
+++ b/app/components/ReadBooks.tsx
@@ -101,7 +101,12 @@ const ReadBooks = () => {
}
if (reviews.length === 0) {
- return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
+ return (
+
+
+ {t("empty")}
+
+ );
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
diff --git a/app/components/RichTextClient.tsx b/app/components/RichTextClient.tsx
index 1813c51..e9abf7d 100644
--- a/app/components/RichTextClient.tsx
+++ b/app/components/RichTextClient.tsx
@@ -1,22 +1,19 @@
"use client";
-import React, { useMemo } from "react";
-import type { JSONContent } from "@tiptap/react";
-import { richTextToSafeHtml } from "@/lib/richtext";
+import React from "react";
+// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml).
+// This keeps TipTap/ProseMirror out of the client bundle entirely.
export default function RichTextClient({
- doc,
+ html,
className,
}: {
- doc: JSONContent;
+ html: string;
className?: string;
}) {
- const html = useMemo(() => richTextToSafeHtml(doc), [doc]);
-
return (
);
diff --git a/app/components/ScrollFadeIn.tsx b/app/components/ScrollFadeIn.tsx
new file mode 100644
index 0000000..ae8ae45
--- /dev/null
+++ b/app/components/ScrollFadeIn.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useRef, useEffect, useState, type ReactNode } from "react";
+
+interface ScrollFadeInProps {
+ children: ReactNode;
+ className?: string;
+ delay?: number;
+}
+
+/**
+ * Wraps children in a fade-in-up animation triggered by scroll.
+ * Unlike Framer Motion's initial={{ opacity: 0 }}, this does NOT
+ * render opacity:0 in SSR HTML — content is visible by default
+ * and only hidden after JS hydration for the animation effect.
+ */
+export default function ScrollFadeIn({ children, className = "", delay = 0 }: ScrollFadeInProps) {
+ const ref = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ const el = ref.current;
+ if (!el) return;
+
+ // Fallback for browsers without IntersectionObserver
+ if (typeof IntersectionObserver === "undefined") {
+ setIsVisible(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ observer.unobserve(el);
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/components/ShaderGradientBackground.tsx b/app/components/ShaderGradientBackground.tsx
index 8f7240c..56e4b88 100644
--- a/app/components/ShaderGradientBackground.tsx
+++ b/app/components/ShaderGradientBackground.tsx
@@ -1,112 +1,60 @@
-"use client";
-
-import React from "react";
-import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react";
-
-const ShaderGradientBackground = () => {
+// Pure CSS gradient background — replaces the Three.js/WebGL shader gradient.
+// Server component: no "use client", zero JS bundle cost, renders in initial HTML.
+// Visual result is identical since all original spheres had animate="off" (static).
+export default function ShaderGradientBackground() {
return (
-
- {/* Sphere 1 - Links oben */}
-
-
- {/* Sphere 2 - Rechts mitte */}
-
-
- {/* Sphere 3 - Unten links */}
-
-
+ />
+ {/* Right-center: pink → crimson (was Sphere 2: posX=2.0, posY=-0.5) */}
+
+ {/* Lower-left: orange → pink (was Sphere 3: posX=-0.5, posY=-2.0) */}
+
);
-};
-
-export default ShaderGradientBackground;
+}
diff --git a/app/components/ThemeProvider.tsx b/app/components/ThemeProvider.tsx
index 189a2b1..eef9dac 100644
--- a/app/components/ThemeProvider.tsx
+++ b/app/components/ThemeProvider.tsx
@@ -1,11 +1,38 @@
"use client";
-import * as React from "react";
-import { ThemeProvider as NextThemesProvider } from "next-themes";
+import React, { createContext, useContext, useEffect, useState } from "react";
-export function ThemeProvider({
- children,
- ...props
-}: React.ComponentProps) {
- return {children};
+type Theme = "light" | "dark";
+
+const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
+ theme: "light",
+ setTheme: () => {},
+});
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setThemeState] = useState("light");
+
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem("theme") as Theme | null;
+ if (stored === "dark" || stored === "light") {
+ setThemeState(stored);
+ document.documentElement.classList.toggle("dark", stored === "dark");
+ }
+ } catch {}
+ }, []);
+
+ const setTheme = (t: Theme) => {
+ setThemeState(t);
+ try {
+ localStorage.setItem("theme", t);
+ } catch {}
+ document.documentElement.classList.toggle("dark", t === "dark");
+ };
+
+ return {children};
+}
+
+export function useTheme() {
+ return useContext(ThemeCtx);
}
diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx
index 0d61707..7f096d7 100644
--- a/app/components/ThemeToggle.tsx
+++ b/app/components/ThemeToggle.tsx
@@ -2,8 +2,7 @@
import * as React from "react";
import { Moon, Sun } from "lucide-react";
-import { useTheme } from "next-themes";
-import { motion } from "framer-motion";
+import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -18,11 +17,9 @@ export function ThemeToggle() {
}
return (
- setTheme(theme === "dark" ? "light" : "dark")}
- className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm"
+ className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform"
aria-label="Toggle theme"
>
{theme === "dark" ? (
@@ -30,6 +27,6 @@ export function ThemeToggle() {
) : (
)}
-
+
);
}
diff --git a/app/global-error.tsx b/app/global-error.tsx
index ea2998d..bef27c1 100644
--- a/app/global-error.tsx
+++ b/app/global-error.tsx
@@ -1,6 +1,5 @@
"use client";
-import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
@@ -11,15 +10,9 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
- // Capture exception in Sentry
- Sentry.captureException(error);
-
- // Log error details to console
- console.error("Global Error:", error);
- console.error("Error Name:", error.name);
- console.error("Error Message:", error.message);
- console.error("Error Stack:", error.stack);
- console.error("Error Digest:", error.digest);
+ if (process.env.NODE_ENV === "development") {
+ console.error("Global Error:", error);
+ }
}, [error]);
return (
diff --git a/app/layout.tsx b/app/layout.tsx
index 59f8dad..4c16aae 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@ import { Metadata } from "next";
import { Inter, Playfair_Display } from "next/font/google";
import React from "react";
import ClientProviders from "./components/ClientProviders";
+import ShaderGradientBackground from "./components/ShaderGradientBackground";
import { cookies } from "next/headers";
import { getBaseUrl } from "@/lib/seo";
@@ -31,9 +32,12 @@ export default async function RootLayout({
+ {/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
+
+
{children}
diff --git a/app/legal-notice/page.tsx b/app/legal-notice/page.tsx
index 9278a9f..6c54edb 100644
--- a/app/legal-notice/page.tsx
+++ b/app/legal-notice/page.tsx
@@ -1,20 +1,18 @@
"use client";
import React from "react";
-import { motion } from 'framer-motion';
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
-import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient";
export default function LegalNotice() {
const locale = useLocale();
const t = useTranslations("common");
- const [cmsDoc, setCmsDoc] = useState(null);
+ const [cmsHtml, setCmsHtml] = useState(null);
useEffect(() => {
(async () => {
@@ -23,8 +21,8 @@ export default function LegalNotice() {
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
- if (data?.content?.content && data?.content?.locale === locale) {
- setCmsDoc(data.content.content as JSONContent);
+ if (data?.content?.html && data?.content?.locale === locale) {
+ setCmsHtml(data.content.html as string);
}
} catch {}
})();
@@ -34,13 +32,9 @@ export default function LegalNotice() {
-
+
{/* Editorial Header */}
-
+
{t("backToHome")}
-
+
Legal.
-
+
{/* Bento Content Grid */}
-
+
{/* Main Legal Content (Large Box) */}
-
- {cmsDoc ? (
+
+ {cmsHtml ? (
-
+
) : (
@@ -91,11 +80,11 @@ export default function LegalNotice() {
)}
-
+
{/* Sidebar Widgets */}
-
+
{/* Quick Contact Box */}
Direct Contact
diff --git a/app/manage/page.tsx b/app/manage/page.tsx
index 39feaff..5a4cc75 100644
--- a/app/manage/page.tsx
+++ b/app/manage/page.tsx
@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
-import { motion } from 'framer-motion';
import { Lock, Loader2 } from 'lucide-react';
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
@@ -58,7 +57,7 @@ const AdminPage = () => {
// Check if user is locked out
const checkLockout = useCallback(() => {
if (typeof window === 'undefined') return false;
-
+
try {
const lockoutData = localStorage.getItem('admin_lockout');
if (lockoutData) {
@@ -103,11 +102,11 @@ const AdminPage = () => {
try {
const sessionToken = sessionStorage.getItem('admin_session_token');
if (!sessionToken) {
- setAuthState(prev => ({
- ...prev,
- isAuthenticated: false,
- showLogin: true,
- isLoading: false
+ setAuthState(prev => ({
+ ...prev,
+ isAuthenticated: false,
+ showLogin: true,
+ isLoading: false
}));
return;
}
@@ -118,38 +117,38 @@ const AdminPage = () => {
'Content-Type': 'application/json',
'X-CSRF-Token': authState.csrfToken
},
- body: JSON.stringify({
- sessionToken,
- csrfToken: authState.csrfToken
+ body: JSON.stringify({
+ sessionToken,
+ csrfToken: authState.csrfToken
})
});
const data = await response.json();
-
+
if (response.ok && data.valid) {
- setAuthState(prev => ({
- ...prev,
- isAuthenticated: true,
- showLogin: false,
- isLoading: false
+ setAuthState(prev => ({
+ ...prev,
+ isAuthenticated: true,
+ showLogin: false,
+ isLoading: false
}));
sessionStorage.setItem('admin_authenticated', 'true');
} else {
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
- setAuthState(prev => ({
- ...prev,
- isAuthenticated: false,
- showLogin: true,
- isLoading: false
+ setAuthState(prev => ({
+ ...prev,
+ isAuthenticated: false,
+ showLogin: true,
+ isLoading: false
}));
}
} catch {
- setAuthState(prev => ({
- ...prev,
- isAuthenticated: false,
- showLogin: true,
- isLoading: false
+ setAuthState(prev => ({
+ ...prev,
+ isAuthenticated: false,
+ showLogin: true,
+ isLoading: false
}));
}
}, [authState.csrfToken]);
@@ -158,13 +157,13 @@ const AdminPage = () => {
useEffect(() => {
const init = async () => {
if (checkLockout()) return;
-
+
const token = await fetchCSRFToken();
if (token) {
setAuthState(prev => ({ ...prev, csrfToken: token }));
}
};
-
+
init();
}, [checkLockout, fetchCSRFToken]);
@@ -178,7 +177,7 @@ const AdminPage = () => {
// Handle login form submission
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
-
+
if (!authState.password.trim() || authState.isLoading) return;
setAuthState(prev => ({ ...prev, isLoading: true, error: '' }));
@@ -259,10 +258,12 @@ const AdminPage = () => {
// Loading state
if (authState.isLoading) {
return (
-
-
-
-
Loading...
+
);
@@ -271,26 +272,38 @@ const AdminPage = () => {
// Lockout state
if (authState.isLocked) {
return (
-
-
-
-
+
+
+
+
+
+
+
+
+
+ dk0.dev · admin
+
+
+ Account Locked
+
+
+ Too many failed attempts. Please try again in 15 minutes.
+
+
+
-
Account Locked
-
Too many failed attempts. Please try again in 15 minutes.
-
);
@@ -299,70 +312,84 @@ const AdminPage = () => {
// Login form
if (authState.showLogin || !authState.isAuthenticated) {
return (
-
-
-
-
-
-
-
-
-
Admin Access
-
Enter your password to continue
-
+
+ {/* Liquid ambient blobs */}
+
-