Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b97d3f7a1 | |||
| 9e6ae866d0 | |||
| 0fa10315b2 | |||
| 62f2fb7742 | |||
| 96ec2d867f | |||
| 02914d6145 |
@@ -132,7 +132,7 @@ jobs:
|
|||||||
-p ${HEALTH_PORT}:3000 \
|
-p ${HEALTH_PORT}:3000 \
|
||||||
-e NODE_ENV=production \
|
-e NODE_ENV=production \
|
||||||
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
|
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
|
||||||
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \
|
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://testing.dk0.dev} \
|
||||||
-e DATABASE_URL="${DATABASE_URL}" \
|
-e DATABASE_URL="${DATABASE_URL}" \
|
||||||
-e REDIS_URL="${REDIS_URL}" \
|
-e REDIS_URL="${REDIS_URL}" \
|
||||||
-e MY_EMAIL="${MY_EMAIL}" \
|
-e MY_EMAIL="${MY_EMAIL}" \
|
||||||
@@ -150,6 +150,9 @@ jobs:
|
|||||||
|
|
||||||
# Connect to proxy network
|
# Connect to proxy network
|
||||||
docker network connect proxy $CONTAINER_NAME 2>/dev/null || true
|
docker network connect proxy $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
# Traefik erreicht den Container über das Netz edge; die Route testing.dk0.dev
|
||||||
|
# liegt im homelab-Repo (apps/doxx/traefik/portfolio-testing.yml)
|
||||||
|
docker network connect edge $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
|
||||||
# Wait for health
|
# Wait for health
|
||||||
echo "⏳ Waiting for container to be healthy..."
|
echo "⏳ Waiting for container to be healthy..."
|
||||||
@@ -173,7 +176,7 @@ jobs:
|
|||||||
echo "✅ Dev deployment completed!"
|
echo "✅ Dev deployment completed!"
|
||||||
env:
|
env:
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
||||||
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
|
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://testing.dk0.dev' }}
|
||||||
DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public
|
DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public
|
||||||
REDIS_URL: redis://portfolio-redis:6379
|
REDIS_URL: redis://portfolio-redis:6379
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||||
|
|||||||
@@ -64,3 +64,6 @@ bones/*.bones.json
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Sicherungen aus scripts/directus-cleanup-redesign.mjs
|
||||||
|
directus-backup-*.json
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for ActivityFeed NaN handling
|
|
||||||
*
|
|
||||||
* This test suite validates that the ActivityFeed component correctly handles
|
|
||||||
* NaN and numeric values in gaming and custom activity data to prevent
|
|
||||||
* "Received NaN for the children attribute" React errors.
|
|
||||||
*/
|
|
||||||
describe('ActivityFeed NaN Handling', () => {
|
|
||||||
describe('Gaming activity rendering logic', () => {
|
|
||||||
// Helper function to simulate getSafeGamingText behavior
|
|
||||||
const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => {
|
|
||||||
if (typeof details === 'string' && details.trim().length > 0) return details;
|
|
||||||
if (typeof state === 'string' && state.trim().length > 0) return state;
|
|
||||||
if (typeof details === 'number' && !isNaN(details)) return String(details);
|
|
||||||
if (typeof state === 'number' && !isNaN(state)) return String(state);
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should safely handle NaN in gaming.details', () => {
|
|
||||||
const result = getSafeGamingText(NaN, 'Playing', 'Playing...');
|
|
||||||
expect(result).toBe('Playing'); // Should fall through NaN to state
|
|
||||||
expect(result).not.toBe(NaN);
|
|
||||||
expect(typeof result).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should safely handle NaN in both gaming.details and gaming.state', () => {
|
|
||||||
const result = getSafeGamingText(NaN, NaN, 'Playing...');
|
|
||||||
expect(result).toBe('Playing...'); // Should use fallback
|
|
||||||
expect(typeof result).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize string details over numeric state', () => {
|
|
||||||
const result = getSafeGamingText('Details text', 42, 'Playing...');
|
|
||||||
expect(result).toBe('Details text'); // String details takes precedence
|
|
||||||
expect(typeof result).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize string state over numeric details', () => {
|
|
||||||
const result = getSafeGamingText(42, 'State text', 'Playing...');
|
|
||||||
expect(result).toBe('State text'); // String state takes precedence over numeric details
|
|
||||||
expect(typeof result).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should convert valid numeric details to string', () => {
|
|
||||||
const result = getSafeGamingText(42, undefined, 'Playing...');
|
|
||||||
expect(result).toBe('42');
|
|
||||||
expect(typeof result).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty strings correctly', () => {
|
|
||||||
const result1 = getSafeGamingText('', 'Playing', 'Playing...');
|
|
||||||
expect(result1).toBe('Playing'); // Empty string should fall through to state
|
|
||||||
|
|
||||||
const result2 = getSafeGamingText(' ', 'Playing', 'Playing...');
|
|
||||||
expect(result2).toBe('Playing'); // Whitespace-only should fall through to state
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should convert gaming.name to string safely', () => {
|
|
||||||
const validName = String('Test Game');
|
|
||||||
expect(validName).toBe('Test Game');
|
|
||||||
expect(typeof validName).toBe('string');
|
|
||||||
|
|
||||||
// In the actual code, we use String(data.gaming.name || '')
|
|
||||||
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
|
||||||
const nanValue = NaN;
|
|
||||||
const nanName = String(nanValue || '');
|
|
||||||
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
|
||||||
expect(typeof nanName).toBe('string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Custom activities progress handling', () => {
|
|
||||||
it('should only render progress bar when progress is a valid number', () => {
|
|
||||||
const validProgress = 75;
|
|
||||||
const shouldRender = validProgress !== undefined &&
|
|
||||||
typeof validProgress === 'number' &&
|
|
||||||
!isNaN(validProgress);
|
|
||||||
expect(shouldRender).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render progress bar when progress is NaN', () => {
|
|
||||||
const invalidProgress = NaN;
|
|
||||||
const shouldRender = invalidProgress !== undefined &&
|
|
||||||
typeof invalidProgress === 'number' &&
|
|
||||||
!isNaN(invalidProgress);
|
|
||||||
expect(shouldRender).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render progress bar when progress is undefined', () => {
|
|
||||||
const undefinedProgress = undefined;
|
|
||||||
const shouldRender = undefinedProgress !== undefined &&
|
|
||||||
typeof undefinedProgress === 'number' &&
|
|
||||||
!isNaN(undefinedProgress);
|
|
||||||
expect(shouldRender).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Custom activities dynamic field rendering', () => {
|
|
||||||
it('should safely convert valid numeric values to string', () => {
|
|
||||||
const value = 42;
|
|
||||||
const shouldRender = typeof value === 'string' ||
|
|
||||||
(typeof value === 'number' && !isNaN(value));
|
|
||||||
|
|
||||||
expect(shouldRender).toBe(true);
|
|
||||||
|
|
||||||
if (shouldRender) {
|
|
||||||
const stringValue = String(value);
|
|
||||||
expect(stringValue).toBe('42');
|
|
||||||
expect(typeof stringValue).toBe('string');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render NaN values', () => {
|
|
||||||
const value = NaN;
|
|
||||||
const shouldRender = typeof value === 'string' ||
|
|
||||||
(typeof value === 'number' && !isNaN(value));
|
|
||||||
|
|
||||||
expect(shouldRender).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render valid string values', () => {
|
|
||||||
const value = 'Test String';
|
|
||||||
const shouldRender = typeof value === 'string' ||
|
|
||||||
(typeof value === 'number' && !isNaN(value));
|
|
||||||
|
|
||||||
expect(shouldRender).toBe(true);
|
|
||||||
|
|
||||||
if (shouldRender) {
|
|
||||||
const stringValue = String(value);
|
|
||||||
expect(stringValue).toBe('Test String');
|
|
||||||
expect(typeof stringValue).toBe('string');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render zero as a valid numeric value', () => {
|
|
||||||
const value = 0;
|
|
||||||
const shouldRender = typeof value === 'string' ||
|
|
||||||
(typeof value === 'number' && !isNaN(value));
|
|
||||||
|
|
||||||
expect(shouldRender).toBe(true);
|
|
||||||
|
|
||||||
if (shouldRender) {
|
|
||||||
const stringValue = String(value);
|
|
||||||
expect(stringValue).toBe('0');
|
|
||||||
expect(typeof stringValue).toBe('string');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
jest.mock("next-intl", () => ({
|
|
||||||
useTranslations: () => (key: string) => key,
|
|
||||||
useLocale: () => "en",
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("next/image", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("CurrentlyReading Component", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders loading skeleton when loading", () => {
|
|
||||||
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
|
|
||||||
render(<CurrentlyReadingComp />);
|
|
||||||
expect(screen.getAllByText).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a book when data is fetched", async () => {
|
|
||||||
const mockBooks = [
|
|
||||||
{
|
|
||||||
title: "Test Book",
|
|
||||||
authors: ["Test Author"],
|
|
||||||
image: "/test.jpg",
|
|
||||||
progress: 50,
|
|
||||||
startedAt: "2024-01-01"
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(global.fetch as jest.Mock).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ currentlyReading: mockBooks }),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CurrentlyReadingComp />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Test Book")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Author")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,58 +3,38 @@ import Hero from '@/app/components/Hero';
|
|||||||
|
|
||||||
// Mock next-intl/server
|
// Mock next-intl/server
|
||||||
jest.mock('next-intl/server', () => ({
|
jest.mock('next-intl/server', () => ({
|
||||||
getTranslations: () => Promise.resolve((key: string) => {
|
getTranslations: () =>
|
||||||
const messages: Record<string, string> = {
|
Promise.resolve((key: string, values?: { days?: number }) => {
|
||||||
badge: 'Student & Self-Hoster',
|
const messages: Record<string, string> = {
|
||||||
line1: 'Building',
|
kicker: 'Dennis Konkol — Osnabrück, Germany',
|
||||||
line2: 'Stuff.',
|
claim: 'I self-host half my life. The rest I code.',
|
||||||
description: 'Dennis is a student and passionate self-hoster.',
|
intro: "IT admin in Osnabrück and master's student.",
|
||||||
ctaWork: 'View My Work',
|
countdown: `${values?.days ?? 0} days to go.`,
|
||||||
ctaContact: 'Get in touch',
|
ctaWork: 'See projects',
|
||||||
};
|
ctaContact: 'Write me',
|
||||||
return messages[key] || key;
|
};
|
||||||
}),
|
return messages[key] || key;
|
||||||
}));
|
}),
|
||||||
|
|
||||||
// Mock next/image
|
|
||||||
interface ImageProps {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
fill?: boolean;
|
|
||||||
priority?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('next/image', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
data-fill={fill?.toString()}
|
|
||||||
data-priority={priority?.toString()}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Hero', () => {
|
describe('Hero', () => {
|
||||||
it('renders the hero section correctly', async () => {
|
it('renders the poster hero with claim, intro and calls to action', async () => {
|
||||||
const HeroResolved = await Hero({ locale: 'en' });
|
const HeroResolved = await Hero({ locale: 'en' });
|
||||||
render(HeroResolved);
|
render(HeroResolved);
|
||||||
|
|
||||||
// Check for the main headlines (defaults in Hero.tsx)
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('I self-host half my life. The rest I code.');
|
||||||
expect(screen.getByText('Building')).toBeInTheDocument();
|
expect(screen.getByText('Dennis Konkol — Osnabrück, Germany')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Stuff.')).toBeInTheDocument();
|
expect(screen.getByText(/IT admin in Osnabrück/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /See projects/ })).toHaveAttribute('href', '#projects');
|
||||||
|
expect(screen.getByRole('link', { name: 'Write me' })).toHaveAttribute('href', '#contact');
|
||||||
|
});
|
||||||
|
|
||||||
// Check for the description from our mock
|
it('renders the DK0 wordmark as decoration hidden from assistive tech', async () => {
|
||||||
expect(screen.getByText(/Dennis is a student/i)).toBeInTheDocument();
|
const HeroResolved = await Hero({ locale: 'en' });
|
||||||
|
const { container } = render(HeroResolved);
|
||||||
|
|
||||||
// Check for the image
|
const wordmark = container.querySelector('[aria-hidden="true"].font-display');
|
||||||
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
|
expect(wordmark).not.toBeNull();
|
||||||
|
expect(wordmark).toHaveTextContent('DK0');
|
||||||
// Check for CTA
|
|
||||||
expect(screen.getByText('View My Work')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,23 +83,17 @@ describe("i18n: message files", () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
|
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
|
||||||
About: ["home.about", "about"],
|
|
||||||
Projects: ["home.projects"],
|
|
||||||
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
|
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
|
||||||
Footer: ["footer"],
|
Footer: ["footer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
|
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
|
||||||
AboutClient: ["home.about", "about"],
|
|
||||||
ProjectsClient: ["home.projects"],
|
|
||||||
ContactClient: ["home.contact"],
|
ContactClient: ["home.contact"],
|
||||||
FooterClient: ["footer"],
|
FooterClient: ["footer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("i18n: ClientWrappers provide all requested namespaces", () => {
|
describe("i18n: ClientWrappers provide all requested namespaces", () => {
|
||||||
const componentToWrapper: Record<string, string> = {
|
const componentToWrapper: Record<string, string> = {
|
||||||
About: "AboutClient",
|
|
||||||
Projects: "ProjectsClient",
|
|
||||||
Contact: "ContactClient",
|
Contact: "ContactClient",
|
||||||
Footer: "FooterClient",
|
Footer: "FooterClient",
|
||||||
};
|
};
|
||||||
@@ -136,16 +130,12 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
|
|||||||
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"],
|
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"],
|
||||||
},
|
},
|
||||||
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
||||||
"home.hero": { Hero: ["badge", "line1", "line2", "description", "ctaWork", "ctaContact"] },
|
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
|
||||||
"home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
|
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "chatCard", "chatCardHint", "sticker"] },
|
||||||
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
|
"home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "chat.title", "chat.welcome", "chat.consent", "chat.allow", "chat.placeholder", "chat.send", "chat.error", "chat.thinking", "chat.note", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] },
|
||||||
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
|
|
||||||
"home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] },
|
|
||||||
"home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] },
|
|
||||||
"home.contact": { Contact: ["title", "subtitle"] },
|
"home.contact": { Contact: ["title", "subtitle"] },
|
||||||
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
|
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
|
||||||
"home.contact.info": { Contact: ["locationValue"] },
|
"home.contact.info": { Contact: ["locationValue"] },
|
||||||
about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] },
|
|
||||||
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
|
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
|
||||||
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
|
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
|
||||||
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
|
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
|
||||||
|
|||||||
+16
-88
@@ -1,16 +1,15 @@
|
|||||||
import Header from "../components/Header.server";
|
import Header from "../components/Header.server";
|
||||||
import Hero from "../components/Hero";
|
import Hero from "../components/Hero";
|
||||||
|
import TischSection from "../components/tisch/TischSection";
|
||||||
|
import WindowProvider from "../components/fenster/WindowProvider";
|
||||||
|
import { getWindowLabels } from "../components/fenster/getWindowLabels";
|
||||||
import ScrollFadeIn from "../components/ScrollFadeIn";
|
import ScrollFadeIn from "../components/ScrollFadeIn";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import {
|
import {
|
||||||
getAboutTranslations,
|
|
||||||
getProjectsTranslations,
|
|
||||||
getContactTranslations,
|
getContactTranslations,
|
||||||
getFooterTranslations,
|
getFooterTranslations,
|
||||||
} from "@/lib/translations-loader";
|
} from "@/lib/translations-loader";
|
||||||
import {
|
import {
|
||||||
AboutClient,
|
|
||||||
ProjectsClient,
|
|
||||||
ContactClient,
|
ContactClient,
|
||||||
FooterClient,
|
FooterClient,
|
||||||
} from "../components/ClientWrappers";
|
} from "../components/ClientWrappers";
|
||||||
@@ -21,11 +20,10 @@ interface HomePageServerProps {
|
|||||||
|
|
||||||
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||||
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
||||||
const [aboutT, projectsT, contactT, footerT] = await Promise.all([
|
const [contactT, footerT, windowLabels] = await Promise.all([
|
||||||
getAboutTranslations(locale),
|
|
||||||
getProjectsTranslations(locale),
|
|
||||||
getContactTranslations(locale),
|
getContactTranslations(locale),
|
||||||
getFooterTranslations(locale),
|
getFooterTranslations(locale),
|
||||||
|
getWindowLabels(locale),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,90 +85,20 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Header locale={locale} />
|
<WindowProvider labels={windowLabels}>
|
||||||
{/* Spacer to prevent navbar overlap */}
|
<Header locale={locale} />
|
||||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
<main className="relative">
|
||||||
<main className="relative">
|
<Hero locale={locale} />
|
||||||
<Hero locale={locale} />
|
<TischSection locale={locale} />
|
||||||
|
|
||||||
{/* Wavy Separator 1 - Hero to About */}
|
|
||||||
<div className="relative h-24 overflow-hidden">
|
|
||||||
<svg
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
viewBox="0 0 1440 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
|
|
||||||
fill="url(#gradient1)"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
|
|
||||||
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
|
|
||||||
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<ScrollFadeIn>
|
||||||
|
<ContactClient locale={locale} translations={contactT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
|
</main>
|
||||||
<ScrollFadeIn>
|
<ScrollFadeIn>
|
||||||
<AboutClient locale={locale} translations={aboutT} />
|
<FooterClient locale={locale} translations={footerT} />
|
||||||
</ScrollFadeIn>
|
</ScrollFadeIn>
|
||||||
|
</WindowProvider>
|
||||||
{/* Wavy Separator 2 - About to Projects */}
|
|
||||||
<div className="relative h-24 overflow-hidden">
|
|
||||||
<svg
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
viewBox="0 0 1440 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,64 C360,96 720,32 1080,64 C1200,96 1320,32 1440,64 L1440,0 L0,0 Z"
|
|
||||||
fill="url(#gradient2)"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stopColor="#A7F3D0" stopOpacity="0.3" />
|
|
||||||
<stop offset="50%" stopColor="#BFDBFE" stopOpacity="0.3" />
|
|
||||||
<stop offset="100%" stopColor="#DDD6FE" stopOpacity="0.3" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollFadeIn>
|
|
||||||
<ProjectsClient locale={locale} translations={projectsT} />
|
|
||||||
</ScrollFadeIn>
|
|
||||||
|
|
||||||
{/* Wavy Separator 3 - Projects to Contact */}
|
|
||||||
<div className="relative h-24 overflow-hidden">
|
|
||||||
<svg
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
viewBox="0 0 1440 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,32 C240,64 480,0 720,32 C960,64 1200,0 1440,32 L1440,120 L0,120 Z"
|
|
||||||
fill="url(#gradient3)"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stopColor="#FDE68A" stopOpacity="0.3" />
|
|
||||||
<stop offset="50%" stopColor="#FCA5A5" stopOpacity="0.3" />
|
|
||||||
<stop offset="100%" stopColor="#C4B5FD" stopOpacity="0.3" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollFadeIn>
|
|
||||||
<ContactClient locale={locale} translations={contactT} />
|
|
||||||
</ScrollFadeIn>
|
|
||||||
</main>
|
|
||||||
<ScrollFadeIn>
|
|
||||||
<FooterClient locale={locale} translations={footerT} />
|
|
||||||
</ScrollFadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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 dynamic from "next/dynamic";
|
|
||||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
|
||||||
import ReadBooks from "./ReadBooks";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { TechStackCategory, TechStackItem, Hobby } from "@/lib/directus";
|
|
||||||
import Link from "next/link";
|
|
||||||
import ActivityFeed from "./ActivityFeed";
|
|
||||||
import BentoChat from "./BentoChat";
|
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
const iconMap: Record<string, LucideIcon> = {
|
|
||||||
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
|
||||||
};
|
|
||||||
|
|
||||||
const About = () => {
|
|
||||||
const locale = useLocale();
|
|
||||||
const t = useTranslations("home.about");
|
|
||||||
const at = useTranslations("about");
|
|
||||||
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
|
||||||
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
|
||||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
|
||||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const [cmsRes, techRes, hobbiesRes, msgRes] = await Promise.all([
|
|
||||||
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
|
||||||
fetch(`/api/tech-stack?locale=${locale}`),
|
|
||||||
fetch(`/api/hobbies?locale=${locale}`),
|
|
||||||
fetch(`/api/messages?locale=${locale}`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const cmsData = await cmsRes.json();
|
|
||||||
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
|
|
||||||
|
|
||||||
const techData = await techRes.json();
|
|
||||||
if (techData?.techStack) setTechStack(techData.techStack);
|
|
||||||
|
|
||||||
const hobbiesData = await hobbiesRes.json();
|
|
||||||
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
|
|
||||||
|
|
||||||
const msgData = await msgRes.json();
|
|
||||||
if (msgData?.messages) setCmsMessages(msgData.messages);
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
|
||||||
|
|
||||||
{/* 1. Large Bio Text */}
|
|
||||||
<motion.div
|
|
||||||
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
|
||||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
|
||||||
</h2>
|
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-6 w-full" />
|
|
||||||
<Skeleton className="h-6 w-[95%]" />
|
|
||||||
<Skeleton className="h-6 w-[90%]" />
|
|
||||||
</div>
|
|
||||||
) : cmsHtml ? (
|
|
||||||
<RichTextClient html={cmsHtml} />
|
|
||||||
) : (
|
|
||||||
<p>{t("p1")} {t("p2")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 sm:pt-6 md:pt-8">
|
|
||||||
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
|
||||||
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 2. Activity / Status Box */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
|
||||||
>
|
|
||||||
<div className="relative z-10 h-full">
|
|
||||||
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
|
||||||
<Activity size={20} /> {at("status")}
|
|
||||||
</h3>
|
|
||||||
<ActivityFeed locale={locale} />
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 3. AI Chat Box */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-5 sm:mb-8">
|
|
||||||
<MessageSquare className="text-liquid-purple" size={20} />
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">{at("aiAssistant")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<BentoChat />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 4. Tech Stack */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="space-y-6">
|
|
||||||
<Skeleton className="h-3 w-20" />
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Skeleton className="h-8 w-24 rounded-xl" />
|
|
||||||
<Skeleton className="h-8 w-16 rounded-xl" />
|
|
||||||
<Skeleton className="h-8 w-20 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
techStack.map((cat) => (
|
|
||||||
<div key={cat.id} className="space-y-6">
|
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{cat.items?.map((item: TechStackItem) => (
|
|
||||||
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 5. Library */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.4 }}
|
|
||||||
className="md:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
|
||||||
>
|
|
||||||
<div className="relative z-10 flex flex-col h-full">
|
|
||||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
|
||||||
<BookOpen className="text-liquid-purple" size={24} /> {at("library")}
|
|
||||||
</h3>
|
|
||||||
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity">
|
|
||||||
{at("viewAll")} <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<CurrentlyReading />
|
|
||||||
<div className="mt-6 flex-1">
|
|
||||||
<ReadBooks />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 6. My Gear */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="md:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8"
|
|
||||||
>
|
|
||||||
<div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group">
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
|
||||||
<Cpu className="text-liquid-mint" size={24} /> {at("myGear")}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearMain")}</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearPC")}</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearServer")}</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearOS")}</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 7. Hobbies */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="md:col-span-12"
|
|
||||||
>
|
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
|
||||||
) : (
|
|
||||||
hobbies.map((hobby) => {
|
|
||||||
const Icon = iconMap[hobby.icon] || Lightbulb;
|
|
||||||
return (
|
|
||||||
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
|
|
||||||
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform duration-200 ease-out shrink-0 sm:w-5 sm:h-5" />
|
|
||||||
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
|
|
||||||
{hobby.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
|
|
||||||
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{at("curiosity")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default About;
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
interface CustomActivity {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusData {
|
|
||||||
music: { isPlaying: boolean; track: string; artist: string; albumArt: string; url: string; } | null;
|
|
||||||
gaming: { isPlaying: boolean; name: string; image: string | null; state?: string | number; details?: string | number; } | null;
|
|
||||||
coding: { isActive: boolean; project?: string; file?: string; language?: string; } | null;
|
|
||||||
customActivities?: Record<string, CustomActivity>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const techQuotes = {
|
|
||||||
de: [
|
|
||||||
{ content: "Informatik hat nicht mehr mit Computern zu tun als Astronomie mit Teleskopen.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
|
|
||||||
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
|
|
||||||
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
|
|
||||||
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
|
|
||||||
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
|
|
||||||
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
|
|
||||||
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
|
|
||||||
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
|
|
||||||
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
|
|
||||||
],
|
|
||||||
en: [
|
|
||||||
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
|
|
||||||
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
|
|
||||||
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
|
|
||||||
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
|
|
||||||
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
|
|
||||||
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
|
|
||||||
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
|
|
||||||
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
|
|
||||||
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
|
|
||||||
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string {
|
|
||||||
if (typeof details === 'string' && details.trim().length > 0) return details;
|
|
||||||
if (typeof state === 'string' && state.trim().length > 0) return state;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ActivityFeed({
|
|
||||||
onActivityChange,
|
|
||||||
locale = 'en'
|
|
||||||
}: {
|
|
||||||
onActivityChange?: (active: boolean) => void;
|
|
||||||
locale?: string;
|
|
||||||
}) {
|
|
||||||
const [data, setData] = useState<StatusData | null>(null);
|
|
||||||
const [hasActivity, setHasActivity] = useState(false);
|
|
||||||
const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const t = useTranslations("home.about.activity");
|
|
||||||
|
|
||||||
const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/n8n/status");
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const json = await res.json();
|
|
||||||
const activityData = Array.isArray(json) ? json[0] : json;
|
|
||||||
|
|
||||||
setData(activityData);
|
|
||||||
const isActive = Boolean(
|
|
||||||
activityData.coding?.isActive ||
|
|
||||||
activityData.gaming?.isPlaying ||
|
|
||||||
activityData.music?.isPlaying ||
|
|
||||||
Object.values(activityData.customActivities || {}).some((act) => Boolean((act as { enabled?: boolean })?.enabled))
|
|
||||||
);
|
|
||||||
setHasActivity(isActive);
|
|
||||||
onActivityChange?.(isActive);
|
|
||||||
} catch {
|
|
||||||
setHasActivity(false);
|
|
||||||
onActivityChange?.(false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
const statusInterval = setInterval(fetchData, 30000);
|
|
||||||
|
|
||||||
// Pick a random quote every 10 seconds (never the same one twice in a row)
|
|
||||||
const quoteInterval = setInterval(() => {
|
|
||||||
setQuoteIndex((prev) => {
|
|
||||||
let next;
|
|
||||||
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(statusInterval);
|
|
||||||
clearInterval(quoteInterval);
|
|
||||||
};
|
|
||||||
}, [onActivityChange, allQuotes.length]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-24 bg-stone-100 dark:bg-stone-800 rounded-2xl" />
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasActivity) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col justify-center space-y-6">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
|
|
||||||
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-[80px] sm:min-h-[120px] relative">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={quoteIndex}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
|
|
||||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-400 dark:text-stone-400 italic">
|
|
||||||
“{allQuotes[quoteIndex].content}”
|
|
||||||
</p>
|
|
||||||
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
|
|
||||||
— {allQuotes[quoteIndex].author}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-400 pt-4 border-t border-stone-100 dark:border-stone-800">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
|
|
||||||
{t("idleStatus")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{data?.coding?.isActive && (
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-5">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<Zap size={14} className="text-emerald-600 dark:text-emerald-400 animate-pulse" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
|
|
||||||
</div>
|
|
||||||
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
|
|
||||||
<p className="text-xs text-stone-500 dark:text-stone-400 truncate">{data.coding.file}</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data?.gaming?.isPlaying && (
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/10 dark:bg-indigo-500/20 border border-indigo-500/20 dark:border-indigo-400/30 rounded-2xl p-5">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<Gamepad2 size={14} className="text-indigo-600 dark:text-indigo-300" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-300">{t("gaming")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{data.gaming.image ? (
|
|
||||||
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative bg-indigo-500/10">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 rounded-xl shrink-0 bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
|
|
||||||
<Gamepad2 size={20} className="text-indigo-500 dark:text-indigo-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex flex-col justify-center">
|
|
||||||
<p className="font-bold text-indigo-700 dark:text-white text-base truncate">{data.gaming.name}</p>
|
|
||||||
<p className="text-xs text-indigo-600/70 dark:text-indigo-200/80 truncate">
|
|
||||||
{getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data?.music?.isPlaying && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="bg-[#1DB954]/5 dark:bg-[#1DB954]/10 border border-[#1DB954]/20 rounded-2xl p-5 relative overflow-hidden group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3 relative z-10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Disc3 size={14} className="text-[#1DB954] animate-spin-slow" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-[#1DB954]">{t("listening")}</span>
|
|
||||||
</div>
|
|
||||||
{/* Simple Animated Music Bars */}
|
|
||||||
<div className="flex items-end gap-[2px] h-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
animate={{ scaleY: [0.2, 1, 0.2] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.8,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: i * 0.2,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="w-[2px] h-full origin-bottom bg-[#1DB954] rounded-full"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={data.music.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex gap-4 relative z-10"
|
|
||||||
>
|
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-300">
|
|
||||||
<Image
|
|
||||||
src={data.music.albumArt}
|
|
||||||
alt="Album Art"
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105"
|
|
||||||
sizes="64px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex flex-col justify-center">
|
|
||||||
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p>
|
|
||||||
<p className="text-sm text-stone-600 dark:text-stone-400 truncate font-medium">{data.music.artist}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/* Subtle Spotify branding gradient */}
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1DB954]/5 blur-[60px] rounded-full -mr-16 -mt-16 pointer-events-none" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import BackgroundBlobs from "@/components/BackgroundBlobs";
|
|
||||||
|
|
||||||
export default function BackgroundBlobsClient() {
|
|
||||||
// Avoid SSR/webpack bailout issues from `next/dynamic({ ssr:false })`
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return <BackgroundBlobs />;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { Send, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
sender: "user" | "bot";
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredMessage {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
sender: "user" | "bot";
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BentoChat() {
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState<string>("default");
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const storedId = localStorage.getItem("chatSessionId");
|
|
||||||
if (storedId) setConversationId(storedId);
|
|
||||||
else {
|
|
||||||
const newId = crypto.randomUUID();
|
|
||||||
localStorage.setItem("chatSessionId", newId);
|
|
||||||
setConversationId(newId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedMsgs = localStorage.getItem("chatMessages");
|
|
||||||
if (storedMsgs) {
|
|
||||||
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
|
|
||||||
} else {
|
|
||||||
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length > 0) {
|
|
||||||
localStorage.setItem("chatMessages", JSON.stringify(messages));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerRef.current) {
|
|
||||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!inputValue.trim() || isLoading) return;
|
|
||||||
const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() };
|
|
||||||
setMessages(prev => [...prev, userMsg]);
|
|
||||||
setInputValue("");
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/n8n/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]);
|
|
||||||
} catch {
|
|
||||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(true);
|
|
||||||
setTimeout(() => setIsLoading(false), 500); // Small delay for feel
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full min-h-[300px]">
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
|
|
||||||
>
|
|
||||||
{messages.map((m) => (
|
|
||||||
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
|
|
||||||
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
|
|
||||||
{m.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
|
||||||
placeholder="Ask me..."
|
|
||||||
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner dark:text-white"
|
|
||||||
/>
|
|
||||||
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple active:scale-[0.97] transition-transform duration-150 ease-out">
|
|
||||||
<Send size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import {
|
|
||||||
MessageCircle,
|
|
||||||
X,
|
|
||||||
Send,
|
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
sender: "user" | "bot";
|
|
||||||
timestamp: Date;
|
|
||||||
isTyping?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatWidget() {
|
|
||||||
// Prevent hydration mismatch by only rendering after mount
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState<string>("default");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
// Generate or retrieve conversation ID only on client
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("chatSessionId");
|
|
||||||
if (stored) {
|
|
||||||
setConversationId(stored);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate UUID with fallback for browsers without crypto.randomUUID
|
|
||||||
let newId: string;
|
|
||||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
||||||
newId = crypto.randomUUID();
|
|
||||||
} else {
|
|
||||||
// Fallback UUID generation
|
|
||||||
newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem("chatSessionId", newId);
|
|
||||||
setConversationId(newId);
|
|
||||||
} catch (error) {
|
|
||||||
// localStorage might be disabled or full
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Failed to access localStorage for chat session:', error);
|
|
||||||
}
|
|
||||||
setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new messages arrive
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
// Focus input when chat opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Helper function to decode HTML entities
|
|
||||||
const decodeHtmlEntities = (text: string): string => {
|
|
||||||
if (!text || typeof text !== "string") return text;
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.innerHTML = text;
|
|
||||||
return textarea.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load messages from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("chatMessages");
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(stored);
|
|
||||||
setMessages(
|
|
||||||
parsed.map((m: Message) => ({
|
|
||||||
...m,
|
|
||||||
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
|
|
||||||
timestamp: new Date(m.timestamp),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error("Failed to parse chat history", e);
|
|
||||||
}
|
|
||||||
// Clear corrupted data
|
|
||||||
try {
|
|
||||||
localStorage.removeItem("chatMessages");
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
// Add welcome message
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add welcome message
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// localStorage might be disabled
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn("Failed to load chat history from localStorage:", error);
|
|
||||||
}
|
|
||||||
// Add welcome message anyway
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save messages to localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== "undefined" && messages.length > 0) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem("chatMessages", JSON.stringify(messages));
|
|
||||||
} catch (error) {
|
|
||||||
// localStorage might be full or disabled
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn("Failed to save chat messages to localStorage:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!inputValue.trim() || isLoading) return;
|
|
||||||
|
|
||||||
const userMessage: Message = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
text: inputValue.trim(),
|
|
||||||
sender: "user",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
|
||||||
setInputValue("");
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// Get last 10 messages for context
|
|
||||||
const history = messages.slice(-10).map((m) => ({
|
|
||||||
role: m.sender === "user" ? "user" : "assistant",
|
|
||||||
content: m.text,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/n8n/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: userMessage.text,
|
|
||||||
conversationId,
|
|
||||||
history,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => "Unknown error");
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error("Chat API error:", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Log response for debugging (only in development)
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.log("Chat API response:", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode HTML entities in the reply
|
|
||||||
let replyText =
|
|
||||||
data.reply || "Sorry, I couldn't process that. Please try again.";
|
|
||||||
|
|
||||||
// Decode HTML entities client-side (double safety)
|
|
||||||
replyText = decodeHtmlEntities(replyText);
|
|
||||||
|
|
||||||
const botMessage: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: replyText,
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, botMessage]);
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error("Chat error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: "Sorry, I'm having trouble connecting right now. Please try again later or use the contact form below.",
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, errorMessage]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearChat = () => {
|
|
||||||
// Reset session ID
|
|
||||||
const newId = crypto.randomUUID();
|
|
||||||
setConversationId(newId);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem("chatSessionId", newId);
|
|
||||||
localStorage.removeItem("chatMessages");
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
text: "Conversation restarted! Ask me anything about Dennis! 🚀",
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render until mounted to prevent hydration mismatch
|
|
||||||
if (!mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Chat Button */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{!isOpen && (
|
|
||||||
<motion.div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, transition: { duration: 0.25, ease: [0.23, 1, 0.32, 1] } }}
|
|
||||||
exit={{ scale: 0.9, opacity: 0, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white active:scale-[0.97] transition-[background-color,transform] duration-150 ease-out group cursor-pointer border border-white/60 ring-1 ring-white/30"
|
|
||||||
aria-label="Open chat"
|
|
||||||
>
|
|
||||||
<MessageCircle size={24} />
|
|
||||||
<span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" />
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-150 ease-out group-hover:delay-300 whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
|
|
||||||
Chat with AI
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Chat Window */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
data-chat-widget
|
|
||||||
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
|
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
|
|
||||||
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
|
|
||||||
className="origin-bottom-left fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-liquid-mint/50 via-liquid-lavender/40 to-liquid-rose/40 flex items-center justify-center ring-1 ring-white/50 shadow-sm">
|
|
||||||
<Sparkles size={18} className="text-stone-800" />
|
|
||||||
</div>
|
|
||||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
|
|
||||||
Assistant
|
|
||||||
</h3>
|
|
||||||
<p className="text-[11px] font-medium text-stone-500 truncate">
|
|
||||||
Powered by AI
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={clearChat}
|
|
||||||
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
|
|
||||||
title="Clear conversation"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
|
|
||||||
aria-label="Close chat"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
|
|
||||||
{messages.map((message) => (
|
|
||||||
<motion.div
|
|
||||||
key={message.id}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-[85%] rounded-2xl px-4 py-3 shadow-sm ${
|
|
||||||
message.sender === "user"
|
|
||||||
? "bg-stone-900 text-white"
|
|
||||||
: "bg-white/70 text-stone-900 border border-white/60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
|
|
||||||
message.sender === "user" ? "text-white/90 font-normal" : "text-stone-900 font-medium"
|
|
||||||
}`}>
|
|
||||||
{message.text}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={`text-[10px] mt-1.5 ${
|
|
||||||
message.sender === "user"
|
|
||||||
? "text-stone-400"
|
|
||||||
: "text-stone-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message.timestamp.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Typing Indicator */}
|
|
||||||
{isLoading && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="flex justify-start"
|
|
||||||
>
|
|
||||||
<div className="bg-[#f3f1e7] border border-[#e7e5e4] rounded-2xl px-4 py-3 shadow-sm">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<motion.div
|
|
||||||
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
|
|
||||||
animate={{ y: [0, -6, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.6,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
|
|
||||||
animate={{ y: [0, -6, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.6,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: 0.1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
|
|
||||||
animate={{ y: [0, -6, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.6,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: 0.2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<div className="p-4 bg-stone-50 border-t border-[#e7e5e4]">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
placeholder="Ask anything..."
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!inputValue.trim() || isLoading}
|
|
||||||
className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg active:scale-[0.97] transition-[background-color,box-shadow,transform] duration-150 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 shadow-md flex items-center justify-center aspect-square"
|
|
||||||
aria-label="Send message"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
|
|
||||||
{[
|
|
||||||
"Skills 🛠️",
|
|
||||||
"Projects 🚀",
|
|
||||||
"Contact 📧",
|
|
||||||
].map((suggestion, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => {
|
|
||||||
setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { MotionConfig } from "framer-motion";
|
import { MotionConfig } from "framer-motion";
|
||||||
import { ToastProvider } from "@/components/Toast";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { ConsentProvider } from "./ConsentProvider";
|
import { ConsentProvider } from "./ConsentProvider";
|
||||||
import { ThemeProvider } from "./ThemeProvider";
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
|
|
||||||
const BackgroundBlobs = dynamic(
|
|
||||||
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
|
|
||||||
{ ssr: false, loading: () => null }
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ClientProviders({
|
export default function ClientProviders({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -33,7 +19,7 @@ export default function ClientProviders({
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{/* Respect prefers-reduced-motion in every Framer Motion animation */}
|
{/* Respect prefers-reduced-motion in every Framer Motion animation */}
|
||||||
<MotionConfig reducedMotion="user">
|
<MotionConfig reducedMotion="user">
|
||||||
<GatedProviders mounted={mounted}>
|
<GatedProviders>
|
||||||
{children}
|
{children}
|
||||||
</GatedProviders>
|
</GatedProviders>
|
||||||
</MotionConfig>
|
</MotionConfig>
|
||||||
@@ -44,31 +30,10 @@ export default function ClientProviders({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GatedProviders({
|
function GatedProviders({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
mounted,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
mounted: boolean;
|
|
||||||
}) {
|
|
||||||
// Defer animated background blobs until after LCP
|
|
||||||
const [deferredReady, setDeferredReady] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return;
|
|
||||||
let id: ReturnType<typeof setTimeout> | 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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{deferredReady && <BackgroundBlobs />}
|
|
||||||
<div className="relative z-10">{children}</div>
|
<div className="relative z-10">{children}</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
|
// 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.
|
// 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 Contact = dynamic(() => import('./Contact'));
|
||||||
const Footer = dynamic(() => import('./Footer'));
|
const Footer = dynamic(() => import('./Footer'));
|
||||||
import type {
|
import type {
|
||||||
AboutTranslations,
|
|
||||||
ProjectsTranslations,
|
|
||||||
ContactTranslations,
|
ContactTranslations,
|
||||||
FooterTranslations,
|
FooterTranslations,
|
||||||
} from '@/types/translations';
|
} from '@/types/translations';
|
||||||
@@ -29,41 +25,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
|||||||
return locale.startsWith('de') ? 'de' : 'en';
|
return locale.startsWith('de') ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
|
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
|
||||||
const baseMessages = messageMap[normalLocale];
|
|
||||||
|
|
||||||
const messages = {
|
|
||||||
home: {
|
|
||||||
about: baseMessages.home.about
|
|
||||||
},
|
|
||||||
about: baseMessages.about
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
||||||
<About />
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
|
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
|
||||||
const baseMessages = messageMap[normalLocale];
|
|
||||||
|
|
||||||
const messages = {
|
|
||||||
home: {
|
|
||||||
projects: baseMessages.home.projects
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
||||||
<Projects />
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|||||||
+96
-189
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { Send, Github, Linkedin } from "lucide-react";
|
||||||
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@@ -80,6 +79,7 @@ const Contact = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setTouched({ name: true, email: true, subject: true, message: true });
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
@@ -152,196 +152,103 @@ const Contact = () => {
|
|||||||
validateForm();
|
validateForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fields: { name: keyof typeof formData; type: string; multiline?: boolean; autoComplete?: string }[] = [
|
||||||
|
{ name: "name", type: "text", autoComplete: "name" },
|
||||||
|
{ name: "email", type: "email", autoComplete: "email" },
|
||||||
|
{ name: "subject", type: "text" },
|
||||||
|
{ name: "message", type: "text", multiline: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pill =
|
||||||
|
"inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="contact" className="border-t-2 border-ink bg-signal px-4 pb-16 pt-14 text-ink sm:px-6 md:pb-24 md:pt-20 lg:px-10">
|
||||||
id="contact"
|
<div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
|
||||||
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500"
|
<div className="min-w-0">
|
||||||
>
|
<p className="font-mono text-[13px]">{tInfo("locationValue")}</p>
|
||||||
<div className="max-w-7xl mx-auto">
|
<h2 className="mt-3 font-display text-[clamp(3rem,9vw,7.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80]">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
{t("title")}
|
||||||
|
</h2>
|
||||||
{/* Header Card */}
|
{cmsHtml ? (
|
||||||
<motion.div
|
<RichTextClient html={cmsHtml} className="prose mt-6 max-w-[46ch] text-lg leading-relaxed text-ink [&_*]:text-inherit" />
|
||||||
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
) : (
|
||||||
|
<p className="mt-6 max-w-[46ch] text-lg leading-relaxed">{t("subtitle")}</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="mailto:contact@dk0.dev"
|
||||||
|
className="mt-8 block w-fit font-display text-[clamp(2rem,6vw,4.4rem)] font-extrabold leading-none tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_80] hover:underline hover:decoration-[3px] hover:underline-offset-[6px]"
|
||||||
>
|
>
|
||||||
<div className="max-w-3xl">
|
contact<span className="text-paper">@</span>dk0.dev
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
</a>
|
||||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
</h2>
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
|
||||||
{cmsHtml ? (
|
<Github size={16} aria-hidden="true" /> GitHub
|
||||||
<RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
</a>
|
||||||
) : (
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<Linkedin size={16} aria-hidden="true" /> LinkedIn
|
||||||
{t("subtitle")}
|
</a>
|
||||||
</p>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Info Side (Unified Connect Box) */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
|
||||||
>
|
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
|
||||||
{/* Email */}
|
|
||||||
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
|
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
|
|
||||||
<Mail size={16} />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
|
||||||
|
|
||||||
{/* GitHub */}
|
|
||||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
|
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
|
|
||||||
<Github size={16} />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
|
||||||
|
|
||||||
{/* LinkedIn */}
|
|
||||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
|
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
|
|
||||||
<Linkedin size={16} />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
|
|
||||||
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
|
||||||
<MapPin size={14} className="text-liquid-mint" />
|
|
||||||
<span className="font-bold">{tInfo("locationValue")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Form Side */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
||||||
>
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
|
|
||||||
{tForm("title")}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
||||||
{tForm("labels.name")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
|
||||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
|
|
||||||
placeholder={tForm("placeholders.name")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
||||||
{tForm("labels.email")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
|
||||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
|
|
||||||
placeholder={tForm("placeholders.email")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
||||||
{tForm("labels.subject")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="subject"
|
|
||||||
name="subject"
|
|
||||||
value={formData.subject}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
|
||||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
|
|
||||||
placeholder={tForm("placeholders.subject")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
||||||
{tForm("labels.message")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="message"
|
|
||||||
name="message"
|
|
||||||
value={formData.message}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
|
||||||
rows={5}
|
|
||||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium resize-none"
|
|
||||||
placeholder={tForm("placeholders.message")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
whileTap={{ scale: 0.97 }}
|
|
||||||
transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-shadow duration-200 ease-out disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send size={16} />
|
|
||||||
{tForm("send")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</form>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
className="grid gap-4 rounded-[18px] border-2 border-ink bg-paper p-5 shadow-[8px_8px_0_rgb(var(--plakat-ink))] md:p-7"
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-2xl font-extrabold tracking-[-0.03em]">{tForm("title")}</h3>
|
||||||
|
{fields.map((f) => {
|
||||||
|
const showError = touched[f.name] && errors[f.name];
|
||||||
|
const common = {
|
||||||
|
id: f.name,
|
||||||
|
name: f.name,
|
||||||
|
value: formData[f.name],
|
||||||
|
onChange: handleChange,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
required: true,
|
||||||
|
placeholder: tForm(`placeholders.${f.name}`),
|
||||||
|
"aria-invalid": showError ? true : undefined,
|
||||||
|
"aria-describedby": showError ? `${f.name}-error` : undefined,
|
||||||
|
className: `w-full rounded-xl border-2 bg-paper px-4 py-3 text-base text-ink placeholder:text-ink/40 transition-[border-color,box-shadow] duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-signal ${
|
||||||
|
showError ? "border-signal" : "border-ink"
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={f.name} className="grid gap-1.5">
|
||||||
|
<label htmlFor={f.name} className="font-mono text-[13px]">
|
||||||
|
{tForm(`labels.${f.name}`)}
|
||||||
|
</label>
|
||||||
|
{f.multiline ? (
|
||||||
|
<textarea {...common} rows={5} className={`${common.className} resize-none`} />
|
||||||
|
) : (
|
||||||
|
<input {...common} type={f.type} autoComplete={f.autoComplete} />
|
||||||
|
)}
|
||||||
|
{showError && (
|
||||||
|
<p id={`${f.name}-error`} className="text-sm font-medium text-signal" role="alert">
|
||||||
|
{errors[f.name]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="mt-1 inline-flex items-center justify-center gap-2.5 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
|
||||||
|
{tForm("sending")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
{tForm("send")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { BookOpen } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
|
||||||
|
|
||||||
interface CurrentlyReading {
|
|
||||||
title: string;
|
|
||||||
authors: string[];
|
|
||||||
image: string | null;
|
|
||||||
progress: number;
|
|
||||||
startedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CurrentlyReading = () => {
|
|
||||||
const t = useTranslations("home.about.currentlyReading");
|
|
||||||
const [books, setBooks] = useState<CurrentlyReading[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Nur einmal beim Laden der Seite
|
|
||||||
const fetchCurrentlyReading = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/n8n/hardcover/currently-reading", {
|
|
||||||
cache: "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
// Handle both single book and array of books
|
|
||||||
if (data.currentlyReading) {
|
|
||||||
const booksArray = Array.isArray(data.currentlyReading)
|
|
||||||
? data.currentlyReading
|
|
||||||
: [data.currentlyReading];
|
|
||||||
setBooks(booksArray);
|
|
||||||
} else {
|
|
||||||
setBooks([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error);
|
|
||||||
}
|
|
||||||
setBooks([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCurrentlyReading();
|
|
||||||
}, []); // Leeres Array = nur einmal beim Mount
|
|
||||||
|
|
||||||
// Zeige nichts wenn kein Buch gelesen wird
|
|
||||||
if (books.length === 0 && !loading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Skeleton className="h-5 w-5 rounded" />
|
|
||||||
<Skeleton className="h-5 w-40" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-6 space-y-3">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/2" />
|
|
||||||
<Skeleton className="h-2 w-full rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
|
||||||
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
|
||||||
{t("title")} {books.length > 1 && `(${books.length})`}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Books List */}
|
|
||||||
{books.map((book, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={`${book.title}-${index}`}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{ duration: 0.5, delay: index * 0.06, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-colors duration-300"
|
|
||||||
>
|
|
||||||
{/* Background Blob Animation */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
opacity: [0.3, 0.5, 0.3],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 8,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
delay: index * 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
|
|
||||||
{/* Book Cover */}
|
|
||||||
{book.image && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
|
||||||
<Image
|
|
||||||
src={book.image}
|
|
||||||
alt={book.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 640px) 96px, 112px"
|
|
||||||
/>
|
|
||||||
{/* Glossy Overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Book Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Title */}
|
|
||||||
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
|
|
||||||
{book.title}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{/* Authors */}
|
|
||||||
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
|
|
||||||
{book.authors.join(", ")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
|
|
||||||
<span>{t("progress")}</span>
|
|
||||||
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
|
|
||||||
{/* scaleX instead of width: stays on the compositor, no layout per frame */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: (isNaN(book.progress) ? 0 : book.progress) / 100 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="origin-left w-full absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CurrentlyReading;
|
|
||||||
+41
-65
@@ -3,80 +3,56 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { useConsent } from "./ConsentProvider";
|
||||||
|
|
||||||
|
const pill =
|
||||||
|
"inline-flex items-center rounded-full px-4 py-2 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-paper)/0.5)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-paper hover:text-ink active:scale-[0.97]";
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
|
const { resetConsent } = useConsent();
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-stone-50 dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
|
<footer className="bg-ink px-4 pb-10 pt-12 text-paper sm:px-6 lg:px-10">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="font-display text-[clamp(4rem,14vw,9rem)] font-extrabold leading-[0.8] tracking-[-0.05em]"
|
||||||
|
aria-label="dk0"
|
||||||
|
>
|
||||||
|
dk<span className="text-signal">0</span>
|
||||||
|
</Link>
|
||||||
|
<a href="#top" className={pill}>
|
||||||
|
{t("backToTop")} <span aria-hidden="true" className="ml-1.5">↑</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
|
<nav className="mt-8 flex flex-wrap gap-2 border-t-2 border-paper/20 pt-6" aria-label={t("legalNotice")}>
|
||||||
{/* Copyright & Info */}
|
<Link href={`/${locale}/legal-notice`} className={pill}>
|
||||||
<div className="md:col-span-4 space-y-6">
|
{t("legalNotice")}
|
||||||
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
</Link>
|
||||||
dk
|
<Link href={`/${locale}/privacy-policy`} className={pill}>
|
||||||
</div>
|
{t("privacyPolicy")}
|
||||||
<div className="space-y-2">
|
</Link>
|
||||||
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
|
<button type="button" onClick={resetConsent} className={pill} title={t("privacySettingsTitle")}>
|
||||||
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">© {year} {t("madeIn")}.</p>
|
{t("privacySettings")}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
<div className="mt-8 grid gap-1.5 font-mono text-xs text-paper/70">
|
||||||
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
<p>
|
||||||
<div className="space-y-4">
|
© {year} Dennis Konkol · {t("role")} · {t("madeIn")}
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
<p>{t("builtWith")}</p>
|
||||||
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
<p>{t("aiDisclaimer")}</p>
|
||||||
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
|
||||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back to Top */}
|
|
||||||
<div className="md:col-span-4 flex justify-start md:justify-end">
|
|
||||||
<button
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>{t("backToTop")}</span>
|
|
||||||
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 group-active:scale-[0.97] transition-[background-color,color,transform] duration-200 ease-out shadow-sm">
|
|
||||||
<ArrowUp size={20} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Bar */}
|
|
||||||
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
|
|
||||||
{t("builtWith")} Next.js, Directus & Passion.
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-stone-500 dark:text-stone-500 tracking-wide">
|
|
||||||
{t("aiDisclaimer")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">{t("systemsOnline")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: t("home"), href: `/${locale}` },
|
{ name: t("home"), href: `/${locale}` },
|
||||||
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
{ name: t("about"), href: isHome ? "#projects" : `/${locale}#projects` },
|
||||||
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
|
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
|
||||||
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
|
|||||||
+113
-145
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import type { NavTranslations } from "@/types/translations";
|
import type { NavTranslations } from "@/types/translations";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import OpenWindowLink from "./fenster/OpenWindowLink";
|
||||||
|
|
||||||
const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
|
const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
@@ -65,7 +66,8 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: translations.home, href: `/${locale}` },
|
{ name: translations.home, href: `/${locale}` },
|
||||||
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
|
// „Über mich“ öffnet auf der Startseite das Fenster; ohne JS bzw. auf Unterseiten führt es zum Tisch mit dem Foto
|
||||||
|
{ name: translations.about, href: isHome ? "#projects" : `/${locale}#projects`, window: isHome ? ({ kind: "about" } as const) : undefined },
|
||||||
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
|
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
|
||||||
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
@@ -86,174 +88,140 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
const enHref = `/en${pathWithoutLocale}${query}`;
|
const enHref = `/en${pathWithoutLocale}${query}`;
|
||||||
const deHref = `/de${pathWithoutLocale}${query}`;
|
const deHref = `/de${pathWithoutLocale}${query}`;
|
||||||
|
|
||||||
|
const de = locale === "de";
|
||||||
|
|
||||||
|
// Über dem roten Plakat bleibt der Kopf transparent, sonst wird er zum Papierstreifen
|
||||||
|
const solid = scrolled || !isHome;
|
||||||
|
const pill =
|
||||||
|
"rounded-full border-[1.5px] border-ink px-4 py-2 text-sm font-medium transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]";
|
||||||
|
const langPill = (active: boolean) =>
|
||||||
|
`rounded-full px-3 py-2 font-mono text-xs transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
|
||||||
|
active ? "bg-ink text-paper" : "hover:bg-ink/10"
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
|
<header
|
||||||
<div
|
className={`fixed inset-x-0 top-0 z-50 border-b-2 transition-[background-color,border-color] duration-200 ease-out ${
|
||||||
className={`pointer-events-auto transition-[max-width] duration-500 ease-in-out ${
|
solid ? "border-ink bg-paper/90 backdrop-blur-md" : "border-transparent bg-transparent"
|
||||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<div className="flex items-center justify-between gap-4 px-4 py-3 text-ink sm:px-6 lg:px-10">
|
||||||
<div
|
<Link
|
||||||
className={`backdrop-blur-xl transition-[padding,background-color,box-shadow,border-color] duration-500 ease-in-out flex justify-between items-center ${
|
href={`/${locale}`}
|
||||||
scrolled
|
className="font-display text-2xl font-extrabold tracking-[-0.03em] transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||||
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
|
aria-label={de ? "dk0, zur Startseite" : "dk0, home"}
|
||||||
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2 active:scale-[0.97] transition-transform duration-150 ease-out">
|
dk<span className={`transition-colors duration-200 ${solid ? "text-signal" : "text-paper"}`}>0</span>
|
||||||
<Link
|
</Link>
|
||||||
href={`/${locale}`}
|
|
||||||
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
||||||
>
|
{navItems.slice(1).map((item) =>
|
||||||
dk<span className="text-red-500">0</span>
|
item.window ? (
|
||||||
|
<OpenWindowLink key={item.name} href={item.href} request={item.window} className={pill}>
|
||||||
|
{item.name}
|
||||||
|
</OpenWindowLink>
|
||||||
|
) : (
|
||||||
|
<Link key={item.name} href={item.href} className={pill}>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<div className="ml-2 flex items-center gap-1 border-l-2 border-ink/20 pl-3">
|
||||||
|
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
|
||||||
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={enHref} className={langPill(locale === "en")} aria-current={locale === "en" ? "true" : undefined}>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<button
|
||||||
{navItems.map((item) => (
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
<div key={item.name} className="active:scale-[0.97] transition-transform duration-150 ease-out">
|
className={`${pill} inline-flex items-center gap-2 md:hidden`}
|
||||||
<Link
|
aria-label={de ? "Menü öffnen" : "Open menu"}
|
||||||
href={item.href}
|
aria-expanded={isOpen}
|
||||||
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
|
>
|
||||||
>
|
<MenuIcon size={18} /> {de ? "Menü" : "Menu"}
|
||||||
{item.name}
|
</button>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Language Switcher */}
|
|
||||||
<div className="flex items-center space-x-2 ml-4 pl-4 border-l border-stone-300">
|
|
||||||
<Link
|
|
||||||
href={enHref}
|
|
||||||
className={`px-2 py-1 text-sm font-medium rounded transition-colors ${
|
|
||||||
locale === "en"
|
|
||||||
? "bg-stone-900 text-white"
|
|
||||||
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={deHref}
|
|
||||||
className={`px-2 py-1 text-sm font-medium rounded transition-colors ${
|
|
||||||
locale === "de"
|
|
||||||
? "bg-stone-900 text-white"
|
|
||||||
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97]"
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
{isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile menu overlay — always mounted so it can fade out, not just vanish */}
|
{/* Abdunklung hinter dem Menü — bleibt gemountet, damit sie ausblenden kann */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden transition-opacity ${
|
className={`fixed inset-0 z-40 bg-ink/40 transition-opacity md:hidden ${
|
||||||
isOpen ? "opacity-100 duration-300 ease-out" : "opacity-0 pointer-events-none duration-200 ease-out"
|
isOpen ? "opacity-100 duration-300 ease-out" : "pointer-events-none opacity-0 duration-200 ease-out"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile menu panel — drawer curve in, quicker exit */}
|
{/* Menü als Drawer: Drawer-Kurve rein, schneller raus */}
|
||||||
<div
|
<div
|
||||||
inert={!isOpen}
|
inert={!isOpen}
|
||||||
className={`fixed right-0 top-0 h-full w-80 max-w-[85vw] bg-white shadow-2xl z-50 md:hidden overflow-y-auto overscroll-contain transition-[transform,visibility] ${
|
role="dialog"
|
||||||
isOpen
|
aria-label={de ? "Menü" : "Menu"}
|
||||||
? "translate-x-0 visible duration-500 ease-drawer"
|
className={`fixed right-0 top-0 z-50 flex h-full w-80 max-w-[85vw] flex-col overflow-y-auto overscroll-contain border-l-2 border-ink bg-paper text-ink transition-[transform,visibility] md:hidden ${
|
||||||
: "translate-x-full invisible duration-200 ease-out"
|
isOpen ? "visible translate-x-0 duration-500 ease-drawer" : "invisible translate-x-full duration-200 ease-out"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<Link
|
||||||
<Link
|
href={`/${locale}`}
|
||||||
href={`/${locale}`}
|
className="font-display text-2xl font-extrabold tracking-[-0.03em]"
|
||||||
className="text-2xl font-black text-stone-900"
|
onClick={() => setIsOpen(false)}
|
||||||
onClick={() => setIsOpen(false)}
|
>
|
||||||
>
|
dk<span className="text-signal">0</span>
|
||||||
dk<span className="text-red-500">0</span>
|
</Link>
|
||||||
</Link>
|
<button onClick={() => setIsOpen(false)} className={`${pill} inline-flex items-center`} aria-label={de ? "Menü schließen" : "Close menu"}>
|
||||||
<button
|
<XIcon size={18} />
|
||||||
onClick={() => setIsOpen(false)}
|
</button>
|
||||||
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97]"
|
</div>
|
||||||
aria-label="Close menu"
|
|
||||||
>
|
|
||||||
<XIcon size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="space-y-2">
|
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => {
|
||||||
<Link
|
const cls =
|
||||||
key={item.name}
|
"border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2";
|
||||||
href={item.href}
|
return item.window ? (
|
||||||
onClick={() => setIsOpen(false)}
|
<OpenWindowLink key={item.name} href={item.href} request={item.window} onClick={() => setIsOpen(false)} className={cls}>
|
||||||
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 active:bg-stone-100 rounded-lg font-medium transition-colors"
|
{item.name}
|
||||||
>
|
</OpenWindowLink>
|
||||||
|
) : (
|
||||||
|
<Link key={item.name} href={item.href} onClick={() => setIsOpen(false)} className={cls}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
</nav>
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Language Switcher Mobile */}
|
<div className="flex items-center gap-1 px-5 py-5">
|
||||||
<div className="flex items-center gap-2 mt-6 pt-6 border-t border-stone-200">
|
<Link href={deHref} onClick={() => setIsOpen(false)} className={langPill(locale === "de")}>
|
||||||
<Link
|
DE
|
||||||
href={enHref}
|
</Link>
|
||||||
onClick={() => setIsOpen(false)}
|
<Link href={enHref} onClick={() => setIsOpen(false)} className={langPill(locale === "en")}>
|
||||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-colors ${
|
EN
|
||||||
locale === "en"
|
</Link>
|
||||||
? "bg-stone-900 text-white"
|
<ThemeToggle />
|
||||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
</div>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={deHref}
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-colors ${
|
|
||||||
locale === "de"
|
|
||||||
? "bg-stone-900 text-white"
|
|
||||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-stone-200">
|
<div className="mt-auto flex gap-2 px-5 pb-8">
|
||||||
<div className="flex justify-center space-x-6">
|
{socialLinks.map((link) => {
|
||||||
{socialLinks.map((link) => {
|
const Icon = link.icon;
|
||||||
const Icon = link.icon;
|
return (
|
||||||
return (
|
<a
|
||||||
<a
|
key={link.label}
|
||||||
key={link.label}
|
href={link.href}
|
||||||
href={link.href}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className={`${pill} inline-flex items-center`}
|
||||||
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97]"
|
aria-label={link.label}
|
||||||
aria-label={link.label}
|
>
|
||||||
>
|
<Icon size={18} />
|
||||||
<Icon size={20} />
|
</a>
|
||||||
</a>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+47
-51
@@ -1,73 +1,69 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Image from "next/image";
|
import KineticWordmark from "./KineticWordmark";
|
||||||
|
import OpenWindowLink from "./fenster/OpenWindowLink";
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abgabe der Masterarbeit, Grundlage für den Countdown im Intro
|
||||||
|
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
|
||||||
|
|
||||||
|
const MARQUEE = ["Self-Hosting", "Next.js", "SwiftUI", "Flutter", "Docker", "Microsoft 365", "Godot", "Home Assistant"];
|
||||||
|
|
||||||
export default async function Hero({ locale }: HeroProps) {
|
export default async function Hero({ locale }: HeroProps) {
|
||||||
const t = await getTranslations({ locale, namespace: "home.hero" });
|
const t = await getTranslations({ locale, namespace: "home.hero" });
|
||||||
|
const daysLeft = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
<>
|
||||||
{/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
|
<section id="top" className="relative overflow-hidden bg-signal px-4 pt-24 text-ink sm:px-6 md:pt-28 lg:px-10">
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
<p className="font-mono text-[13px] leading-snug">{t("kicker")}</p>
|
||||||
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
|
|
||||||
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20 pb-10 sm:pb-16">
|
<div className="mt-4 sm:mt-8">
|
||||||
<div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
|
<KineticWordmark />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Left: Text Content */}
|
<div className="mt-3 grid gap-6 border-t-2 border-ink pb-8 pt-6 md:grid-cols-[1.3fr_1fr] md:items-end md:pb-10">
|
||||||
<div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
<h1 className="text-balance font-display text-[clamp(2rem,4.4vw,4rem)] font-extrabold leading-[0.96] tracking-[-0.035em] [font-variation-settings:'wdth'_85]">
|
||||||
<div className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm animate-fade-in">
|
<span className="sr-only">Dennis Konkol: </span>
|
||||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
{t("claim")}
|
||||||
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{t("badge")}</span>
|
</h1>
|
||||||
</div>
|
<div>
|
||||||
|
<p className="max-w-[42ch] text-[17px] leading-relaxed">
|
||||||
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
|
{t("intro")} {daysLeft > 0 && t("countdown", { days: daysLeft })}
|
||||||
<span className="block">
|
|
||||||
{t("line1")}
|
|
||||||
</span>
|
|
||||||
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4">
|
|
||||||
{t("line2")}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto xl:mx-0 font-light leading-relaxed tracking-tight">
|
|
||||||
{t("description")}
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center xl:justify-start pt-2 sm:pt-4 animate-fade-in [animation-delay:150ms]">
|
<a
|
||||||
<a href="#projects" className="group relative overflow-hidden px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] active:scale-[0.97] transition-transform duration-150 ease-out shadow-2xl">
|
href="#projects"
|
||||||
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-700 ease-in-out" />
|
className="inline-flex items-center gap-2 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||||
{t("ctaWork")}
|
>
|
||||||
|
{t("ctaWork")} <span aria-hidden="true">↓</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white transition-colors">
|
<OpenWindowLink
|
||||||
|
href="#contact"
|
||||||
|
request={{ kind: "contact" }}
|
||||||
|
className="inline-flex items-center rounded-full px-5 py-3.5 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]"
|
||||||
|
>
|
||||||
{t("ctaContact")}
|
{t("ctaContact")}
|
||||||
</a>
|
</OpenWindowLink>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: The Photo */}
|
|
||||||
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 xl:w-[500px] xl:h-[500px] shrink-0 mt-4 sm:mt-8 xl:mt-0 animate-fade-in [animation-delay:100ms] [animation-duration:900ms]">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
|
||||||
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]" style={{ willChange: "transform" }}>
|
|
||||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority fetchPriority="high" sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1280px) 320px, 500px" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
|
||||||
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="overflow-hidden bg-ink text-paper" aria-hidden="true">
|
||||||
|
<div className="flex w-max animate-marquee gap-9 py-[18px] font-display text-[clamp(1.1rem,2.4vw,1.7rem)] font-bold leading-none">
|
||||||
|
{[...MARQUEE, ...MARQUEE].map((item, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-9 whitespace-nowrap">
|
||||||
|
{item}
|
||||||
|
<svg viewBox="0 0 24 24" className="h-[0.7em] w-[0.7em] fill-signal">
|
||||||
|
<path d="M12 1.5l2.6 7.9 7.9 2.6-7.9 2.6-2.6 7.9-2.6-7.9-7.9-2.6 7.9-2.6z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4" aria-hidden="true">
|
|
||||||
<div className="w-px h-16 origin-top animate-scroll-hint bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LETTERS = ["D", "K", "0"] as const;
|
||||||
|
|
||||||
|
// Ruhezustand und Spannweite der variablen Achsen von Bricolage Grotesque
|
||||||
|
const REST = { wght: 800, wdth: 100 };
|
||||||
|
const NEAR = { wght: 220, wdth: 75 };
|
||||||
|
const REACH_PX = 520;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das große „DK0“ im Plakat. Die Buchstaben werden dünner und schmaler, je näher
|
||||||
|
* der Zeiger kommt, geglättet über eine kritisch gedämpfte Feder.
|
||||||
|
* Ohne Maus oder bei reduzierter Bewegung bleibt es statisch. Das Markup ist
|
||||||
|
* vollständig server-gerendert, JS fügt nur die Reaktion hinzu.
|
||||||
|
*/
|
||||||
|
export default function KineticWordmark() {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
const area = root?.closest("section");
|
||||||
|
if (!root || !area) return;
|
||||||
|
|
||||||
|
const fine = window.matchMedia?.("(hover: hover) and (pointer: fine)")?.matches ?? false;
|
||||||
|
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||||
|
if (!fine || reduce) return;
|
||||||
|
|
||||||
|
const state = LETTERS.map(() => ({ w: REST.wght, d: REST.wdth, vw: 0, vd: 0, tw: REST.wght, td: REST.wdth }));
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
let moving = false;
|
||||||
|
state.forEach((s, i) => {
|
||||||
|
s.vw = (s.vw + (s.tw - s.w) * 0.08) * 0.78;
|
||||||
|
s.w += s.vw;
|
||||||
|
s.vd = (s.vd + (s.td - s.d) * 0.08) * 0.78;
|
||||||
|
s.d += s.vd;
|
||||||
|
if (Math.abs(s.vw) + Math.abs(s.vd) > 0.05) moving = true;
|
||||||
|
const el = letterRefs.current[i];
|
||||||
|
if (el) el.style.fontVariationSettings = `"wght" ${s.w.toFixed(1)}, "wdth" ${s.d.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
raf = moving ? requestAnimationFrame(loop) : 0;
|
||||||
|
};
|
||||||
|
const kick = () => {
|
||||||
|
if (!raf) raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
letterRefs.current.forEach((el, i) => {
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const dist = Math.hypot(e.clientX - (r.left + r.width / 2), e.clientY - (r.top + r.height / 2));
|
||||||
|
const f = Math.min(1, dist / REACH_PX);
|
||||||
|
state[i].tw = NEAR.wght + f * (REST.wght - NEAR.wght);
|
||||||
|
state[i].td = NEAR.wdth + f * (REST.wdth - NEAR.wdth);
|
||||||
|
});
|
||||||
|
kick();
|
||||||
|
};
|
||||||
|
const onLeave = () => {
|
||||||
|
state.forEach((s) => {
|
||||||
|
s.tw = REST.wght;
|
||||||
|
s.td = REST.wdth;
|
||||||
|
});
|
||||||
|
kick();
|
||||||
|
};
|
||||||
|
|
||||||
|
area.addEventListener("pointermove", onMove);
|
||||||
|
area.addEventListener("pointerleave", onLeave);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
area.removeEventListener("pointermove", onMove);
|
||||||
|
area.removeEventListener("pointerleave", onLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex select-none items-end justify-between font-display text-[clamp(8.5rem,33vw,31rem)] leading-[0.78] tracking-[-0.05em]"
|
||||||
|
>
|
||||||
|
{LETTERS.map((letter, i) => (
|
||||||
|
<span
|
||||||
|
key={letter}
|
||||||
|
ref={(el) => {
|
||||||
|
letterRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
className={`inline-block animate-wordmark-up ${letter === "0" ? "text-paper" : ""}`}
|
||||||
|
style={{ fontVariationSettings: `"wght" ${REST.wght}, "wdth" ${REST.wdth}`, animationDelay: `${i * 80}ms` }}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { ArrowUpRight } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
|
||||||
import ProjectThumbnail from "./ProjectThumbnail";
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
tags: string[];
|
|
||||||
featured: boolean;
|
|
||||||
category: string;
|
|
||||||
date: string;
|
|
||||||
github?: string;
|
|
||||||
live?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Projects = () => {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const locale = useLocale();
|
|
||||||
const t = useTranslations("home.projects");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadProjects = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setProjects(data.projects || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadProjects();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
|
||||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
|
||||||
{t("subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity text-xs uppercase tracking-widest">
|
|
||||||
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-0.5 group-hover:translate-x-0.5 transition-transform duration-200 ease-out" size={14} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="space-y-4">
|
|
||||||
<Skeleton className="aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl" />
|
|
||||||
<Skeleton className="h-6 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
|
||||||
{t("noProjects")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
projects.map((project) => (
|
|
||||||
<motion.div
|
|
||||||
key={project.id}
|
|
||||||
className="group relative"
|
|
||||||
>
|
|
||||||
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
|
||||||
{/* Image Card */}
|
|
||||||
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
|
|
||||||
{project.imageUrl ? (
|
|
||||||
<Image
|
|
||||||
src={project.imageUrl}
|
|
||||||
alt={project.title}
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ProjectThumbnail
|
|
||||||
title={project.title}
|
|
||||||
category={project.category}
|
|
||||||
tags={project.tags}
|
|
||||||
slug={project.slug}
|
|
||||||
size="card"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Overlay on Hover */}
|
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text Content */}
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex gap-2">
|
|
||||||
{project.tags.slice(0, 2).map(tag => (
|
|
||||||
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
)))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Projects;
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
|
||||||
|
|
||||||
interface BookReview {
|
|
||||||
id: string;
|
|
||||||
hardcover_id?: string;
|
|
||||||
book_title: string;
|
|
||||||
book_author: string;
|
|
||||||
book_image?: string;
|
|
||||||
rating?: number | null;
|
|
||||||
review?: string | null;
|
|
||||||
finished_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StarRating = ({ rating }: { rating: number }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<Star
|
|
||||||
key={star}
|
|
||||||
size={14}
|
|
||||||
className={
|
|
||||||
star <= rating
|
|
||||||
? "text-amber-500 fill-amber-500"
|
|
||||||
: "text-stone-300 dark:text-stone-600"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stripHtml = (html: string) => {
|
|
||||||
if (typeof window === 'undefined') return html; // Fallback for SSR
|
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
||||||
return doc.body.textContent || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReadBooks = () => {
|
|
||||||
const locale = useLocale();
|
|
||||||
const t = useTranslations("home.about.readBooks");
|
|
||||||
const [reviews, setReviews] = useState<BookReview[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const INITIAL_SHOW = 3;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchReviews = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
|
|
||||||
{ cache: "default" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.bookReviews) {
|
|
||||||
setReviews(data.bookReviews);
|
|
||||||
} else {
|
|
||||||
setReviews([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
if (process.env.NODE_ENV === "development") console.error("Error fetching book reviews:", error);
|
|
||||||
}
|
|
||||||
setReviews([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchReviews();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
if (reviews.length === 0 && !loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
|
|
||||||
<BookCheck size={16} className="shrink-0" />
|
|
||||||
<span>{t("empty")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
|
|
||||||
const hasMore = reviews.length > INITIAL_SHOW;
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Skeleton className="h-5 w-5 rounded" />
|
|
||||||
<Skeleton className="h-5 w-40" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-5 space-y-3">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/2" />
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
|
||||||
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
|
||||||
{t("title")} ({reviews.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Reviews */}
|
|
||||||
{visibleReviews.map((review, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={review.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
delay: index * 0.06,
|
|
||||||
ease: [0.23, 1, 0.32, 1],
|
|
||||||
}}
|
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-colors duration-300"
|
|
||||||
>
|
|
||||||
{/* Background Blob */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.15, 1],
|
|
||||||
opacity: [0.3, 0.45, 0.3],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 8,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
delay: index * 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
|
|
||||||
{/* Book Cover */}
|
|
||||||
{review.book_image && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
|
||||||
<Image
|
|
||||||
src={review.book_image}
|
|
||||||
alt={review.book_title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 640px) 80px, 96px"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Book Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
|
|
||||||
{review.book_title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
|
|
||||||
{review.book_author}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Rating (Optional) */}
|
|
||||||
{review.rating && review.rating > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<StarRating rating={review.rating} />
|
|
||||||
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
|
|
||||||
{review.rating}/5
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Review Text (Optional) */}
|
|
||||||
{review.review && (
|
|
||||||
<div className="relative">
|
|
||||||
<p className={`text-sm text-stone-700 dark:text-stone-300 leading-relaxed italic ${!expandedReviews.has(review.id) ? 'line-clamp-3' : ''}`}>
|
|
||||||
“{stripHtml(review.review)}”
|
|
||||||
</p>
|
|
||||||
{stripHtml(review.review).length > 100 && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setExpandedReviews(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(review.id)) next.delete(review.id);
|
|
||||||
else next.add(review.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="text-xs text-liquid-sky hover:text-liquid-mint dark:text-liquid-sky dark:hover:text-liquid-mint font-medium mt-1 transition-colors"
|
|
||||||
>
|
|
||||||
{expandedReviews.has(review.id) ? t("collapseReview") : t("readMore")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Finished Date */}
|
|
||||||
{review.finished_at && (
|
|
||||||
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
|
|
||||||
{t("finishedAt")}{" "}
|
|
||||||
{new Date(review.finished_at).toLocaleDateString(
|
|
||||||
locale === "de" ? "de-DE" : "en-US",
|
|
||||||
{ year: "numeric", month: "short" }
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Show More / Show Less */}
|
|
||||||
{hasMore && (
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
|
|
||||||
>
|
|
||||||
{expanded ? (
|
|
||||||
<>
|
|
||||||
{t("showLess")} <ChevronUp size={16} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ReadBooks;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const ToastProvider = React.lazy(() =>
|
|
||||||
import("@/components/Toast").then((mod) => ({
|
|
||||||
default: mod.ToastProvider,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const BackgroundBlobs = React.lazy(() =>
|
|
||||||
import("@/components/BackgroundBlobs")
|
|
||||||
);
|
|
||||||
|
|
||||||
const ChatWidget = React.lazy(() => import("./ChatWidget"));
|
|
||||||
|
|
||||||
export default function RootProviders({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return <div className="relative z-10">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
|
|
||||||
<ToastProvider>
|
|
||||||
<BackgroundBlobs />
|
|
||||||
<div className="relative z-10">{children}</div>
|
|
||||||
<ChatWidget />
|
|
||||||
</ToastProvider>
|
|
||||||
</React.Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// 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 (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="fixed inset-0 -z-10 overflow-hidden pointer-events-none"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute -top-[10%] -left-[15%] w-[55%] h-[65%] rounded-full opacity-60"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(ellipse at 50% 50%, #b01040 0%, #e167c5 40%, transparent 70%)",
|
|
||||||
filter: "blur(80px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute top-[25%] -right-[10%] w-[50%] h-[60%] rounded-full opacity-55"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(ellipse at 50% 50%, #e167c5 0%, #b01040 40%, transparent 70%)",
|
|
||||||
filter: "blur(80px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute -bottom-[15%] left-[5%] w-[50%] h-[60%] rounded-full opacity-50"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(ellipse at 50% 50%, #b04a17 0%, #e167c5 40%, transparent 70%)",
|
|
||||||
filter: "blur(80px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ export function ThemeToggle() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return <div className="w-9 h-9" />;
|
return <div className="ml-1 h-9 w-9" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycle = () => {
|
const cycle = () => {
|
||||||
@@ -24,16 +24,16 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={cycle}
|
onClick={cycle}
|
||||||
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 border border-stone-200 dark:border-stone-700 shadow-sm active:scale-[0.97] transition-[background-color,transform] duration-150 ease-out"
|
className="ml-1 rounded-full border-[1.5px] border-ink p-2 text-ink transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]"
|
||||||
aria-label={`Current theme: ${theme}. Click to switch.`}
|
aria-label={`Current theme: ${theme}. Click to switch.`}
|
||||||
title={theme === "system" ? "System" : theme === "dark" ? "Dark" : "Light"}
|
title={theme === "system" ? "System" : theme === "dark" ? "Dark" : "Light"}
|
||||||
>
|
>
|
||||||
{theme === "system" ? (
|
{theme === "system" ? (
|
||||||
<Monitor size={18} className="text-stone-500 dark:text-stone-300" />
|
<Monitor size={16} />
|
||||||
) : theme === "dark" ? (
|
) : theme === "dark" ? (
|
||||||
<Sun size={18} className="text-amber-400" />
|
<Sun size={16} />
|
||||||
) : (
|
) : (
|
||||||
<Moon size={18} className="text-stone-600" />
|
<Moon size={16} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useOpenWindow } from "./WindowProvider";
|
||||||
|
import type { WindowRequest } from "./types";
|
||||||
|
|
||||||
|
interface OpenWindowLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
href: string;
|
||||||
|
request: WindowRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein normaler Link (funktioniert ohne JavaScript), der mit JavaScript stattdessen
|
||||||
|
* ein Fenster bzw. Sheet öffnet.
|
||||||
|
*/
|
||||||
|
export default function OpenWindowLink({ request, onClick, ...props }: OpenWindowLinkProps) {
|
||||||
|
const openWindow = useOpenWindow();
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
aria-haspopup={openWindow ? "dialog" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
onClick?.(e);
|
||||||
|
if (!openWindow || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openWindow(request, e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
"inline-flex items-center gap-2 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]";
|
||||||
|
const ghost =
|
||||||
|
"inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]";
|
||||||
|
|
||||||
|
/** Titelzeile eines Fensters, auch für die Ablage-Leiste. */
|
||||||
|
export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
|
||||||
|
switch (req.kind) {
|
||||||
|
case "project":
|
||||||
|
return req.project.year ? `${req.project.title} · ${req.project.year}` : req.project.title;
|
||||||
|
case "now":
|
||||||
|
return labels.now.title;
|
||||||
|
case "server":
|
||||||
|
return labels.server.title;
|
||||||
|
case "read":
|
||||||
|
return labels.read.title;
|
||||||
|
case "about":
|
||||||
|
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))", chat: "#1d1b3d" }[req.kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowContentProps {
|
||||||
|
req: WindowRequest;
|
||||||
|
labels: WindowLabels;
|
||||||
|
/** Schließt das Fenster, z. B. wenn ein Link auf der Seite weiterführt */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WindowContent({ req, labels, onClose }: WindowContentProps) {
|
||||||
|
switch (req.kind) {
|
||||||
|
case "project": {
|
||||||
|
const p = req.project;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="-mx-5 -mt-5 flex min-h-[150px] flex-col justify-between border-b-2 border-ink p-5"
|
||||||
|
style={{ background: p.color, color: p.textColor }}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[12.5px]">{p.tags.slice(0, 4).join(" · ")}</span>
|
||||||
|
<span className="font-display text-[clamp(2.4rem,6vw,3.4rem)] font-extrabold leading-[0.86] tracking-[-0.045em] [font-variation-settings:'wdth'_78]">
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] leading-relaxed">{p.description}</p>
|
||||||
|
<dl className="border-t-2 border-ink">
|
||||||
|
{p.year && <Row label={labels.year}>{p.year}</Row>}
|
||||||
|
{p.tags.length > 0 && <Row label={labels.stack}>{p.tags.join(", ")}</Row>}
|
||||||
|
</dl>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href={p.href} className={btn}>
|
||||||
|
{labels.projectPage} <span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
{p.liveUrl && (
|
||||||
|
<a href={p.liveUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
|
||||||
|
{labels.live} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{p.githubUrl && (
|
||||||
|
<a href={p.githubUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
|
||||||
|
{labels.source} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "now":
|
||||||
|
return <NowBody labels={labels} />;
|
||||||
|
|
||||||
|
case "server":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="font-display text-[1.8rem] font-extrabold leading-none tracking-[-0.03em]">{labels.server.headline}</p>
|
||||||
|
<div>
|
||||||
|
{labels.server.rows.map((r) => (
|
||||||
|
<div key={r.name} className="grid grid-cols-[10px_1fr] items-center gap-3 border-b border-ink/20 py-2.5 text-[15px]">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{r.name}
|
||||||
|
<span className="block font-mono text-xs opacity-75">{r.detail}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[12.5px] leading-snug">{labels.server.note}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "read":
|
||||||
|
return <ReadBody labels={labels} />;
|
||||||
|
|
||||||
|
case "about":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{labels.about.paragraphs.map((text) => (
|
||||||
|
<p key={text} className="text-[15px] leading-relaxed">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<dl className="border-t-2 border-ink">
|
||||||
|
{labels.about.facts.map((f) => (
|
||||||
|
<Row key={f.label} label={f.label}>
|
||||||
|
{f.value}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "contact":
|
||||||
|
return <ContactBody labels={labels} onClose={onClose} />;
|
||||||
|
|
||||||
|
case "chat":
|
||||||
|
return <ChatBody labels={labels} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-3 border-b border-ink/20 py-2.5">
|
||||||
|
<dt className="font-mono text-[12.5px] leading-6">{label}</dt>
|
||||||
|
<dd className="m-0 text-[15px]">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgabe der Masterarbeit; auf dem Client gerechnet, damit das Fenster nie einen veralteten Stand zeigt
|
||||||
|
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
|
||||||
|
|
||||||
|
function NowBody({ labels }: { labels: WindowLabels }) {
|
||||||
|
const days = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-[5rem] font-extrabold leading-[0.82] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
|
||||||
|
{days}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-semibold">{labels.now.days}</p>
|
||||||
|
</div>
|
||||||
|
<dl className="border-t-2 border-ink">
|
||||||
|
{labels.now.rows.map((r) => (
|
||||||
|
<Row key={r.label} label={r.label}>
|
||||||
|
{r.text}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadBody({ labels }: { labels: WindowLabels }) {
|
||||||
|
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadCurrentBook().then((b) => alive && setBook(b));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{book === undefined ? (
|
||||||
|
<div className="grid grid-cols-[72px_1fr] gap-4" aria-hidden="true">
|
||||||
|
<div className="h-[104px] animate-pulse rounded bg-paper-2" />
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<div className="h-6 w-4/5 animate-pulse rounded bg-paper-2" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-paper-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : book ? (
|
||||||
|
<div className="grid grid-cols-[72px_1fr] items-center gap-4">
|
||||||
|
<div className="relative h-[104px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover">
|
||||||
|
{book.image && <Image src={book.image} alt="" fill sizes="72px" className="object-cover" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-xl font-bold leading-tight">{book.title}</div>
|
||||||
|
<div className="font-mono text-xs">{book.authors.join(", ")}</div>
|
||||||
|
<div className="mt-2.5 font-mono text-xs">
|
||||||
|
{progress} % {labels.read.progress}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 h-1 overflow-hidden rounded bg-paper-2">
|
||||||
|
<div className="h-full origin-left bg-ink" style={{ transform: `scaleX(${progress / 100})` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>{labels.read.empty}</p>
|
||||||
|
)}
|
||||||
|
<Link href={labels.read.libraryHref} className={`${ghost} w-fit`}>
|
||||||
|
{labels.read.library} <span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactBody({ labels, onClose }: { labels: WindowLabels; onClose: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(EMAIL);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1600);
|
||||||
|
} catch {
|
||||||
|
window.location.href = `mailto:${EMAIL}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="font-display text-[clamp(1.8rem,5vw,2.4rem)] font-extrabold leading-[0.95] tracking-[-0.04em] [overflow-wrap:anywhere]">
|
||||||
|
contact<span className="text-signal">@</span>dk0.dev
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] leading-relaxed">{labels.contact.body}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={copy} className={btn} aria-live="polite">
|
||||||
|
{copied ? labels.contact.copied : labels.contact.copy}
|
||||||
|
</button>
|
||||||
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={ghost}>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={ghost}>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a href="#contact" className={ghost} onClick={onClose}>
|
||||||
|
{labels.contact.form}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||||
|
import { AnimatePresence, motion, useDragControls } from "framer-motion";
|
||||||
|
import { Drawer } from "vaul";
|
||||||
|
import WindowContent, { windowAccent, windowTitle } from "./WindowContent";
|
||||||
|
import { windowId, type WindowLabels, type WindowRequest } from "./types";
|
||||||
|
|
||||||
|
type OpenWindow = (req: WindowRequest, trigger?: HTMLElement | null) => void;
|
||||||
|
|
||||||
|
const WindowContext = createContext<OpenWindow | null>(null);
|
||||||
|
|
||||||
|
/** Öffnet ein Fenster (Desktop) oder Sheet (Handy). Ohne Provider passiert nichts. */
|
||||||
|
export function useOpenWindow(): OpenWindow | null {
|
||||||
|
return useContext(WindowContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WinState {
|
||||||
|
id: string;
|
||||||
|
req: WindowRequest;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
minimized: boolean;
|
||||||
|
/** Punkt, von dem aus das Fenster aufgeht (relativ zum Fenster) */
|
||||||
|
origin: { x: number; y: number };
|
||||||
|
trigger: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIN_WIDTH = 440;
|
||||||
|
|
||||||
|
export default function WindowProvider({ labels, children }: { labels: WindowLabels; children: React.ReactNode }) {
|
||||||
|
const [wins, setWins] = useState<WinState[]>([]);
|
||||||
|
const [sheet, setSheet] = useState<{ req: WindowRequest; trigger: HTMLElement | null } | null>(null);
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const zTop = useRef(10);
|
||||||
|
const opened = useRef(0);
|
||||||
|
|
||||||
|
const open = useCallback<OpenWindow>((req, trigger = null) => {
|
||||||
|
const desktop = window.matchMedia?.("(min-width: 768px)")?.matches ?? true;
|
||||||
|
if (!desktop) {
|
||||||
|
setSheet({ req, trigger });
|
||||||
|
setSheetOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = windowId(req);
|
||||||
|
setWins((prev) => {
|
||||||
|
const existing = prev.find((w) => w.id === id);
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((w) => (w.id === id ? { ...w, minimized: false, z: ++zTop.current } : w));
|
||||||
|
}
|
||||||
|
const W = window.innerWidth;
|
||||||
|
const H = window.innerHeight;
|
||||||
|
const width = Math.min(WIN_WIDTH, W - 32);
|
||||||
|
const step = opened.current++ % 5;
|
||||||
|
const x = Math.max(16, Math.min(W - width - 16, W / 2 - width / 2 + step * 34 - 60));
|
||||||
|
const y = Math.max(76, Math.min(H - 420, H * 0.16 + step * 30));
|
||||||
|
const r = trigger?.getBoundingClientRect();
|
||||||
|
const origin = r ? { x: r.left + r.width / 2 - x, y: r.top + r.height / 2 - y } : { x: width / 2, y: 0 };
|
||||||
|
return [...prev, { id, req, x, y, z: ++zTop.current, minimized: false, origin, trigger }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback((id: string) => {
|
||||||
|
setWins((prev) => {
|
||||||
|
const w = prev.find((x) => x.id === id);
|
||||||
|
// Fokus zurück dorthin, wo das Fenster herkam
|
||||||
|
if (w?.trigger?.isConnected) requestAnimationFrame(() => w.trigger?.focus({ preventScroll: true }));
|
||||||
|
return prev.filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const minimize = useCallback((id: string) => {
|
||||||
|
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focusWin = useCallback((id: string) => {
|
||||||
|
setWins((prev) => {
|
||||||
|
const top = prev.reduce((a, b) => (b.z > a.z ? b : a), prev[0]);
|
||||||
|
// Schon vorn und sichtbar: nichts zu tun (ein abgelegtes Fenster muss trotzdem zurück)
|
||||||
|
if (top?.id === id && !top.minimized) return prev;
|
||||||
|
return prev.map((w) => (w.id === id ? { ...w, z: ++zTop.current, minimized: false } : w));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Escape schließt das oberste offene Fenster
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
const visible = wins.filter((w) => !w.minimized);
|
||||||
|
if (!visible.length) return;
|
||||||
|
const top = visible.reduce((a, b) => (b.z > a.z ? b : a));
|
||||||
|
close(top.id);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [wins, close]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WindowContext.Provider value={open}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Desktop: Fenster schweben über der Seite, die Seite bleibt bedienbar */}
|
||||||
|
<div ref={layerRef} className="pointer-events-none fixed inset-0 z-[60]" aria-live="off">
|
||||||
|
<AnimatePresence>
|
||||||
|
{wins
|
||||||
|
.filter((w) => !w.minimized)
|
||||||
|
.map((w) => (
|
||||||
|
<Window
|
||||||
|
key={w.id}
|
||||||
|
win={w}
|
||||||
|
labels={labels}
|
||||||
|
layerRef={layerRef}
|
||||||
|
onClose={() => close(w.id)}
|
||||||
|
onMinimize={() => minimize(w.id)}
|
||||||
|
onFocus={() => focusWin(w.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{wins.length > 0 && (
|
||||||
|
<motion.nav
|
||||||
|
aria-label={labels.openWindows}
|
||||||
|
className="fixed bottom-[18px] left-1/2 z-[61] hidden max-w-[calc(100vw-32px)] gap-1.5 overflow-x-auto rounded-full bg-ink p-1.5 md:flex"
|
||||||
|
style={{ x: "-50%" }}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
>
|
||||||
|
{wins.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => focusWin(w.id)}
|
||||||
|
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full px-3.5 py-2 text-[13px] font-medium transition-[background-color,transform] duration-150 ease-out hover:bg-paper/15 active:scale-[0.97] ${
|
||||||
|
w.minimized ? "text-paper/60" : "text-paper"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full border border-paper/40" style={{ background: windowAccent(w.req) }} aria-hidden="true" />
|
||||||
|
{windowTitle(w.req, labels).split(" · ")[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.nav>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Handy: ein Sheet von unten, nach unten ziehen schließt */}
|
||||||
|
<Drawer.Root
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
setSheetOpen(o);
|
||||||
|
if (!o && sheet?.trigger?.isConnected) requestAnimationFrame(() => sheet.trigger?.focus({ preventScroll: true }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 z-[70] bg-ink/40" />
|
||||||
|
<Drawer.Content
|
||||||
|
className="fixed inset-x-0 bottom-0 z-[71] flex max-h-[88svh] flex-col rounded-t-[22px] border-2 border-b-0 border-ink bg-paper text-ink outline-none"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<Drawer.Handle className="!mt-2.5 !h-[5px] !w-11 !bg-ink/30" />
|
||||||
|
{sheet && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 border-b-2 border-ink px-5 pb-3 pt-2">
|
||||||
|
<Drawer.Title className="flex-1 truncate font-mono text-[13px] font-normal">{windowTitle(sheet.req, labels)}</Drawer.Title>
|
||||||
|
<Drawer.Close
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-full border-2 border-ink text-[15px] font-semibold transition-transform duration-150 ease-out active:scale-95"
|
||||||
|
aria-label={labels.close}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Drawer.Close>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5 pb-[calc(20px+env(safe-area-inset-bottom,0px))]">
|
||||||
|
<WindowContent req={sheet.req} labels={labels} onClose={() => setSheetOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
</WindowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowProps {
|
||||||
|
win: WinState;
|
||||||
|
labels: WindowLabels;
|
||||||
|
layerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onClose: () => void;
|
||||||
|
onMinimize: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Window({ win, labels, layerRef, onClose, onMinimize, onFocus }: WindowProps) {
|
||||||
|
const controls = useDragControls();
|
||||||
|
const [lifted, setLifted] = useState(false);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeRef.current?.focus({ preventScroll: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
drag
|
||||||
|
dragListener={false}
|
||||||
|
dragControls={controls}
|
||||||
|
dragConstraints={layerRef}
|
||||||
|
dragElastic={0.15}
|
||||||
|
dragTransition={{ power: 0.3, timeConstant: 300, bounceStiffness: 260, bounceDamping: 24 }}
|
||||||
|
onDragStart={() => setLifted(true)}
|
||||||
|
onDragEnd={() => setLifted(false)}
|
||||||
|
onPointerDown={onFocus}
|
||||||
|
initial={{ opacity: 0, scale: 0.86 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className={`pointer-events-auto fixed flex max-h-[calc(100vh-120px)] w-[min(440px,calc(100vw-32px))] flex-col rounded-[18px] border-2 border-ink bg-paper text-ink transition-shadow duration-200 ease-out ${
|
||||||
|
lifted ? "shadow-[16px_18px_0_rgb(var(--plakat-ink))]" : "shadow-[8px_8px_0_rgb(var(--plakat-ink))]"
|
||||||
|
}`}
|
||||||
|
style={{ left: win.x, top: win.y, zIndex: win.z, transformOrigin: `${win.origin.x}px ${win.origin.y}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex cursor-grab touch-none select-none items-center gap-2.5 border-b-2 border-ink py-2.5 pl-4 pr-2.5 active:cursor-grabbing"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
controls.start(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span id={titleId} className="flex-1 truncate font-mono text-[13px]">
|
||||||
|
{windowTitle(win.req, labels)}
|
||||||
|
</span>
|
||||||
|
<HeadButton label={labels.minimize} onClick={onMinimize}>
|
||||||
|
–
|
||||||
|
</HeadButton>
|
||||||
|
<HeadButton label={labels.close} onClick={onClose} buttonRef={closeRef}>
|
||||||
|
×
|
||||||
|
</HeadButton>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5">
|
||||||
|
<WindowContent req={win.req} labels={labels} onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
buttonRef,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
buttonRef?: RefObject<HTMLButtonElement | null>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className="grid h-[30px] w-[30px] place-items-center rounded-full border-2 border-ink bg-paper text-[15px] font-semibold transition-[background-color,transform] duration-150 ease-out hover:bg-signal active:scale-[0.94]"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { WindowLabels } from "./types";
|
||||||
|
|
||||||
|
/** Alle Texte für Fenster und Sheets, serverseitig geladen und als Props weitergereicht. */
|
||||||
|
export async function getWindowLabels(locale: string): Promise<WindowLabels> {
|
||||||
|
const t = await getTranslations({ locale, namespace: "home.windows" });
|
||||||
|
const tisch = await getTranslations({ locale, namespace: "home.tisch" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: t("close"),
|
||||||
|
minimize: t("minimize"),
|
||||||
|
openWindows: t("openWindows"),
|
||||||
|
projectPage: t("projectPage"),
|
||||||
|
source: t("source"),
|
||||||
|
live: t("live"),
|
||||||
|
stack: t("stack"),
|
||||||
|
year: t("year"),
|
||||||
|
now: {
|
||||||
|
title: t("now.title"),
|
||||||
|
days: t("now.days"),
|
||||||
|
rows: (["study", "work", "side"] as const).map((k) => ({ label: t(`now.${k}.label`), text: t(`now.${k}.text`) })),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
title: t("server.title"),
|
||||||
|
headline: t("server.headline"),
|
||||||
|
rows: (["ingress", "mail", "home"] as const).map((k) => ({
|
||||||
|
name: tisch(`servers.${k}.name`),
|
||||||
|
detail: tisch(`servers.${k}.detail`),
|
||||||
|
})),
|
||||||
|
note: tisch("serverNote"),
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
title: t("read.title"),
|
||||||
|
progress: t("read.progress"),
|
||||||
|
empty: t("read.empty"),
|
||||||
|
library: t("read.library"),
|
||||||
|
libraryHref: `/${locale}/books`,
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: t("about.title"),
|
||||||
|
paragraphs: [t("about.p1"), t("about.p2")],
|
||||||
|
facts: (["laptop", "pc", "study", "languages"] as const).map((k) => ({
|
||||||
|
label: t(`about.facts.${k}.label`),
|
||||||
|
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"),
|
||||||
|
copy: t("contact.copy"),
|
||||||
|
copied: t("contact.copied"),
|
||||||
|
form: t("contact.form"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TischProject } from "../tisch/types";
|
||||||
|
|
||||||
|
// Was ein Fenster (Desktop) bzw. Sheet (Handy) zeigen kann
|
||||||
|
export type WindowRequest =
|
||||||
|
| { kind: "project"; project: TischProject }
|
||||||
|
| { kind: "now" }
|
||||||
|
| { kind: "server" }
|
||||||
|
| { kind: "read" }
|
||||||
|
| { kind: "about" }
|
||||||
|
| { kind: "contact" }
|
||||||
|
| { kind: "chat" };
|
||||||
|
|
||||||
|
export function windowId(req: WindowRequest): string {
|
||||||
|
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowLabels {
|
||||||
|
close: string;
|
||||||
|
minimize: string;
|
||||||
|
openWindows: string;
|
||||||
|
projectPage: string;
|
||||||
|
source: string;
|
||||||
|
live: string;
|
||||||
|
stack: string;
|
||||||
|
year: string;
|
||||||
|
now: {
|
||||||
|
title: string;
|
||||||
|
days: string;
|
||||||
|
rows: { label: string; text: string }[];
|
||||||
|
};
|
||||||
|
server: {
|
||||||
|
title: string;
|
||||||
|
headline: string;
|
||||||
|
rows: { name: string; detail: string }[];
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
read: {
|
||||||
|
title: string;
|
||||||
|
progress: string;
|
||||||
|
empty: string;
|
||||||
|
library: string;
|
||||||
|
libraryHref: string;
|
||||||
|
};
|
||||||
|
about: {
|
||||||
|
title: string;
|
||||||
|
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;
|
||||||
|
copy: string;
|
||||||
|
copied: string;
|
||||||
|
form: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||||
|
import {
|
||||||
|
animate,
|
||||||
|
motion,
|
||||||
|
useMotionValue,
|
||||||
|
useReducedMotion,
|
||||||
|
useSpring,
|
||||||
|
useTransform,
|
||||||
|
useVelocity,
|
||||||
|
type MotionValue,
|
||||||
|
} from "framer-motion";
|
||||||
|
import ObjectFace from "./ObjectFace";
|
||||||
|
import { useOpenWindow } from "../fenster/WindowProvider";
|
||||||
|
import { labelFor, windowFor } from "./windowFor";
|
||||||
|
import { BOARD_SIZE, BOARD_SPOTS, type TischLabels, type TischObject } from "./types";
|
||||||
|
|
||||||
|
interface Handle {
|
||||||
|
el: HTMLDivElement;
|
||||||
|
x: MotionValue<number>;
|
||||||
|
y: MotionValue<number>;
|
||||||
|
base: MotionValue<number>;
|
||||||
|
spotRotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardProps {
|
||||||
|
objects: TischObject[];
|
||||||
|
labels: TischLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_HEIGHT = 1080;
|
||||||
|
const SETTLE = { type: "spring", bounce: 0, duration: 0.6 } as const;
|
||||||
|
|
||||||
|
/** Desktop: freier Tisch. Objekte lassen sich werfen, federn am Rand zurück und lassen sich aufräumen. */
|
||||||
|
export default function Board({ objects, labels }: BoardProps) {
|
||||||
|
const tableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handles = useRef(new Map<string, Handle>());
|
||||||
|
const [order, setOrder] = useState(() => objects.map((o) => o.id));
|
||||||
|
|
||||||
|
const bringToFront = useCallback((id: string) => {
|
||||||
|
setOrder((prev) => (prev[prev.length - 1] === id ? prev : [...prev.filter((x) => x !== id), id]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tidy = () => {
|
||||||
|
const table = tableRef.current;
|
||||||
|
if (!table) return;
|
||||||
|
const width = table.clientWidth;
|
||||||
|
const gap = 22;
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 28;
|
||||||
|
let rowH = 0;
|
||||||
|
objects.forEach((obj, i) => {
|
||||||
|
const h = handles.current.get(obj.id);
|
||||||
|
if (!h) return;
|
||||||
|
const { w, h: oh } = BOARD_SIZE[obj.kind];
|
||||||
|
if (cx + w > width && cx > 0) {
|
||||||
|
cx = 0;
|
||||||
|
cy += rowH + gap;
|
||||||
|
rowH = 0;
|
||||||
|
}
|
||||||
|
const tx = cx - h.el.offsetLeft;
|
||||||
|
const ty = cy - h.el.offsetTop;
|
||||||
|
cx += w + gap;
|
||||||
|
rowH = Math.max(rowH, oh);
|
||||||
|
const delay = i * 0.03;
|
||||||
|
animate(h.x, tx, { ...SETTLE, delay });
|
||||||
|
animate(h.y, ty, { ...SETTLE, delay });
|
||||||
|
animate(h.base, 0, { ...SETTLE, delay });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mess = () => {
|
||||||
|
objects.forEach((obj, i) => {
|
||||||
|
const h = handles.current.get(obj.id);
|
||||||
|
if (!h) return;
|
||||||
|
const delay = i * 0.03;
|
||||||
|
animate(h.x, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||||
|
animate(h.y, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||||
|
animate(h.base, h.spotRotation, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 lg:px-10">
|
||||||
|
<p className="font-mono text-[13px]">{labels.hintBoard}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={mess}
|
||||||
|
className="rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{labels.mess}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={tidy}
|
||||||
|
className="rounded-full bg-ink px-5 py-3 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{labels.tidy}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={tableRef}
|
||||||
|
className="relative mx-4 border-t-2 border-ink bg-[radial-gradient(circle_at_1px_1px,rgb(var(--plakat-ink)/0.16)_1px,transparent_0)] [background-size:22px_22px] sm:mx-6 lg:mx-10"
|
||||||
|
style={{ height: TABLE_HEIGHT }}
|
||||||
|
>
|
||||||
|
{objects.map((obj, i) => (
|
||||||
|
<BoardItem
|
||||||
|
key={obj.id}
|
||||||
|
obj={obj}
|
||||||
|
labels={labels}
|
||||||
|
spot={BOARD_SPOTS[i % BOARD_SPOTS.length]}
|
||||||
|
z={order.indexOf(obj.id) + 1}
|
||||||
|
tableRef={tableRef}
|
||||||
|
onFront={() => bringToFront(obj.id)}
|
||||||
|
register={(h) => {
|
||||||
|
if (h) handles.current.set(obj.id, h);
|
||||||
|
else handles.current.delete(obj.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardItemProps {
|
||||||
|
obj: TischObject;
|
||||||
|
labels: TischLabels;
|
||||||
|
spot: { x: number; y: number; r: number };
|
||||||
|
z: number;
|
||||||
|
tableRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onFront: () => void;
|
||||||
|
register: (h: Handle | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const [lifted, setLifted] = useState(false);
|
||||||
|
// Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen
|
||||||
|
const dragged = useRef(false);
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const y = useMotionValue(0);
|
||||||
|
const base = useMotionValue(spot.r);
|
||||||
|
// Neigung folgt der Wurfgeschwindigkeit, geglättet über eine Feder
|
||||||
|
const vx = useVelocity(x);
|
||||||
|
const tilt = useSpring(useTransform(vx, [-1600, 0, 1600], [-10, 0, 10]), { stiffness: 300, damping: 30 });
|
||||||
|
const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get()));
|
||||||
|
|
||||||
|
const { w, h } = BOARD_SIZE[obj.kind];
|
||||||
|
const openWindow = useOpenWindow();
|
||||||
|
const elRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
|
||||||
|
const radius =
|
||||||
|
{ sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]", chat: "rounded-[22px_22px_22px_6px]" }[obj.kind as string] ??
|
||||||
|
"rounded-2xl";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={(el) => {
|
||||||
|
elRef.current = el;
|
||||||
|
register(el ? { el, x, y, base, spotRotation: spot.r } : null);
|
||||||
|
}}
|
||||||
|
drag
|
||||||
|
dragConstraints={tableRef}
|
||||||
|
dragElastic={0.18}
|
||||||
|
dragTransition={{ power: 0.35, timeConstant: 320, bounceStiffness: 260, bounceDamping: 22 }}
|
||||||
|
whileDrag={{ scale: 1.05 }}
|
||||||
|
onPointerDown={() => {
|
||||||
|
dragged.current = false;
|
||||||
|
onFront();
|
||||||
|
}}
|
||||||
|
onDragStart={() => {
|
||||||
|
dragged.current = true;
|
||||||
|
setLifted(true);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setLifted(false)}
|
||||||
|
onTap={() => !dragged.current && openIt()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
openIt();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={labelFor(obj, labels)}
|
||||||
|
className={`absolute cursor-grab touch-none select-none active:cursor-grabbing ${radius} transition-shadow duration-200 ease-out ${
|
||||||
|
lifted ? "shadow-[14px_16px_0_rgb(var(--plakat-ink))]" : "shadow-[6px_6px_0_rgb(var(--plakat-ink))]"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotate,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
left: `min(${spot.x * 100}%, calc(100% - ${w}px))`,
|
||||||
|
top: spot.y,
|
||||||
|
zIndex: z,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ObjectFace obj={obj} labels={labels} />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion";
|
||||||
|
import ObjectFace from "./ObjectFace";
|
||||||
|
import { useOpenWindow } from "../fenster/WindowProvider";
|
||||||
|
import { labelFor, windowFor } from "./windowFor";
|
||||||
|
import type { TischLabels, TischObject } from "./types";
|
||||||
|
|
||||||
|
interface DeckProps {
|
||||||
|
objects: TischObject[];
|
||||||
|
labels: TischLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBLE = 4;
|
||||||
|
// Ab dieser Strecke oder Geschwindigkeit fliegt die Karte weg (Emil: ein kurzer Wisch reicht)
|
||||||
|
const DISTANCE = 110;
|
||||||
|
const VELOCITY = 500;
|
||||||
|
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
type Exit = { kind: "fly"; dir: 1 | -1 } | { kind: "fade" };
|
||||||
|
|
||||||
|
// Bekommt den aktuellen Exit-Modus über `custom` von AnimatePresence
|
||||||
|
const exitVariants: Variants = {
|
||||||
|
leave: (e: Exit) =>
|
||||||
|
e.kind === "fly"
|
||||||
|
? { x: e.dir * 460, rotate: e.dir * 18, opacity: 0, transition: { duration: 0.3, ease: EASE_OUT } }
|
||||||
|
: { scale: 0.85, opacity: 0, transition: { duration: 0.2, ease: EASE_OUT } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Handy: Kartenstapel. Oberste Karte zur Seite wischen, antippen zum Umdrehen. */
|
||||||
|
export default function Deck({ objects, labels }: DeckProps) {
|
||||||
|
const [order, setOrder] = useState(() => objects.map((_, i) => i));
|
||||||
|
// Wie die Karte geht, die den sichtbaren Stapel verlässt: oben weggeworfen oder hinten ausgeblendet
|
||||||
|
const [exit, setExit] = useState<Exit>({ kind: "fly", dir: 1 });
|
||||||
|
|
||||||
|
const next = (d: 1 | -1 = 1) => {
|
||||||
|
setExit({ kind: "fly", dir: d });
|
||||||
|
setOrder((o) => [...o.slice(1), o[0]]);
|
||||||
|
};
|
||||||
|
const prev = () => {
|
||||||
|
setExit({ kind: "fade" });
|
||||||
|
setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = order.slice(0, VISIBLE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-4 font-mono text-[13px] sm:px-6">{labels.hintDeck}</p>
|
||||||
|
<div className="relative h-[440px] overflow-hidden">
|
||||||
|
<AnimatePresence initial={false} custom={exit}>
|
||||||
|
{visible
|
||||||
|
.map((idx, depth) => (
|
||||||
|
<DeckCard
|
||||||
|
key={objects[idx].id}
|
||||||
|
obj={objects[idx]}
|
||||||
|
labels={labels}
|
||||||
|
depth={depth}
|
||||||
|
enteringFromLeft={depth === 0 && exit.kind === "fade"}
|
||||||
|
onDismiss={next}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.reverse()}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 pb-10 pt-1 sm:px-6">
|
||||||
|
<RoundButton label={labels.prev} onClick={prev}>
|
||||||
|
←
|
||||||
|
</RoundButton>
|
||||||
|
<span className="font-mono text-[13px] [font-variant-numeric:tabular-nums]" aria-live="polite">
|
||||||
|
{pad(order[0] + 1)} / {pad(objects.length)}
|
||||||
|
</span>
|
||||||
|
<RoundButton label={labels.next} onClick={() => next(1)}>
|
||||||
|
→
|
||||||
|
</RoundButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundButton({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className="grid h-[52px] w-[52px] place-items-center rounded-full border-2 border-ink bg-paper text-xl font-semibold transition-[background-color,transform] duration-150 ease-out active:scale-95 active:bg-paper-2"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeckCardProps {
|
||||||
|
obj: TischObject;
|
||||||
|
labels: TischLabels;
|
||||||
|
depth: number;
|
||||||
|
enteringFromLeft: boolean;
|
||||||
|
onDismiss: (dir: 1 | -1) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) {
|
||||||
|
const dragged = useRef(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const openWindow = useOpenWindow();
|
||||||
|
const openIt = () => openWindow?.(windowFor(obj), cardRef.current);
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
|
||||||
|
const isTop = depth === 0;
|
||||||
|
const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3;
|
||||||
|
|
||||||
|
const handleDragEnd = (_: unknown, info: PanInfo) => {
|
||||||
|
if (Math.abs(info.offset.x) > DISTANCE || Math.abs(info.velocity.x) > VELOCITY) {
|
||||||
|
onDismiss(info.offset.x > 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-x-0 top-4 mx-auto h-[390px] w-[min(300px,82vw)]"
|
||||||
|
style={{ zIndex: 10 - depth }}
|
||||||
|
initial={enteringFromLeft ? { x: -420, rotate: -14, opacity: 0 } : { scale: 0.85, y: 3 * 14, opacity: 0 }}
|
||||||
|
animate={{ x: 0, scale: 1 - Math.min(depth, 3) * 0.05, y: Math.min(depth, 3) * 14, rotate: restRotate, opacity: 1 }}
|
||||||
|
variants={exitVariants}
|
||||||
|
exit="leave"
|
||||||
|
transition={{ type: "spring", bounce: 0, duration: 0.45 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={cardRef}
|
||||||
|
className={`h-full w-full ${obj.kind === "sticker" ? "rounded-full" : "rounded-2xl"} shadow-[6px_6px_0_rgb(var(--plakat-ink))] ${isTop ? "cursor-grab" : "pointer-events-none"}`}
|
||||||
|
style={{ x, rotate: dragRotate, touchAction: "pan-y" }}
|
||||||
|
drag={isTop ? "x" : false}
|
||||||
|
dragSnapToOrigin
|
||||||
|
dragElastic={0.9}
|
||||||
|
onPointerDown={() => {
|
||||||
|
dragged.current = false;
|
||||||
|
}}
|
||||||
|
onDragStart={() => {
|
||||||
|
dragged.current = true;
|
||||||
|
}}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onTap={isTop ? () => !dragged.current && openIt() : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isTop && e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
openIt();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={isTop ? 0 : -1}
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={labelFor(obj, labels)}
|
||||||
|
aria-hidden={!isTop}
|
||||||
|
>
|
||||||
|
<ObjectFace obj={obj} labels={labels} />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { TischLabels, TischObject } from "./types";
|
||||||
|
import { loadCurrentBook, type CurrentBook } from "./currentBook";
|
||||||
|
|
||||||
|
interface ObjectFaceProps {
|
||||||
|
obj: TischObject;
|
||||||
|
labels: TischLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
// Rand und Rundung, die alle Objekte teilen
|
||||||
|
const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink";
|
||||||
|
|
||||||
|
/** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */
|
||||||
|
export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
|
||||||
|
switch (obj.kind) {
|
||||||
|
case "project":
|
||||||
|
return (
|
||||||
|
<div className={`${edge} flex flex-col justify-between p-4`} style={{ background: obj.color, color: obj.textColor }}>
|
||||||
|
<div className="flex justify-between font-mono text-xs">
|
||||||
|
<span>({pad(obj.index + 1)})</span>
|
||||||
|
<span>{obj.year}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-[2.4rem] font-extrabold leading-[0.88] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] [color:inherit]">
|
||||||
|
{obj.title}
|
||||||
|
</h3>
|
||||||
|
<div className="font-mono text-xs leading-snug">
|
||||||
|
<div>{obj.tags.slice(0, 3).join(" · ")}</div>
|
||||||
|
<div className="mt-2 opacity-70">{labels.flip} ↗</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "ticket":
|
||||||
|
return (
|
||||||
|
<div className={`${edge} grid grid-cols-[1fr_76px] bg-ink text-paper`}>
|
||||||
|
<div className="flex flex-col justify-between p-4">
|
||||||
|
<span className="font-mono text-[11.5px] uppercase tracking-wide">{labels.ticketTitle}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-[4.4rem] font-extrabold leading-[0.8] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
|
||||||
|
{obj.daysLeft}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold">{labels.ticketDays}</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11.5px] leading-snug">{labels.ticketJob}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid place-items-center border-l-2 border-dashed border-paper/30 font-mono text-[11px] uppercase tracking-[0.12em] text-signal [transform:rotate(180deg)] [writing-mode:vertical-rl]">
|
||||||
|
{labels.ticketStub}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "server":
|
||||||
|
return (
|
||||||
|
<div className={`${edge} flex flex-col gap-3 bg-paper p-4 text-ink`}>
|
||||||
|
<h3 className="font-display text-2xl font-extrabold leading-none tracking-[-0.03em]">{labels.serverTitle}</h3>
|
||||||
|
{labels.servers.map((s) => (
|
||||||
|
<div key={s.name} className="grid grid-cols-[10px_1fr] items-center gap-2.5 text-sm">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{s.name}
|
||||||
|
<span className="block font-mono text-[11.5px] opacity-75">{s.detail}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="mt-auto border-t-2 border-ink/15 pt-2.5 font-mono text-[11.5px] leading-snug">{labels.serverNote}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "book":
|
||||||
|
return <BookFace href={obj.href} labels={labels} />;
|
||||||
|
|
||||||
|
case "photo":
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col rounded-md border-2 border-ink bg-polaroid p-2.5 pb-0 text-polaroid-ink">
|
||||||
|
<div className="relative w-full flex-1 overflow-hidden rounded-sm bg-paper-2">
|
||||||
|
<Image src="/images/me.jpg" alt="Dennis Konkol" fill sizes="220px" className="object-cover" draggable={false} />
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5 text-center font-display text-lg font-bold">{labels.photoCaption}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "chat":
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between rounded-[22px_22px_22px_6px] border-2 border-ink bg-ink p-4 text-paper">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-wide text-signal">KI · n8n</span>
|
||||||
|
<span className="font-display text-[1.7rem] font-extrabold leading-[0.95] tracking-[-0.03em]">{labels.chatCard}</span>
|
||||||
|
<span className="font-mono text-xs opacity-70">{labels.chatCardHint} ↗</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "sticker":
|
||||||
|
return (
|
||||||
|
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
|
||||||
|
{labels.sticker}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookFace({ href, labels }: { href: string; labels: TischLabels }) {
|
||||||
|
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadCurrentBook().then((b) => {
|
||||||
|
if (alive) setBook(b);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-[6px_14px_14px_6px] border-2 border-ink bg-bookcover text-bookcover-text shadow-[inset_6px_0_0_rgba(255,255,255,0.08)]">
|
||||||
|
{book?.image && (
|
||||||
|
<Image src={book.image} alt="" fill sizes="200px" className="object-cover opacity-40" draggable={false} />
|
||||||
|
)}
|
||||||
|
<div className="relative flex h-full flex-col justify-between p-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-wide">{labels.bookLabel}</span>
|
||||||
|
{book === undefined ? (
|
||||||
|
<div className="space-y-2" aria-hidden="true">
|
||||||
|
<div className="h-7 w-4/5 animate-pulse rounded bg-white/15" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
|
||||||
|
</div>
|
||||||
|
) : book ? (
|
||||||
|
<div>
|
||||||
|
<div className="line-clamp-3 font-display text-[1.9rem] font-extrabold leading-[0.92] tracking-[-0.03em]">{book.title}</div>
|
||||||
|
<div className="mt-1.5 font-mono text-xs">{book.authors.join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link href={href} className="font-display text-2xl font-extrabold leading-tight underline decoration-2 underline-offset-4">
|
||||||
|
{labels.bookFallback}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xs">{book ? `${progress} %` : " "}</div>
|
||||||
|
<div className="mt-1.5 h-1 overflow-hidden rounded bg-white/20">
|
||||||
|
<div className="h-full origin-left bg-signal" style={{ transform: `scaleX(${progress / 100})` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getProjects } from "@/lib/directus";
|
||||||
|
import Board from "./Board";
|
||||||
|
import Deck from "./Deck";
|
||||||
|
import { PROJECT_COLORS, type TischLabels, type TischObject } from "./types";
|
||||||
|
|
||||||
|
interface TischSectionProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgabe der Masterarbeit, Grundlage für das Ticket
|
||||||
|
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Tisch: Projekte und ein paar Dinge aus meinem Alltag als Objekte.
|
||||||
|
* Desktop zeigt den freien Tisch, das Handy einen Kartenstapel. Beides wird
|
||||||
|
* serverseitig gerendert und per CSS umgeschaltet, damit nichts springt.
|
||||||
|
*/
|
||||||
|
export default async function TischSection({ locale }: TischSectionProps) {
|
||||||
|
const t = await getTranslations({ locale, namespace: "home.tisch" });
|
||||||
|
const projects = (await getProjects(locale, { published: true, limit: 6 }).catch(() => null)) ?? [];
|
||||||
|
const daysLeft = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
|
||||||
|
|
||||||
|
const labels: TischLabels = {
|
||||||
|
hintBoard: t("hintBoard"),
|
||||||
|
hintDeck: t("hintDeck"),
|
||||||
|
tidy: t("tidy"),
|
||||||
|
mess: t("mess"),
|
||||||
|
flip: t("flip"),
|
||||||
|
details: t("details"),
|
||||||
|
prev: t("prev"),
|
||||||
|
next: t("next"),
|
||||||
|
ticketTitle: t("ticketTitle"),
|
||||||
|
ticketDays: t("ticketDays"),
|
||||||
|
ticketJob: t("ticketJob"),
|
||||||
|
ticketStub: t("ticketStub"),
|
||||||
|
serverTitle: t("serverTitle"),
|
||||||
|
servers: [
|
||||||
|
{ name: t("servers.ingress.name"), detail: t("servers.ingress.detail") },
|
||||||
|
{ name: t("servers.mail.name"), detail: t("servers.mail.detail") },
|
||||||
|
{ name: t("servers.home.name"), detail: t("servers.home.detail") },
|
||||||
|
],
|
||||||
|
serverNote: t("serverNote"),
|
||||||
|
bookLabel: t("bookLabel"),
|
||||||
|
bookFallback: t("bookFallback"),
|
||||||
|
photoCaption: t("photoCaption"),
|
||||||
|
chatCard: t("chatCard"),
|
||||||
|
chatCardHint: t("chatCardHint"),
|
||||||
|
sticker: t("sticker"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectObjects: TischObject[] = projects.map((p, i) => {
|
||||||
|
const color = PROJECT_COLORS[i % PROJECT_COLORS.length];
|
||||||
|
return {
|
||||||
|
kind: "project",
|
||||||
|
id: `project-${p.slug}`,
|
||||||
|
index: i,
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
year: p.created_at ? String(new Date(p.created_at).getFullYear()) : "",
|
||||||
|
tags: p.tags?.length ? p.tags : p.technologies ?? [],
|
||||||
|
href: `/${locale}/projects/${p.slug}`,
|
||||||
|
color: color.bg,
|
||||||
|
textColor: color.text,
|
||||||
|
liveUrl: p.live_url && p.live_url !== "#" ? p.live_url : undefined,
|
||||||
|
githubUrl: p.github_url && p.github_url !== "#" ? p.github_url : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reihenfolge = Startplätze auf dem Tisch; Projekte zuerst, damit sie oben liegen
|
||||||
|
const objects: TischObject[] = [
|
||||||
|
...projectObjects,
|
||||||
|
{ kind: "ticket", id: "ticket", daysLeft },
|
||||||
|
{ kind: "server", id: "server" },
|
||||||
|
{ kind: "book", id: "book", href: `/${locale}/books` },
|
||||||
|
{ kind: "photo", id: "photo" },
|
||||||
|
{ kind: "chat", id: "chat" },
|
||||||
|
{ kind: "sticker", id: "sticker" },
|
||||||
|
];
|
||||||
|
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
|
||||||
|
const deckObjects = objects.filter((o) => o.kind !== "sticker");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="projects" className="border-b-2 border-ink pb-16 pt-14 md:pt-16" aria-labelledby="tisch-title">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4 px-4 pb-4 sm:px-6 lg:px-10">
|
||||||
|
<h2
|
||||||
|
id="tisch-title"
|
||||||
|
className="font-display text-[clamp(2.4rem,6vw,5rem)] font-extrabold leading-[0.92] tracking-[-0.045em] [font-variation-settings:'wdth'_82]"
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/projects`}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{t("allProjects")} <span aria-hidden="true">↗</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Board objects={objects} labels={labels} />
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Deck objects={deckObjects} labels={labels} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Aktuelles Buch aus Hardcover (über n8n). Tisch, Stapel und Lese-Fenster teilen sich eine Anfrage.
|
||||||
|
|
||||||
|
export interface CurrentBook {
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
image: string | null;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen
|
||||||
|
let bookRequest: Promise<CurrentBook | null> | null = null;
|
||||||
|
export function loadCurrentBook(): Promise<CurrentBook | null> {
|
||||||
|
if (!bookRequest) {
|
||||||
|
bookRequest = fetch("/api/n8n/hardcover/currently-reading")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
const list = data?.currentlyReading;
|
||||||
|
const first = Array.isArray(list) ? list[0] : list;
|
||||||
|
return first ?? null;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
return bookRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Objekte auf dem Tisch. Der Server baut die Liste, die Client-Komponenten zeichnen sie.
|
||||||
|
|
||||||
|
export interface TischProject {
|
||||||
|
kind: "project";
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
year: string;
|
||||||
|
tags: string[];
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
textColor: string;
|
||||||
|
liveUrl?: string;
|
||||||
|
githubUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischTicket {
|
||||||
|
kind: "ticket";
|
||||||
|
id: "ticket";
|
||||||
|
daysLeft: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischServer {
|
||||||
|
kind: "server";
|
||||||
|
id: "server";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischBook {
|
||||||
|
kind: "book";
|
||||||
|
id: "book";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischPhoto {
|
||||||
|
kind: "photo";
|
||||||
|
id: "photo";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischChat {
|
||||||
|
kind: "chat";
|
||||||
|
id: "chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TischSticker {
|
||||||
|
kind: "sticker";
|
||||||
|
id: "sticker";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischChat | TischSticker;
|
||||||
|
|
||||||
|
export interface TischLabels {
|
||||||
|
hintBoard: string;
|
||||||
|
hintDeck: string;
|
||||||
|
tidy: string;
|
||||||
|
mess: string;
|
||||||
|
flip: string;
|
||||||
|
details: string;
|
||||||
|
prev: string;
|
||||||
|
next: string;
|
||||||
|
ticketTitle: string;
|
||||||
|
ticketDays: string;
|
||||||
|
ticketJob: string;
|
||||||
|
ticketStub: string;
|
||||||
|
serverTitle: string;
|
||||||
|
servers: { name: string; detail: string }[];
|
||||||
|
serverNote: string;
|
||||||
|
bookLabel: string;
|
||||||
|
bookFallback: string;
|
||||||
|
photoCaption: string;
|
||||||
|
chatCard: string;
|
||||||
|
chatCardHint: string;
|
||||||
|
sticker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breite und Höhe je Objektart auf dem Desktop-Tisch (px)
|
||||||
|
export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> = {
|
||||||
|
project: { w: 236, h: 312 },
|
||||||
|
ticket: { w: 320, h: 180 },
|
||||||
|
server: { w: 270, h: 290 },
|
||||||
|
book: { w: 176, h: 262 },
|
||||||
|
photo: { w: 210, h: 250 },
|
||||||
|
chat: { w: 250, h: 170 },
|
||||||
|
sticker: { w: 180, h: 180 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Startpositionen: Anteil der Tischbreite, Abstand von oben in px, Neigung in Grad
|
||||||
|
export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
|
||||||
|
{ x: 0.02, y: 60, r: -6 },
|
||||||
|
{ x: 0.23, y: 120, r: 4 },
|
||||||
|
{ x: 0.45, y: 50, r: -3 },
|
||||||
|
{ x: 0.67, y: 110, r: 6 },
|
||||||
|
{ x: 0.04, y: 480, r: 5 },
|
||||||
|
{ x: 0.28, y: 510, r: -5 },
|
||||||
|
{ x: 0.52, y: 440, r: 3 },
|
||||||
|
{ x: 0.8, y: 60, r: 8 },
|
||||||
|
{ x: 0.76, y: 450, r: -7 },
|
||||||
|
{ x: 0.42, y: 800, r: 7 },
|
||||||
|
{ x: 0.12, y: 800, r: -4 },
|
||||||
|
{ x: 0.66, y: 790, r: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
|
||||||
|
export const PROJECT_COLORS: { bg: string; text: string }[] = [
|
||||||
|
{ bg: "#2f5bea", text: "#f6f3f1" },
|
||||||
|
{ bg: "#1d1b3d", text: "#f6f3f1" },
|
||||||
|
{ bg: "#0f6e66", text: "#f6f3f1" },
|
||||||
|
{ bg: "#f2b531", text: "#140c0b" },
|
||||||
|
{ bg: "#140c0b", text: "#e8402f" },
|
||||||
|
{ bg: "#3f8a4a", text: "#f6f3f1" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { WindowRequest } from "../fenster/types";
|
||||||
|
import type { TischLabels, TischObject } from "./types";
|
||||||
|
|
||||||
|
/** Welches Fenster ein Objekt auf dem Tisch öffnet */
|
||||||
|
export function windowFor(obj: TischObject): WindowRequest {
|
||||||
|
switch (obj.kind) {
|
||||||
|
case "project":
|
||||||
|
return { kind: "project", project: obj };
|
||||||
|
case "ticket":
|
||||||
|
return { kind: "now" };
|
||||||
|
case "server":
|
||||||
|
case "sticker":
|
||||||
|
return { kind: "server" };
|
||||||
|
case "book":
|
||||||
|
return { kind: "read" };
|
||||||
|
case "photo":
|
||||||
|
return { kind: "about" };
|
||||||
|
case "chat":
|
||||||
|
return { kind: "chat" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorlesetext für das Objekt als Knopf */
|
||||||
|
export function labelFor(obj: TischObject, labels: TischLabels): string {
|
||||||
|
switch (obj.kind) {
|
||||||
|
case "project":
|
||||||
|
return obj.title;
|
||||||
|
case "ticket":
|
||||||
|
return labels.ticketTitle;
|
||||||
|
case "server":
|
||||||
|
return labels.serverTitle;
|
||||||
|
case "book":
|
||||||
|
return labels.bookLabel;
|
||||||
|
case "photo":
|
||||||
|
return labels.photoCaption;
|
||||||
|
case "chat":
|
||||||
|
return labels.chatCard;
|
||||||
|
case "sticker":
|
||||||
|
return labels.sticker;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
export const BentoGrid = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BentoGridItem = ({
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
header,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
title?: string | React.ReactNode;
|
|
||||||
description?: string | React.ReactNode;
|
|
||||||
header?: React.ReactNode;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ y: -5 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className={cn(
|
|
||||||
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
<div className="group-hover/bento:translate-x-2 transition duration-200">
|
|
||||||
{icon}
|
|
||||||
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+19
-13
@@ -30,6 +30,13 @@
|
|||||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
|
||||||
|
/* Plakat-Design (RGB-Kanäle, damit Tailwind-Deckkraft funktioniert) */
|
||||||
|
--plakat-red: 232 64 47;
|
||||||
|
--plakat-ink: 20 12 11;
|
||||||
|
--plakat-paper: 246 243 241;
|
||||||
|
--plakat-paper-2: 235 230 226;
|
||||||
|
--plakat-ok: 31 157 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -52,12 +59,18 @@
|
|||||||
--border: #44403c;
|
--border: #44403c;
|
||||||
--input: #292524;
|
--input: #292524;
|
||||||
--ring: #d6d3d1;
|
--ring: #d6d3d1;
|
||||||
|
|
||||||
|
/* Tinte und Papier tauschen die Rollen, das Rot bleibt */
|
||||||
|
--plakat-ink: 243 238 233;
|
||||||
|
--plakat-paper: 21 16 15;
|
||||||
|
--plakat-paper-2: 36 30 28;
|
||||||
|
--plakat-ok: 60 199 138;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: rgb(var(--plakat-paper));
|
||||||
color: var(--foreground);
|
color: rgb(var(--plakat-ink));
|
||||||
font-family: "Inter", sans-serif;
|
font-family: var(--font-geist), "Helvetica Neue", Arial, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -70,8 +83,8 @@ body {
|
|||||||
|
|
||||||
/* Custom Selection */
|
/* Custom Selection */
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--primary); /* Rich brown for better contrast */
|
background: rgb(var(--plakat-ink));
|
||||||
color: var(--primary-foreground); /* Off-white */
|
color: rgb(var(--plakat-paper));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth Scrolling */
|
/* Smooth Scrolling */
|
||||||
@@ -135,18 +148,11 @@ h3,
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-family: var(--font-playfair), Georgia, serif;
|
font-family: var(--font-bricolage), "Avenir Next", "Segoe UI", sans-serif;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--foreground);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
|
||||||
p,
|
|
||||||
span,
|
|
||||||
div {
|
|
||||||
color: var(--foreground); /* #3e2723 - meets WCAG AA standards */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar but keep functionality */
|
/* Hide scrollbar but keep functionality */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
+15
-10
@@ -1,24 +1,31 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { Inter, Playfair_Display } from "next/font/google";
|
import { Bricolage_Grotesque, Geist, Geist_Mono } from "next/font/google";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ClientProviders from "./components/ClientProviders";
|
import ClientProviders from "./components/ClientProviders";
|
||||||
import ShaderGradientBackground from "./components/ShaderGradientBackground";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getBaseUrl } from "@/lib/seo";
|
import { getBaseUrl } from "@/lib/seo";
|
||||||
|
|
||||||
const inter = Inter({
|
// Bricolage behält die Achsen für Breite und optische Größe, damit das Plakat-Wortbild variieren kann
|
||||||
variable: "--font-inter",
|
const bricolage = Bricolage_Grotesque({
|
||||||
|
variable: "--font-bricolage",
|
||||||
|
subsets: ["latin"],
|
||||||
|
axes: ["opsz", "wdth"],
|
||||||
|
display: "swap",
|
||||||
|
adjustFontFallback: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const geist = Geist({
|
||||||
|
variable: "--font-geist",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
adjustFontFallback: true,
|
adjustFontFallback: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const playfair = Playfair_Display({
|
const geistMono = Geist_Mono({
|
||||||
variable: "--font-playfair",
|
variable: "--font-geist-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
adjustFontFallback: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
@@ -37,9 +44,7 @@ export default async function RootLayout({
|
|||||||
{/* Prevent flash of unstyled theme — reads localStorage + system preference before React hydrates */}
|
{/* Prevent flash of unstyled theme — reads localStorage + system preference before React hydrates */}
|
||||||
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');var d=t==='dark'||((!t||t==='system')&&window.matchMedia('(prefers-color-scheme:dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}` }} />
|
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');var d=t==='dark'||((!t||t==='system')&&window.matchMedia('(prefers-color-scheme:dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}` }} />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
<body className={`${bricolage.variable} ${geist.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
||||||
<div className="grain-overlay" aria-hidden="true" />
|
|
||||||
<ShaderGradientBackground />
|
|
||||||
<ClientProviders>{children}</ClientProviders>
|
<ClientProviders>{children}</ClientProviders>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const BackgroundBlobs = () => {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
|
||||||
{/* Mint blob - top left */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
|
|
||||||
animate={{ scale: [1, 1.15, 1] }}
|
|
||||||
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Lavender blob - top right */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
|
|
||||||
animate={{ scale: [1, 1.1, 1] }}
|
|
||||||
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Rose blob - bottom left */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
|
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
|
||||||
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Peach blob - middle right */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
|
|
||||||
animate={{ scale: [1, 1.25, 1] }}
|
|
||||||
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BackgroundBlobs;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("ActivityFeed reload rendering", () => {
|
|
||||||
test("feed stays visible and dark after reload", async ({ page }) => {
|
|
||||||
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
|
||||||
|
|
||||||
const feed = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
|
|
||||||
await expect(feed).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const initialBox = await feed.boundingBox();
|
|
||||||
expect(initialBox).not.toBeNull();
|
|
||||||
expect(initialBox!.width).toBeGreaterThan(200);
|
|
||||||
expect(initialBox!.height).toBeGreaterThan(30);
|
|
||||||
|
|
||||||
const initialOpacity = await feed.evaluate((el) => getComputedStyle(el).opacity);
|
|
||||||
expect(Number(initialOpacity)).toBeGreaterThan(0.5);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
|
||||||
|
|
||||||
const feedAfter = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
|
|
||||||
await expect(feedAfter).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const afterBox = await feedAfter.boundingBox();
|
|
||||||
expect(afterBox).not.toBeNull();
|
|
||||||
expect(afterBox!.width).toBeGreaterThan(200);
|
|
||||||
expect(afterBox!.height).toBeGreaterThan(30);
|
|
||||||
|
|
||||||
const afterOpacity = await feedAfter.evaluate((el) => getComputedStyle(el).opacity);
|
|
||||||
expect(Number(afterOpacity)).toBeGreaterThan(0.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -2,9 +2,6 @@ import { getLocalizedMessage } from '@/lib/i18n-loader';
|
|||||||
import type {
|
import type {
|
||||||
NavTranslations,
|
NavTranslations,
|
||||||
FooterTranslations,
|
FooterTranslations,
|
||||||
HeroTranslations,
|
|
||||||
AboutTranslations,
|
|
||||||
ProjectsTranslations,
|
|
||||||
ContactTranslations,
|
ContactTranslations,
|
||||||
ConsentTranslations,
|
ConsentTranslations,
|
||||||
} from '@/types/translations';
|
} from '@/types/translations';
|
||||||
@@ -55,92 +52,6 @@ export async function getFooterTranslations(locale: string): Promise<FooterTrans
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHeroTranslations(locale: string): Promise<HeroTranslations> {
|
|
||||||
const keys = [
|
|
||||||
'home.hero.description',
|
|
||||||
'home.hero.ctaWork',
|
|
||||||
'home.hero.ctaContact',
|
|
||||||
'home.hero.features.f1',
|
|
||||||
'home.hero.features.f2',
|
|
||||||
'home.hero.features.f3',
|
|
||||||
];
|
|
||||||
|
|
||||||
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
description: values[0],
|
|
||||||
ctaWork: values[1],
|
|
||||||
ctaContact: values[2],
|
|
||||||
features: {
|
|
||||||
f1: values[3],
|
|
||||||
f2: values[4],
|
|
||||||
f3: values[5],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
|
|
||||||
const keys = [
|
|
||||||
'home.about.title',
|
|
||||||
'home.about.p1',
|
|
||||||
'home.about.p2',
|
|
||||||
'home.about.p3',
|
|
||||||
'home.about.funFactTitle',
|
|
||||||
'home.about.funFactBody',
|
|
||||||
'home.about.techStackTitle',
|
|
||||||
'home.about.techStack.categories.frontendMobile',
|
|
||||||
'home.about.techStack.categories.backendDevops',
|
|
||||||
'home.about.techStack.categories.toolsAutomation',
|
|
||||||
'home.about.techStack.categories.securityAdmin',
|
|
||||||
'home.about.techStack.items.selfHostedServices',
|
|
||||||
'home.about.hobbiesTitle',
|
|
||||||
'home.about.hobbies.selfHosting',
|
|
||||||
'home.about.hobbies.gaming',
|
|
||||||
'home.about.hobbies.gameServers',
|
|
||||||
'home.about.hobbies.jogging',
|
|
||||||
];
|
|
||||||
|
|
||||||
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: values[0],
|
|
||||||
p1: values[1],
|
|
||||||
p2: values[2],
|
|
||||||
p3: values[3],
|
|
||||||
funFactTitle: values[4],
|
|
||||||
funFactBody: values[5],
|
|
||||||
techStackTitle: values[6],
|
|
||||||
techStack: {
|
|
||||||
categories: {
|
|
||||||
frontendMobile: values[7],
|
|
||||||
backendDevops: values[8],
|
|
||||||
toolsAutomation: values[9],
|
|
||||||
securityAdmin: values[10],
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
selfHostedServices: values[11],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hobbiesTitle: values[12],
|
|
||||||
hobbies: {
|
|
||||||
selfHosting: values[13],
|
|
||||||
gaming: values[14],
|
|
||||||
gameServers: values[15],
|
|
||||||
jogging: values[16],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectsTranslations(locale: string): Promise<ProjectsTranslations> {
|
|
||||||
const [title, subtitle, viewAll] = await Promise.all([
|
|
||||||
getLocalizedMessage('home.projects.title', locale),
|
|
||||||
getLocalizedMessage('home.projects.subtitle', locale),
|
|
||||||
getLocalizedMessage('home.projects.viewAll', locale),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { title, subtitle, viewAll };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
|
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
|
||||||
const keys = [
|
const keys = [
|
||||||
'home.contact.title',
|
'home.contact.title',
|
||||||
|
|||||||
+118
-78
@@ -26,75 +26,131 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Student & Self-Hoster",
|
"kicker": "Dennis Konkol — Osnabrück",
|
||||||
"line1": "Building",
|
"claim": "Ich hoste mein halbes Leben selbst. Den Rest programmiere ich.",
|
||||||
"line2": "Stuff.",
|
"intro": "IT-Admin in Osnabrück und Masterstudent. Ab Februar 2027 kümmere ich mich hauptberuflich um Microsoft 365, bis dahin um meine Masterarbeit.",
|
||||||
"features": {
|
"countdown": "{days, plural, one {Noch # Tag.} other {Noch # Tage.}}",
|
||||||
"f1": "Next.js & Flutter",
|
"ctaWork": "Projekte ansehen",
|
||||||
"f2": "Docker Swarm & CI/CD",
|
"ctaContact": "Schreib mir"
|
||||||
"f3": "Self-Hosted Infrastruktur"
|
|
||||||
},
|
|
||||||
"description": "Ich bin Dennis Konkol, Informatik-Student und Webentwickler aus Osnabrück. Ich entwickle Fullstack-Apps mit Next.js und Flutter und betreibe meine eigene Infrastruktur mit Docker und CI/CD.",
|
|
||||||
"ctaWork": "Meine Projekte",
|
|
||||||
"ctaContact": "Kontakt"
|
|
||||||
},
|
},
|
||||||
"about": {
|
"tisch": {
|
||||||
"title": "Hinter den Kulissen",
|
"title": "Der Tisch.",
|
||||||
"p1": "Schon seit ich meinen ersten eigenen Server aufgesetzt habe, lässt mich das Thema Infrastruktur nicht mehr los. Als Student in Osnabrück verbringe ich meine Zeit am liebsten damit, moderne Web-Apps mit Next.js zu bauen oder mobile Lösungen mit Flutter zu entwickeln.",
|
"allProjects": "Alle Projekte",
|
||||||
"p2": "Aber für mich hört es nicht beim Code auf: Ich liebe es, meine eigenen Docker-Cluster zu managen, CI/CD-Pipelines zu optimieren und sicherzustellen, dass alles stabil und sicher läuft. DevOps ist für mich kein Job-Titel, sondern eine Lebenseinstellung.",
|
"hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, antippen für Details.",
|
||||||
"p3": "Wenn die Server einmal ohne mich klarkommen, findet man mich beim Laufen durch Osnabrück, beim Gaming oder beim Experimentieren mit neuen Automationen in n8n.",
|
"hintDeck": "Wisch die Karten zur Seite, tipp für Details.",
|
||||||
"funFactTitle": "Hardcore analog",
|
"tidy": "Aufräumen",
|
||||||
"funFactBody": "Trotz Cloud und Automatisierung: Meine wichtigsten Pläne entstehen immer noch mit Füller auf Papier. Das ist mein Anker im digitalen Chaos.",
|
"mess": "Durcheinander",
|
||||||
"techStackTitle": "Mein Tech Stack",
|
"flip": "antippen für Details",
|
||||||
"hobbiesTitle": "Wenn ich nicht code",
|
"details": "Zum Projekt",
|
||||||
"techStack": {
|
"prev": "Vorherige Karte",
|
||||||
"categories": {
|
"next": "Nächste Karte",
|
||||||
"frontendMobile": "Frontend & Mobile",
|
"ticketTitle": "Masterarbeit · Abgabe 15.02.2027",
|
||||||
"backendDevops": "Backend & DevOps",
|
"ticketDays": "Tage bis zur Abgabe",
|
||||||
"toolsAutomation": "Tools & Automation",
|
"ticketJob": "ab 02/2027 fest als M365-Administrator",
|
||||||
"securityAdmin": "Security & Admin"
|
"ticketStub": "Jetzt",
|
||||||
|
"serverTitle": "Was bei mir läuft",
|
||||||
|
"servers": {
|
||||||
|
"ingress": {
|
||||||
|
"name": "netcup · Eingang",
|
||||||
|
"detail": "Traefik, Dokploy, diese Seite"
|
||||||
},
|
},
|
||||||
"items": {
|
"mail": {
|
||||||
"selfHostedServices": "Self-hosted Services"
|
"name": "IONOS · Post und Fotos",
|
||||||
|
"detail": "mailcow, Immich"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"name": "Zuhause · Raspberry Pi",
|
||||||
|
"detail": "Home Assistant, Paperless"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hobbies": {
|
"serverNote": "Gesichert mit CrowdSec, tägliche Backups mit restic.",
|
||||||
"selfHosting": "Self-Hosting & DevOps",
|
"bookLabel": "Lese gerade",
|
||||||
"gaming": "Gaming",
|
"bookFallback": "Zur Bibliothek",
|
||||||
"gameServers": "Game-Server einrichten",
|
"photoCaption": "Das bin ich.",
|
||||||
"jogging": "Joggen um den Kopf freizubekommen und aktiv bleiben"
|
"chatCard": "Frag mich etwas.",
|
||||||
|
"chatCardHint": "KI-Chat öffnen",
|
||||||
|
"sticker": "Läuft auf eigenen Servern."
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"close": "Schließen",
|
||||||
|
"minimize": "Ablegen",
|
||||||
|
"openWindows": "Offene Fenster",
|
||||||
|
"projectPage": "Projektseite",
|
||||||
|
"source": "Quellcode",
|
||||||
|
"live": "Live ansehen",
|
||||||
|
"stack": "Technik",
|
||||||
|
"year": "Jahr",
|
||||||
|
"now": {
|
||||||
|
"title": "jetzt.txt · Stand Oktober 2026",
|
||||||
|
"days": "Tage bis zur Abgabe der Masterarbeit",
|
||||||
|
"study": {
|
||||||
|
"label": "Studium",
|
||||||
|
"text": "Federated Reinforcement Learning für einen Logistik-Hub, Abgabe Mitte Februar 2027"
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"label": "Arbeit",
|
||||||
|
"text": "IT bei einem Unternehmen in Osnabrück, ab Februar 2027 fest als Microsoft-365-Administrator"
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"label": "Nebenher",
|
||||||
|
"text": "nightly, eine App für Gedanken zwischen 2 und 5 Uhr nachts"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"currentlyReading": {
|
"server": {
|
||||||
"title": "Aktuell am Lesen",
|
"title": "server · alle grün",
|
||||||
"progress": "Fortschritt"
|
"headline": "Drei Rechner, ein VPN."
|
||||||
},
|
},
|
||||||
"readBooks": {
|
"read": {
|
||||||
"title": "Gelesene Bücher",
|
"title": "lesen · aus Hardcover",
|
||||||
"finishedAt": "Beendet am",
|
"progress": "gelesen",
|
||||||
"showMore": "{count} weitere anzeigen",
|
"empty": "Gerade liegt kein Buch auf dem Nachttisch.",
|
||||||
"showLess": "Weniger anzeigen",
|
"library": "Zur Bibliothek"
|
||||||
"readMore": "Weiterlesen",
|
|
||||||
"collapseReview": "Weniger anzeigen",
|
|
||||||
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
|
|
||||||
},
|
},
|
||||||
"activity": {
|
"about": {
|
||||||
"idleStatus": "System im Leerlauf / Geist aktiv",
|
"title": "über mich",
|
||||||
"codingNow": "Gerade am Coden",
|
"p1": "Aufgewachsen bin ich in Lingen, inzwischen wohne ich in Osnabrück. Angefangen hat alles mit einem Raspberry Pi und der Frage, ob ich meine Fotos nicht selbst speichern kann. Daraus sind drei Server geworden.",
|
||||||
"gaming": "Am Zocken",
|
"p2": "Wenn ich nicht am Rechner sitze, bin ich dienstags bei Body Attack, im Winter auf der Piste oder mit Freunden bei einem Brettspiel.",
|
||||||
"listening": "Hört gerade",
|
"facts": {
|
||||||
"inGame": "Im Spiel"
|
"laptop": {
|
||||||
|
"label": "Arbeitsrechner",
|
||||||
|
"value": "MacBook Pro M4 Pro"
|
||||||
|
},
|
||||||
|
"pc": {
|
||||||
|
"label": "Zum Spielen",
|
||||||
|
"value": "PC mit RTX 3080"
|
||||||
|
},
|
||||||
|
"study": {
|
||||||
|
"label": "Studium",
|
||||||
|
"value": "Master Informatik"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"label": "Sprachen",
|
||||||
|
"value": "Deutsch, Englisch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "frag mich · KI",
|
||||||
|
"welcome": "Hi! Frag mich etwas über Dennis, seine Projekte oder sein Setup.",
|
||||||
|
"consent": "Der Chat läuft über einen KI-Dienst in meinem n8n. Deine Nachrichten werden dafür verarbeitet und kurz gespeichert. Erst wenn du zustimmst, wird etwas gesendet.",
|
||||||
|
"allow": "Chat erlauben",
|
||||||
|
"placeholder": "Deine Frage …",
|
||||||
|
"send": "Senden",
|
||||||
|
"error": "Gerade keine Verbindung. Versuch es gleich noch mal.",
|
||||||
|
"thinking": "Antwort wird geschrieben",
|
||||||
|
"note": "Antworten kommen von einer KI und können danebenliegen."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "kontakt",
|
||||||
|
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
||||||
|
"copy": "Adresse kopieren",
|
||||||
|
"copied": "Kopiert",
|
||||||
|
"form": "Zum Formular"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
|
||||||
"title": "Ausgewählte Arbeiten",
|
|
||||||
"subtitle": "Projekte, die meine Grenzen erweitert haben.",
|
|
||||||
"featured": "Featured",
|
|
||||||
"viewAll": "Archiv ansehen",
|
|
||||||
"noProjects": "Noch keine Projekte."
|
|
||||||
},
|
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Kontakt",
|
"title": "Schreib mir.",
|
||||||
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
|
"subtitle": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
||||||
"getInTouch": "Melde dich",
|
"getInTouch": "Melde dich",
|
||||||
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
|
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
|
||||||
"info": {
|
"info": {
|
||||||
@@ -154,13 +210,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "Software Engineer",
|
"role": "IT & Self-Hosting",
|
||||||
"madeIn": "Made in Germany",
|
"madeIn": "selbst gehostet in Deutschland",
|
||||||
"legalNotice": "Impressum",
|
"legalNotice": "Impressum",
|
||||||
"privacyPolicy": "Datenschutz",
|
"privacyPolicy": "Datenschutz",
|
||||||
"privacySettings": "Datenschutz-Einstellungen",
|
"privacySettings": "Datenschutz-Einstellungen",
|
||||||
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
|
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
|
||||||
"builtWith": "Built with",
|
"builtWith": "Gebaut mit Next.js und Directus, läuft auf meinen eigenen Servern.",
|
||||||
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
|
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
|
||||||
"backToTop": "Nach oben",
|
"backToTop": "Nach oben",
|
||||||
"systemsOnline": "Systeme online"
|
"systemsOnline": "Systeme online"
|
||||||
@@ -180,21 +236,5 @@
|
|||||||
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
|
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
|
||||||
"backHome": "Zurück",
|
"backHome": "Zurück",
|
||||||
"empty": "Noch keine Bücher."
|
"empty": "Noch keine Bücher."
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"status": "Status",
|
|
||||||
"aiAssistant": "KI-Assistent",
|
|
||||||
"library": "Bibliothek",
|
|
||||||
"viewAll": "Alle ansehen",
|
|
||||||
"myGear": "Mein Gear",
|
|
||||||
"gearMain": "Main",
|
|
||||||
"gearPC": "PC",
|
|
||||||
"gearServer": "Server",
|
|
||||||
"gearOS": "OS",
|
|
||||||
"curiosity": "Neugier über die Softwareentwicklung hinaus.",
|
|
||||||
"connect": "Verbinden",
|
|
||||||
"email": "E-Mail",
|
|
||||||
"code": "Code",
|
|
||||||
"professional": "Beruflich"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-85
@@ -23,79 +23,134 @@
|
|||||||
"acceptSelected": "Accept selected",
|
"acceptSelected": "Accept selected",
|
||||||
"rejectAll": "Reject all",
|
"rejectAll": "Reject all",
|
||||||
"hide": "Hide"
|
"hide": "Hide"
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"home": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Student & Self-Hoster",
|
"kicker": "Dennis Konkol — Osnabrück, Germany",
|
||||||
"line1": "Building",
|
"claim": "I self-host half my life. The rest I code.",
|
||||||
"line2": "Stuff.",
|
"intro": "IT admin in Osnabrück and master's student. From February 2027 I look after Microsoft 365 full-time, until then my master's thesis.",
|
||||||
"features": {
|
"countdown": "{days, plural, one {# day to go.} other {# days to go.}}",
|
||||||
"f1": "Next.js & Flutter",
|
"ctaWork": "See projects",
|
||||||
"f2": "Docker Swarm & CI/CD",
|
"ctaContact": "Write me"
|
||||||
"f3": "Self-Hosted Infrastructure"
|
|
||||||
},
|
|
||||||
"description": "I'm Dennis Konkol, a computer science student and web developer from Osnabrück, Germany. I build fullstack apps with Next.js and Flutter and love running my own infrastructure with Docker and CI/CD.",
|
|
||||||
"ctaWork": "View Projects",
|
|
||||||
"ctaContact": "Get in touch"
|
|
||||||
},
|
},
|
||||||
"about": {
|
"tisch": {
|
||||||
"title": "Behind the Code",
|
"title": "The desk.",
|
||||||
"p1": "Ever since I set up my first home server, I've been hooked on infrastructure. Currently studying in Osnabrück, I split my time between developing modern web apps with Next.js and building mobile experiences with Flutter.",
|
"allProjects": "All projects",
|
||||||
"p2": "For me, it doesn't stop at the code. I genuinely enjoy managing my own Docker clusters, optimizing CI/CD pipelines, and making sure everything is stable and secure. DevOps isn't just a part of my job – it's how I think about building things.",
|
"hintBoard": "Everything here can be touched. Throw it, stack it, tap for details.",
|
||||||
"p3": "When the servers are running smoothly, you'll find me jogging through the city, gaming, or tinkering with new automation workflows in n8n.",
|
"hintDeck": "Swipe the cards aside, tap for details.",
|
||||||
"funFactTitle": "Hardcore Analog",
|
"tidy": "Tidy up",
|
||||||
"funFactBody": "Despite my love for automation and the cloud, my most important ideas are still born on paper with a fountain pen. It's my way of staying grounded.",
|
"mess": "Mess it up",
|
||||||
"techStackTitle": "My Tech Stack",
|
"flip": "tap for details",
|
||||||
"hobbiesTitle": "When I'm Not Coding",
|
"details": "Open project",
|
||||||
"techStack": {
|
"prev": "Previous card",
|
||||||
"categories": {
|
"next": "Next card",
|
||||||
"frontendMobile": "Frontend & Mobile",
|
"ticketTitle": "Master's thesis · due 15 Feb 2027",
|
||||||
"backendDevops": "Backend & DevOps",
|
"ticketDays": "days until submission",
|
||||||
"toolsAutomation": "Tools & Automation",
|
"ticketJob": "from 02/2027: M365 administrator",
|
||||||
"securityAdmin": "Security & Admin"
|
"ticketStub": "Now",
|
||||||
|
"serverTitle": "What I run",
|
||||||
|
"servers": {
|
||||||
|
"ingress": {
|
||||||
|
"name": "netcup · front door",
|
||||||
|
"detail": "Traefik, Dokploy, this site"
|
||||||
},
|
},
|
||||||
"items": {
|
"mail": {
|
||||||
"selfHostedServices": "Self-hosted services"
|
"name": "IONOS · mail and photos",
|
||||||
|
"detail": "mailcow, Immich"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"name": "Home · Raspberry Pi",
|
||||||
|
"detail": "Home Assistant, Paperless"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hobbies": {
|
"serverNote": "Guarded by CrowdSec, daily backups with restic.",
|
||||||
"selfHosting": "Self-Hosting & DevOps",
|
"bookLabel": "Reading now",
|
||||||
"gaming": "Gaming",
|
"bookFallback": "To the library",
|
||||||
"gameServers": "Setting up game servers",
|
"photoCaption": "That's me.",
|
||||||
"jogging": "Jogging to clear my mind and stay active"
|
"chatCard": "Ask me something.",
|
||||||
|
"chatCardHint": "Open AI chat",
|
||||||
|
"sticker": "Runs on my own servers."
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"close": "Close",
|
||||||
|
"minimize": "Minimize",
|
||||||
|
"openWindows": "Open windows",
|
||||||
|
"projectPage": "Project page",
|
||||||
|
"source": "Source",
|
||||||
|
"live": "See it live",
|
||||||
|
"stack": "Stack",
|
||||||
|
"year": "Year",
|
||||||
|
"now": {
|
||||||
|
"title": "now.txt · October 2026",
|
||||||
|
"days": "days until my master's thesis is due",
|
||||||
|
"study": {
|
||||||
|
"label": "Study",
|
||||||
|
"text": "Federated reinforcement learning for a logistics hub, due mid-February 2027"
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"label": "Work",
|
||||||
|
"text": "IT at a company in Osnabrück, from February 2027 full-time as Microsoft 365 administrator"
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"label": "On the side",
|
||||||
|
"text": "nightly, an app for thoughts between 2 and 5 a.m."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"currentlyReading": {
|
"server": {
|
||||||
"title": "Currently Reading",
|
"title": "servers · all green",
|
||||||
"progress": "Progress"
|
"headline": "Three machines, one VPN."
|
||||||
},
|
},
|
||||||
"readBooks": {
|
"read": {
|
||||||
"title": "Read",
|
"title": "reading · from Hardcover",
|
||||||
"finishedAt": "Finished",
|
"progress": "read",
|
||||||
"showMore": "{count} more",
|
"empty": "No book on the nightstand right now.",
|
||||||
"showLess": "Show less",
|
"library": "To the library"
|
||||||
"readMore": "Read more",
|
|
||||||
"collapseReview": "Show less",
|
|
||||||
"empty": "Books finished in Hardcover will appear here automatically."
|
|
||||||
},
|
},
|
||||||
"activity": {
|
"about": {
|
||||||
"idleStatus": "System Idle / Mind Active",
|
"title": "about me",
|
||||||
"codingNow": "Coding Now",
|
"p1": "I grew up in Lingen and now live in Osnabrück. It all started with a Raspberry Pi and the question whether I could store my photos myself. That turned into three servers.",
|
||||||
"gaming": "Gaming",
|
"p2": "When I'm not at the computer, I'm at Body Attack on Tuesdays, on the slopes in winter, or playing board games with friends.",
|
||||||
"listening": "Listening",
|
"facts": {
|
||||||
"inGame": "In Game"
|
"laptop": {
|
||||||
|
"label": "Work machine",
|
||||||
|
"value": "MacBook Pro M4 Pro"
|
||||||
|
},
|
||||||
|
"pc": {
|
||||||
|
"label": "For games",
|
||||||
|
"value": "PC with RTX 3080"
|
||||||
|
},
|
||||||
|
"study": {
|
||||||
|
"label": "Study",
|
||||||
|
"value": "Master's in computer science"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"label": "Languages",
|
||||||
|
"value": "German, English"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "ask me · AI",
|
||||||
|
"welcome": "Hi! Ask me anything about Dennis, his projects or his setup.",
|
||||||
|
"consent": "The chat runs through an AI service in my n8n. Your messages are processed and briefly stored for that. Nothing is sent until you agree.",
|
||||||
|
"allow": "Allow chat",
|
||||||
|
"placeholder": "Your question …",
|
||||||
|
"send": "Send",
|
||||||
|
"error": "No connection right now. Try again in a moment.",
|
||||||
|
"thinking": "Writing a reply",
|
||||||
|
"note": "Answers come from an AI and can be wrong."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "contact",
|
||||||
|
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
||||||
|
"copy": "Copy address",
|
||||||
|
"copied": "Copied",
|
||||||
|
"form": "To the form"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
|
||||||
"title": "Selected Work",
|
|
||||||
"subtitle": "Projects that pushed my boundaries.",
|
|
||||||
"featured": "Featured",
|
|
||||||
"viewAll": "View Archive",
|
|
||||||
"noProjects": "No projects yet."
|
|
||||||
},
|
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Contact Me",
|
"title": "Write me.",
|
||||||
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
|
"subtitle": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
||||||
"getInTouch": "Get In Touch",
|
"getInTouch": "Get In Touch",
|
||||||
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
|
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
|
||||||
"info": {
|
"info": {
|
||||||
@@ -133,8 +188,7 @@
|
|||||||
"characters": "{count} characters"
|
"characters": "{count} characters"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"shared": {
|
"shared": {
|
||||||
"featured": "Featured"
|
"featured": "Featured"
|
||||||
@@ -154,16 +208,15 @@
|
|||||||
"viewSource": "View Source",
|
"viewSource": "View Source",
|
||||||
"techStack": "Tech Stack"
|
"techStack": "Tech Stack"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "Software Engineer",
|
"role": "IT & self-hosting",
|
||||||
"madeIn": "Made in Germany",
|
"madeIn": "self-hosted in Germany",
|
||||||
"legalNotice": "Legal notice",
|
"legalNotice": "Legal notice",
|
||||||
"privacyPolicy": "Privacy policy",
|
"privacyPolicy": "Privacy policy",
|
||||||
"privacySettings": "Privacy settings",
|
"privacySettings": "Privacy settings",
|
||||||
"privacySettingsTitle": "Show privacy settings banner again",
|
"privacySettingsTitle": "Show privacy settings banner again",
|
||||||
"builtWith": "Built with",
|
"builtWith": "Built with Next.js and Directus, running on my own servers.",
|
||||||
"aiDisclaimer": "Some content on this site may be AI-assisted.",
|
"aiDisclaimer": "Some content on this site may be AI-assisted.",
|
||||||
"backToTop": "Back to top",
|
"backToTop": "Back to top",
|
||||||
"systemsOnline": "Systems Online"
|
"systemsOnline": "Systems Online"
|
||||||
@@ -183,22 +236,5 @@
|
|||||||
"subtitle": "Books that shaped my mindset and expanded my horizons.",
|
"subtitle": "Books that shaped my mindset and expanded my horizons.",
|
||||||
"backHome": "Back Home",
|
"backHome": "Back Home",
|
||||||
"empty": "No books yet."
|
"empty": "No books yet."
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"status": "Status",
|
|
||||||
"aiAssistant": "AI Assistant",
|
|
||||||
"library": "Library",
|
|
||||||
"viewAll": "View All",
|
|
||||||
"myGear": "My Gear",
|
|
||||||
"gearMain": "Main",
|
|
||||||
"gearPC": "PC",
|
|
||||||
"gearServer": "Server",
|
|
||||||
"gearOS": "OS",
|
|
||||||
"curiosity": "Curiosity beyond software engineering.",
|
|
||||||
"connect": "Connect",
|
|
||||||
"email": "Email",
|
|
||||||
"code": "Code",
|
|
||||||
"professional": "Professional"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+471
-1
@@ -34,7 +34,8 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"vaul": "1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -2994,6 +2995,323 @@
|
|||||||
"@prisma/debug": "5.22.0"
|
"@prisma/debug": "5.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.2.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.5",
|
||||||
|
"@radix-ui/react-context": "1.3.0",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.2.1",
|
||||||
|
"@radix-ui/react-focus-guards": "1.2.0",
|
||||||
|
"@radix-ui/react-focus-scope": "1.3.1",
|
||||||
|
"@radix-ui/react-id": "1.1.4",
|
||||||
|
"@radix-ui/react-portal": "1.2.0",
|
||||||
|
"@radix-ui/react-presence": "1.1.11",
|
||||||
|
"@radix-ui/react-primitive": "2.2.0",
|
||||||
|
"@radix-ui/react-slot": "1.5.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.3.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.2.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.2.0",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.5",
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.2.0",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.2.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.2.0",
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@redis/bloom": {
|
"node_modules/@redis/bloom": {
|
||||||
"version": "5.10.0",
|
"version": "5.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
|
||||||
@@ -5013,6 +5331,18 @@
|
|||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -6388,6 +6718,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@@ -7742,6 +8078,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-package-type": {
|
"node_modules/get-package-type": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||||
@@ -12661,6 +13006,75 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -14518,6 +14932,27 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-intl": {
|
"node_modules/use-intl": {
|
||||||
"version": "4.8.2",
|
"version": "4.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
|
||||||
@@ -14539,6 +14974,28 @@
|
|||||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
@@ -14577,6 +15034,19 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vaul": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
|
|||||||
+2
-1
@@ -78,7 +78,8 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"vaul": "1.1.2"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"chrome >= 100",
|
"chrome >= 100",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Räumt Directus nach dem Plakat-Redesign auf.
|
||||||
|
*
|
||||||
|
* Erst ausführen, wenn das Redesign auf production (dk0.dev) live ist — die alte
|
||||||
|
* Seite liest diese Einträge noch.
|
||||||
|
*
|
||||||
|
* node scripts/directus-cleanup-redesign.mjs # Probelauf, ändert nichts
|
||||||
|
* node scripts/directus-cleanup-redesign.mjs --apply # sichert, dann ändern
|
||||||
|
*
|
||||||
|
* Was passiert (mit --apply):
|
||||||
|
* - hobbies: alle auf status "archived" (die neue Seite zeigt keine Hobby-Karten)
|
||||||
|
* - tech_stack_categories: alle auf "archived" (Tech-Stack steht jetzt im Laufband)
|
||||||
|
* - messages: hero.badge, hero.line1, hero.line2, about.quote.idle löschen
|
||||||
|
* (überschrieben alte Hero-Texte, werden nicht mehr gelesen)
|
||||||
|
* - content_pages: home-hero und home-about (beide Sprachen) auf "archived"
|
||||||
|
*
|
||||||
|
* Vorher wird alles Betroffene vollständig nach directus-backup-redesign-<zeit>.json gesichert.
|
||||||
|
* Archivieren lässt sich im Directus-Admin jederzeit rückgängig machen.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes("--apply");
|
||||||
|
|
||||||
|
function loadEnv(file) {
|
||||||
|
if (!fs.existsSync(file)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
fs
|
||||||
|
.readFileSync(file, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => /^[A-Z_]+=/.test(l))
|
||||||
|
.map((l) => {
|
||||||
|
const i = l.indexOf("=");
|
||||||
|
return [l.slice(0, i), l.slice(i + 1).replace(/^["']|["']$/g, "")];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = { ...loadEnv(path.join(process.cwd(), ".env")), ...process.env };
|
||||||
|
const BASE = env.DIRECTUS_URL || "https://cms.dk0.dev";
|
||||||
|
const TOKEN = env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
if (!TOKEN) {
|
||||||
|
console.error("DIRECTUS_STATIC_TOKEN fehlt (.env oder Umgebung).");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_KEYS = ["hero.badge", "hero.line1", "hero.line2", "about.quote.idle"];
|
||||||
|
const PAGE_SLUGS = ["home-hero", "home-hero-de-de", "home-about", "home-about-de-de"];
|
||||||
|
|
||||||
|
async function rest(method, p, body) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${method} ${p}: HTTP ${res.status} ${await res.text()}`);
|
||||||
|
return res.status === 204 ? null : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gql(query) {
|
||||||
|
const res = await fetch(BASE + "/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.errors) throw new Error(JSON.stringify(json.errors));
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hobbies = (await rest("GET", "/items/hobbies?fields=*,translations.*&limit=-1")).data;
|
||||||
|
const categories = (await rest("GET", "/items/tech_stack_categories?fields=*,translations.*&limit=-1")).data;
|
||||||
|
const messages = (await rest("GET", `/items/messages?fields=*,translations.*&limit=-1`)).data.filter((m) =>
|
||||||
|
MESSAGE_KEYS.includes(m.key),
|
||||||
|
);
|
||||||
|
const pages = (
|
||||||
|
await gql(`{ content_pages(limit: -1) { slug locale status title content meta_description keywords } }`)
|
||||||
|
).content_pages.filter((p) => PAGE_SLUGS.includes(p.slug));
|
||||||
|
|
||||||
|
const activeHobbies = hobbies.filter((h) => h.status !== "archived");
|
||||||
|
const activeCategories = categories.filter((c) => c.status !== "archived");
|
||||||
|
const activePages = pages.filter((p) => p.status !== "archived");
|
||||||
|
|
||||||
|
console.log(`${APPLY ? "AUSFÜHREN" : "PROBELAUF"} gegen ${BASE}\n`);
|
||||||
|
console.log(`hobbies archivieren: ${activeHobbies.length} (${activeHobbies.map((h) => h.key).join(", ") || "-"})`);
|
||||||
|
console.log(`tech_stack_categories archivieren: ${activeCategories.length} (${activeCategories.map((c) => c.key).join(", ") || "-"})`);
|
||||||
|
console.log(`messages löschen: ${messages.length} (${messages.map((m) => m.key).join(", ") || "-"})`);
|
||||||
|
console.log(`content_pages archivieren: ${activePages.length} (${activePages.map((p) => p.slug).join(", ") || "-"})`);
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log("\nNichts geändert. Mit --apply ausführen, wenn das Redesign auf production live ist.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupFile = path.join(process.cwd(), `directus-backup-redesign-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
||||||
|
fs.writeFileSync(backupFile, JSON.stringify({ hobbies, categories, messages, pages }, null, 2));
|
||||||
|
console.log(`\nSicherung: ${backupFile}`);
|
||||||
|
|
||||||
|
for (const h of activeHobbies) await rest("PATCH", `/items/hobbies/${h.id}`, { status: "archived" });
|
||||||
|
for (const c of activeCategories) await rest("PATCH", `/items/tech_stack_categories/${c.id}`, { status: "archived" });
|
||||||
|
for (const m of messages) await rest("DELETE", `/items/messages/${m.id}`);
|
||||||
|
for (const p of activePages) {
|
||||||
|
try {
|
||||||
|
await rest("PATCH", `/items/content_pages/${encodeURIComponent(p.slug)}`, { status: "archived" });
|
||||||
|
} catch {
|
||||||
|
// REST ist für content_pages teils gesperrt, GraphQL geht
|
||||||
|
await gql(`mutation { update_content_pages_item(id: "${p.slug}", data: { status: "archived" }) { slug } }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fertig.");
|
||||||
+25
-3
@@ -32,6 +32,14 @@ export default {
|
|||||||
"0%": { opacity: "0", transform: "translateY(-12px)" },
|
"0%": { opacity: "0", transform: "translateY(-12px)" },
|
||||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
},
|
},
|
||||||
|
wordmarkUp: {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(40%)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
"0%": { transform: "translateX(0)" },
|
||||||
|
"100%": { transform: "translateX(-50%)" },
|
||||||
|
},
|
||||||
scrollHint: {
|
scrollHint: {
|
||||||
"0%": { opacity: "0", transform: "scaleY(0.4)" },
|
"0%": { opacity: "0", transform: "scaleY(0.4)" },
|
||||||
"40%": { opacity: "1", transform: "scaleY(1)" },
|
"40%": { opacity: "1", transform: "scaleY(1)" },
|
||||||
@@ -43,6 +51,8 @@ export default {
|
|||||||
"slide-down": "slideDown 500ms cubic-bezier(0.23, 1, 0.32, 1) both",
|
"slide-down": "slideDown 500ms cubic-bezier(0.23, 1, 0.32, 1) both",
|
||||||
"scroll-hint": "scrollHint 2.4s cubic-bezier(0.77, 0, 0.175, 1) infinite",
|
"scroll-hint": "scrollHint 2.4s cubic-bezier(0.77, 0, 0.175, 1) infinite",
|
||||||
"spin-slow": "spin 4s linear infinite",
|
"spin-slow": "spin 4s linear infinite",
|
||||||
|
"wordmark-up": "wordmarkUp 900ms cubic-bezier(0.23, 1, 0.32, 1) both",
|
||||||
|
marquee: "marquee 32s linear infinite",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
@@ -78,6 +88,17 @@ export default {
|
|||||||
border: "var(--border)",
|
border: "var(--border)",
|
||||||
input: "var(--input)",
|
input: "var(--input)",
|
||||||
ring: "var(--ring)",
|
ring: "var(--ring)",
|
||||||
|
// Plakat-Design: Rot, Tinte, Papier. Tinte und Papier tauschen im Dark Mode die Rollen.
|
||||||
|
signal: "rgb(var(--plakat-red) / <alpha-value>)",
|
||||||
|
ink: "rgb(var(--plakat-ink) / <alpha-value>)",
|
||||||
|
paper: {
|
||||||
|
DEFAULT: "rgb(var(--plakat-paper) / <alpha-value>)",
|
||||||
|
2: "rgb(var(--plakat-paper-2) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
ok: "rgb(var(--plakat-ok) / <alpha-value>)",
|
||||||
|
// Feste Objektfarben auf dem Tisch, unabhängig vom Theme
|
||||||
|
polaroid: { DEFAULT: "#fbfaf8", ink: "#140c0b" },
|
||||||
|
bookcover: { DEFAULT: "#2b1a14", text: "#f3e7dc" },
|
||||||
// Warm brown palette
|
// Warm brown palette
|
||||||
cream: "#FAF8F3",
|
cream: "#FAF8F3",
|
||||||
sand: "#EFEBE9",
|
sand: "#EFEBE9",
|
||||||
@@ -122,9 +143,10 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-inter)", "sans-serif"],
|
sans: ["var(--font-geist)", "Helvetica Neue", "Arial", "sans-serif"],
|
||||||
serif: ["var(--font-playfair)", "Georgia", "serif"],
|
display: ["var(--font-bricolage)", "Avenir Next", "Segoe UI", "sans-serif"],
|
||||||
mono: ["var(--font-roboto-mono)", "Monaco", "Courier New", "monospace"],
|
serif: ["var(--font-bricolage)", "Avenir Next", "Segoe UI", "sans-serif"],
|
||||||
|
mono: ["var(--font-geist-mono)", "SF Mono", "Menlo", "monospace"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,51 +20,6 @@ export interface FooterTranslations {
|
|||||||
builtWith: string;
|
builtWith: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeroTranslations {
|
|
||||||
description: string;
|
|
||||||
ctaWork: string;
|
|
||||||
ctaContact: string;
|
|
||||||
features: {
|
|
||||||
f1: string;
|
|
||||||
f2: string;
|
|
||||||
f3: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AboutTranslations {
|
|
||||||
title: string;
|
|
||||||
p1: string;
|
|
||||||
p2: string;
|
|
||||||
p3: string;
|
|
||||||
funFactTitle: string;
|
|
||||||
funFactBody: string;
|
|
||||||
techStackTitle: string;
|
|
||||||
techStack: {
|
|
||||||
categories: {
|
|
||||||
frontendMobile: string;
|
|
||||||
backendDevops: string;
|
|
||||||
toolsAutomation: string;
|
|
||||||
securityAdmin: string;
|
|
||||||
};
|
|
||||||
items: {
|
|
||||||
selfHostedServices: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
hobbiesTitle: string;
|
|
||||||
hobbies: {
|
|
||||||
selfHosting: string;
|
|
||||||
gaming: string;
|
|
||||||
gameServers: string;
|
|
||||||
jogging: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectsTranslations {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
viewAll: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactTranslations {
|
export interface ContactTranslations {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user