Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd46bcddc7 | |||
| c442aa447b | |||
| 32abc7f3ef | |||
| 87e337a3a0 |
@@ -58,6 +58,9 @@ coverage/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# boneyard generated bones
|
||||||
|
bones/*.bones.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function Skeleton({ children, loading }: { children: React.ReactNode; loading: boolean; name?: string; animate?: string; transition?: boolean | number }) {
|
||||||
|
if (loading) return <div data-testid="boneyard-skeleton">Loading...</div>;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -2,16 +2,13 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
|
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Mock next-intl completely to avoid ESM issues
|
|
||||||
jest.mock("next-intl", () => ({
|
jest.mock("next-intl", () => ({
|
||||||
useTranslations: () => (key: string) => key,
|
useTranslations: () => (key: string) => key,
|
||||||
useLocale: () => "en",
|
useLocale: () => "en",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock next/image
|
|
||||||
jest.mock("next/image", () => ({
|
jest.mock("next/image", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -22,8 +19,8 @@ describe("CurrentlyReading Component", () => {
|
|||||||
|
|
||||||
it("renders skeleton when loading", () => {
|
it("renders skeleton when loading", () => {
|
||||||
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
|
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
|
||||||
const { container } = render(<CurrentlyReadingComp />);
|
render(<CurrentlyReadingComp />);
|
||||||
expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
|
expect(screen.getByTestId("boneyard-skeleton")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a book when data is fetched", async () => {
|
it("renders a book when data is fetched", async () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jest.mock('next/navigation', () => ({
|
|||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
it('renders the header with the dk logo', () => {
|
it('renders the header with the dk logo', () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
expect(screen.getByText('dk')).toBeInTheDocument();
|
expect(screen.getByText('dk0')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for navigation links (appear in both desktop and mobile menus)
|
// Check for navigation links (appear in both desktop and mobile menus)
|
||||||
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
|
||||||
|
|
||||||
export type ProjectDetailData = {
|
export type ProjectDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -90,9 +91,13 @@ export default function ProjectDetailClient({
|
|||||||
{project.imageUrl ? (
|
{project.imageUrl ? (
|
||||||
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
<ProjectThumbnail
|
||||||
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
|
title={project.title}
|
||||||
</div>
|
category={project.category}
|
||||||
|
tags={project.tags}
|
||||||
|
slug={project.slug}
|
||||||
|
size="hero"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Skeleton } from "../components/ui/Skeleton";
|
import { Skeleton } from "../components/ui/Skeleton";
|
||||||
|
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
|
||||||
|
|
||||||
export type ProjectListItem = {
|
export type ProjectListItem = {
|
||||||
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
||||||
@@ -74,7 +75,7 @@ export default function ProjectsPageClient({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
Archive<span className="text-liquid-mint">.</span>
|
{tList("title")}<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||||
{tList("intro")}
|
{tList("intro")}
|
||||||
@@ -127,10 +128,20 @@ export default function ProjectsPageClient({
|
|||||||
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||||
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
|
||||||
{project.imageUrl && (
|
{project.imageUrl ? (
|
||||||
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||||
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||||
|
<ProjectThumbnail
|
||||||
|
title={project.title}
|
||||||
|
category={project.category}
|
||||||
|
tags={project.tags}
|
||||||
|
slug={project.slug}
|
||||||
|
size="card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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";
|
||||||
|
import "../../bones/registry";
|
||||||
|
|
||||||
const BackgroundBlobs = dynamic(
|
const BackgroundBlobs = dynamic(
|
||||||
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
|
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BookOpen } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
|
|
||||||
interface CurrentlyReading {
|
interface CurrentlyReading {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -55,31 +55,14 @@ const CurrentlyReading = () => {
|
|||||||
fetchCurrentlyReading();
|
fetchCurrentlyReading();
|
||||||
}, []); // Leeres Array = nur einmal beim Mount
|
}, []); // Leeres Array = nur einmal beim Mount
|
||||||
|
|
||||||
if (loading) {
|
// Zeige nichts wenn kein Buch gelesen wird
|
||||||
return (
|
if (books.length === 0 && !loading) {
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
|
||||||
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg shrink-0" />
|
|
||||||
<div className="flex-1 space-y-3 w-full">
|
|
||||||
<Skeleton className="h-6 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
<div className="space-y-2 pt-4">
|
|
||||||
<Skeleton className="h-2 w-full" />
|
|
||||||
<Skeleton className="h-2 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
|
|
||||||
if (books.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Skeleton name="currently-reading" loading={loading} animate="shimmer" transition>
|
||||||
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
@@ -170,8 +153,9 @@ const CurrentlyReading = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Skeleton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const Header = () => {
|
|||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
|
||||||
>
|
>
|
||||||
<span className="font-black text-xs tracking-tighter">dk</span>
|
<span className="font-black text-xs tracking-tighter">dk0</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SiGithub, SiLinkedin } from "react-icons/si";
|
|||||||
import Link from "next/link";
|
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";
|
||||||
|
|
||||||
// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
|
// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
|
||||||
const MenuIcon = ({ size = 24 }: { size?: number }) => (
|
const MenuIcon = ({ size = 24 }: { size?: number }) => (
|
||||||
@@ -128,6 +129,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Language Switcher Mobile */}
|
{/* Language Switcher Mobile */}
|
||||||
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
|
<div className="flex items-center gap-2 mt-6 pt-6 border-t border-stone-200">
|
||||||
<Link
|
<Link
|
||||||
href={enHref}
|
href={enHref}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
@@ -211,6 +213,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-stone-200">
|
<div className="mt-8 pt-6 border-t border-stone-200">
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useId } from "react";
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Smartphone,
|
||||||
|
Globe,
|
||||||
|
Code,
|
||||||
|
LayoutDashboard,
|
||||||
|
MessageSquare,
|
||||||
|
Cloud,
|
||||||
|
Wrench,
|
||||||
|
Cpu,
|
||||||
|
Shield,
|
||||||
|
Boxes,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ProjectThumbnailProps {
|
||||||
|
title: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
slug?: string;
|
||||||
|
size?: "card" | "hero";
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryThemes: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
icon: LucideIcon;
|
||||||
|
gradient: string;
|
||||||
|
darkGradient: string;
|
||||||
|
iconColor: string;
|
||||||
|
darkIconColor: string;
|
||||||
|
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
"Web Development": {
|
||||||
|
icon: Code,
|
||||||
|
gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20",
|
||||||
|
darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10",
|
||||||
|
iconColor: "text-blue-500",
|
||||||
|
darkIconColor: "dark:text-blue-400",
|
||||||
|
pattern: "circuit",
|
||||||
|
},
|
||||||
|
"Mobile Development": {
|
||||||
|
icon: Smartphone,
|
||||||
|
gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20",
|
||||||
|
darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10",
|
||||||
|
iconColor: "text-emerald-500",
|
||||||
|
darkIconColor: "dark:text-emerald-400",
|
||||||
|
pattern: "waves",
|
||||||
|
},
|
||||||
|
"Web Application": {
|
||||||
|
icon: Globe,
|
||||||
|
gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20",
|
||||||
|
darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10",
|
||||||
|
iconColor: "text-violet-500",
|
||||||
|
darkIconColor: "dark:text-violet-400",
|
||||||
|
pattern: "dots",
|
||||||
|
},
|
||||||
|
"Backend Development": {
|
||||||
|
icon: Cpu,
|
||||||
|
gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20",
|
||||||
|
darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10",
|
||||||
|
iconColor: "text-amber-500",
|
||||||
|
darkIconColor: "dark:text-amber-400",
|
||||||
|
pattern: "grid",
|
||||||
|
},
|
||||||
|
"Full-Stack Development": {
|
||||||
|
icon: Boxes,
|
||||||
|
gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20",
|
||||||
|
darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10",
|
||||||
|
iconColor: "text-teal-500",
|
||||||
|
darkIconColor: "dark:text-teal-400",
|
||||||
|
pattern: "grid",
|
||||||
|
},
|
||||||
|
DevOps: {
|
||||||
|
icon: Shield,
|
||||||
|
gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20",
|
||||||
|
darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10",
|
||||||
|
iconColor: "text-red-500",
|
||||||
|
darkIconColor: "dark:text-red-400",
|
||||||
|
pattern: "diagonal",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
icon: Wrench,
|
||||||
|
gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20",
|
||||||
|
darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10",
|
||||||
|
iconColor: "text-stone-400",
|
||||||
|
darkIconColor: "dark:text-stone-500",
|
||||||
|
pattern: "dots",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const slugIcons: Record<string, LucideIcon> = {
|
||||||
|
"kernel-panic-404-interactive-terminal": Terminal,
|
||||||
|
"portfolio-website": LayoutDashboard,
|
||||||
|
"real-time-chat-application": MessageSquare,
|
||||||
|
"weather-forecast-app": Cloud,
|
||||||
|
"clarity": Smartphone,
|
||||||
|
"e-commerce-platform-api": Boxes,
|
||||||
|
"task-management-dashboard": LayoutDashboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
|
||||||
|
const patterns: Record<string, React.ReactNode> = {
|
||||||
|
dots: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-dots-${id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="2" cy="2" r="1.5" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-dots-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
grid: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-grid-${id}`} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-grid-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
diagonal: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-diag-${id}`} x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
|
<line x1="0" y1="24" x2="24" y2="0" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-diag-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
circuit: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-circ-${id}`} x="0" y="0" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M0 30h20m20 0h20M30 0v20m0 20v20" stroke="currentColor" strokeWidth="0.8" fill="none" />
|
||||||
|
<circle cx="30" cy="30" r="3" fill="currentColor" />
|
||||||
|
<circle cx="10" cy="30" r="2" fill="currentColor" />
|
||||||
|
<circle cx="50" cy="30" r="2" fill="currentColor" />
|
||||||
|
<circle cx="30" cy="10" r="2" fill="currentColor" />
|
||||||
|
<circle cx="30" cy="50" r="2" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-circ-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
waves: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-wave-${id}`} x="0" y="0" width="100" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M0 10 Q25 0 50 10 T100 10" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-wave-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
terminal: (
|
||||||
|
<svg className="absolute inset-0 w-full h-full opacity-[0.08] dark:opacity-[0.06]" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id={`pat-term-${id}`} x="0" y="0" width="200" height="30" patternUnits="userSpaceOnUse">
|
||||||
|
<text x="4" y="18" fontFamily="monospace" fontSize="10" fill="currentColor">$_</text>
|
||||||
|
<text x="50" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
||||||
|
<text x="70" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.6">404</text>
|
||||||
|
<text x="110" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
||||||
|
<text x="130" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.3">ERR</text>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#pat-term-${id})`} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return patterns[pattern] || patterns.dots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectThumbnail({
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
slug,
|
||||||
|
size = "card",
|
||||||
|
}: ProjectThumbnailProps) {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const theme = useMemo(() => {
|
||||||
|
if (slug && slugIcons[slug]) {
|
||||||
|
const matchedTheme = categoryThemes[category || ""] || categoryThemes.default;
|
||||||
|
return { ...matchedTheme, icon: slugIcons[slug] };
|
||||||
|
}
|
||||||
|
return categoryThemes[category || ""] || categoryThemes.default;
|
||||||
|
}, [category, slug]);
|
||||||
|
|
||||||
|
const Icon = theme.icon;
|
||||||
|
const isHero = size === "hero";
|
||||||
|
const displayTags = tags?.slice(0, 3) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center bg-gradient-to-br ${theme.gradient} ${theme.darkGradient}`}
|
||||||
|
>
|
||||||
|
<PatternOverlay pattern={theme.pattern} id={uniqueId} />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-sm border border-white/40 dark:border-white/10 ${theme.iconColor} ${theme.darkIconColor} ${isHero ? "w-20 h-20 sm:w-28 sm:h-28" : "w-14 h-14 sm:w-20 sm:h-20"}`}
|
||||||
|
>
|
||||||
|
<Icon className={isHero ? "w-10 h-10 sm:w-14 sm:h-14" : "w-7 h-7 sm:w-10 sm:h-10"} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`font-black tracking-tighter uppercase ${isHero ? "text-2xl sm:text-4xl md:text-5xl" : "text-sm sm:text-lg"} text-stone-400/80 dark:text-stone-500/80`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{displayTags.length > 0 && (
|
||||||
|
<div className={`flex flex-wrap justify-center gap-1.5 sm:gap-2 ${isHero ? "max-w-md" : "max-w-[200px]"}`}>
|
||||||
|
{displayTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`px-2 py-0.5 rounded-full bg-white/50 dark:bg-white/5 backdrop-blur-sm border border-white/30 dark:border-white/10 text-stone-500 dark:text-stone-400 font-medium ${isHero ? "text-xs sm:text-sm" : "text-[9px] sm:text-[10px]"}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
-22
@@ -6,7 +6,8 @@ import { ArrowUpRight } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
|
import ProjectThumbnail from "./ProjectThumbnail";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,7 +28,7 @@ const Projects = () => {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
useTranslations("home.projects");
|
const t = useTranslations("home.projects");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
@@ -52,31 +53,22 @@ const Projects = () => {
|
|||||||
<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 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>
|
<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">
|
<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">
|
||||||
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
||||||
Projects that pushed my boundaries.
|
{t("subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-all text-xs uppercase tracking-widest">
|
<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-all text-xs uppercase tracking-widest">
|
||||||
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Skeleton name="projects-grid" loading={loading} animate="shimmer" transition>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
||||||
{loading ? (
|
{projects.length === 0 && !loading ? (
|
||||||
Array.from({ length: 2 }).map((_, i) => (
|
|
||||||
<div key={i} className="space-y-6">
|
|
||||||
<Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-8 w-1/2" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
||||||
No projects yet.
|
{t("noProjects")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((project) => (
|
projects.map((project) => (
|
||||||
@@ -95,9 +87,13 @@ const Projects = () => {
|
|||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
|
<ProjectThumbnail
|
||||||
<span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
|
title={project.title}
|
||||||
</div>
|
category={project.category}
|
||||||
|
tags={project.tags}
|
||||||
|
slug={project.slug}
|
||||||
|
size="card"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Overlay on Hover */}
|
{/* Overlay on Hover */}
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
||||||
@@ -125,6 +121,7 @@ const Projects = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)))}
|
)))}
|
||||||
</div>
|
</div>
|
||||||
|
</Skeleton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
|
|
||||||
interface BookReview {
|
interface BookReview {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,6 +48,7 @@ const ReadBooks = () => {
|
|||||||
const [reviews, setReviews] = useState<BookReview[]>([]);
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const INITIAL_SHOW = 3;
|
const INITIAL_SHOW = 3;
|
||||||
|
|
||||||
@@ -82,25 +83,7 @@ const ReadBooks = () => {
|
|||||||
fetchReviews();
|
fetchReviews();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
if (loading) {
|
if (reviews.length === 0 && !loading) {
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div key={i} className="flex flex-col sm:flex-row gap-4 items-start">
|
|
||||||
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg shrink-0" />
|
|
||||||
<div className="flex-1 space-y-2 w-full">
|
|
||||||
<Skeleton className="h-5 w-1/2" />
|
|
||||||
<Skeleton className="h-4 w-1/3" />
|
|
||||||
<Skeleton className="h-3 w-1/4 pt-2" />
|
|
||||||
<Skeleton className="h-12 w-full pt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviews.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
|
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
|
||||||
<BookCheck size={16} className="shrink-0" />
|
<BookCheck size={16} className="shrink-0" />
|
||||||
@@ -113,7 +96,8 @@ const ReadBooks = () => {
|
|||||||
const hasMore = reviews.length > INITIAL_SHOW;
|
const hasMore = reviews.length > INITIAL_SHOW;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Skeleton name="read-books" loading={loading} animate="shimmer" transition>
|
||||||
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
@@ -198,9 +182,27 @@ const ReadBooks = () => {
|
|||||||
|
|
||||||
{/* Review Text (Optional) */}
|
{/* Review Text (Optional) */}
|
||||||
{review.review && (
|
{review.review && (
|
||||||
<p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed italic">
|
<div className="relative">
|
||||||
“{stripHtml(review.review)}”
|
<p className={`text-sm text-stone-700 dark:text-stone-300 leading-relaxed italic ${!expandedReviews.has(review.id) ? 'line-clamp-3' : ''}`}>
|
||||||
</p>
|
“{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 */}
|
{/* Finished Date */}
|
||||||
@@ -240,8 +242,8 @@ const ReadBooks = () => {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</Skeleton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ export default function PrivacyPolicy() {
|
|||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
<Shield className="text-liquid-mint" size={28} /> Allgemeiner Überblick
|
<Shield className="text-liquid-mint" size={28} /> Verantwortlicher
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-2">
|
||||||
Der Schutz Ihrer persönlichen Daten ist mir ein besonderes Anliegen. Ich verarbeite Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, TMG).
|
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
|
||||||
</p>
|
<p>Auf dem Ziegenbrink 2B</p>
|
||||||
|
<p>49082 Osnabrück, Deutschland</p>
|
||||||
|
<p>E-Mail: <a href="mailto:contact@dk0.dev" className="text-liquid-mint hover:underline">contact@dk0.dev</a></p>
|
||||||
|
<p className="text-sm text-stone-500 dark:text-stone-400 mt-4">Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -73,8 +77,80 @@ export default function PrivacyPolicy() {
|
|||||||
<Database className="text-liquid-sky" size={28} /> Datenerfassung
|
<Database className="text-liquid-sky" size={28} /> Datenerfassung
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
Wenn Sie per Formular auf der Website oder per E-Mail Kontakt mit mir aufnehmen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage bei mir gespeichert.
|
Beim Zugriff auf diese Website werden automatisch Informationen allgemeiner Natur erfasst. Diese beinhalten unter anderem:
|
||||||
</p>
|
</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> IP-Adresse (in anonymisierter Form)</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Uhrzeit und Datum des Zugriffs</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Browsertyp und Betriebssystem</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Referrer-URL (die zuvor besuchte Seite)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mt-4">
|
||||||
|
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen. Rückschlüsse auf Ihre Person sind nicht möglich.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Analyse- und Tracking-Tools</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Zur Analyse der Nutzung dieser Website setze ich <strong className="text-stone-900 dark:text-stone-100">Umami</strong> ein. Umami speichert keine IP-Adressen oder Cookies. Alle erfassten Daten sind anonymisiert. Da ich Umami auf meinem eigenen Server betreibe, erfolgt keine Weitergabe an Dritte. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes Interesse an der Analyse und Optimierung der Website).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Kontaktformular</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Wenn Sie das Kontaktformular nutzen oder per E-Mail Kontakt aufnehmen, werden Ihre Angaben zur Bearbeitung Ihrer Anfrage gespeichert. Diese Daten werden nicht an Dritte weitergegeben und nach Erfüllung des Zwecks gelöscht. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Social Media Links</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Diese Website enthält Links zu GitHub und LinkedIn. Durch das Anklicken dieser Links gelten die Datenschutzbestimmungen der jeweiligen Anbieter.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Weitergabe von Daten</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mb-4">Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:</p>
|
||||||
|
<ul className="space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt haben,</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO erforderlich ist,</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> eine gesetzliche Verpflichtung nach Art. 6 Abs. 1 S. 1 lit. c DSGVO besteht, oder</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung berechtigter Interessen erforderlich ist.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Ihre Rechte</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mb-4">Sie haben gemäß DSGVO folgende Rechte:</p>
|
||||||
|
<ul className="space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 15 DSGVO: Auskunftsrecht über Ihre gespeicherten Daten</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 16 DSGVO: Recht auf Berichtigung unrichtiger Daten</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 17 DSGVO: Recht auf Löschung (soweit keine Aufbewahrungspflichten entgegenstehen)</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
|
||||||
|
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mt-4">
|
||||||
|
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde richten: <a href="https://www.bfdi.bund.de/" className="text-liquid-mint hover:underline" target="_blank" rel="noopener noreferrer">bfdi.bund.de</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Datensicherheit</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten zu schützen. Dazu gehören unter anderem die SSL-Verschlüsselung. Diese Verschlüsselung erkennen Sie an dem Schloss-Symbol in der Adresszeile Ihres Browsers und an der URL, die mit “https://” beginnt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Änderungen</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen. Die jeweils aktuelle Version finden Sie auf dieser Seite.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-stone-400 dark:text-stone-500 mt-6">Letzte Aktualisierung: April 2025</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Boneyard skeleton registry - auto-generated by `npx boneyard-js build`
|
||||||
|
// This file is imported once at app initialization to register all bone layouts.
|
||||||
|
// Run `npx boneyard-js build` to generate bone files from your components.
|
||||||
|
const registry: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
export default registry;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"breakpoints": [375, 768, 1280],
|
||||||
|
"out": "./bones",
|
||||||
|
"wait": 800,
|
||||||
|
"color": "rgba(0,0,0,0.08)",
|
||||||
|
"animate": "pulse"
|
||||||
|
}
|
||||||
+2
-1
@@ -16,11 +16,12 @@ const config: Config = {
|
|||||||
testPathIgnorePatterns: ["/node_modules/", "/__mocks__/", "/.next/", "/e2e/"],
|
testPathIgnorePatterns: ["/node_modules/", "/__mocks__/", "/.next/", "/e2e/"],
|
||||||
// Transform react-markdown and other ESM modules
|
// Transform react-markdown and other ESM modules
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
"node_modules/(?!(react-markdown|remark-.*|rehype-.*|unified|bail|is-plain-obj|trough|vfile|vfile-message|unist-.*|micromark|parse-entities|character-entities|mdast-.*|hast-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces|zwitch|longest-streak|ccount)/)",
|
"node_modules/(?!(react-markdown|remark-.*|rehype-.*|unified|bail|is-plain-obj|trough|vfile|vfile-message|unist-.*|micromark|parse-entities|character-entities|mdast-.*|hast-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces|zwitch|longest-streak|ccount|boneyard-js)/)",
|
||||||
],
|
],
|
||||||
// Module name mapping to fix haste collision
|
// Module name mapping to fix haste collision
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@/(.*)$": "<rootDir>/$1",
|
"^@/(.*)$": "<rootDir>/$1",
|
||||||
|
"^boneyard-js/react$": "<rootDir>/__mocks__/boneyard-js/react.tsx",
|
||||||
},
|
},
|
||||||
// Exclude problematic directories from haste
|
// Exclude problematic directories from haste
|
||||||
modulePathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/", "<rootDir>/e2e/"],
|
modulePathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/", "<rootDir>/e2e/"],
|
||||||
|
|||||||
+6
-3
@@ -73,6 +73,8 @@
|
|||||||
"finishedAt": "Beendet am",
|
"finishedAt": "Beendet am",
|
||||||
"showMore": "{count} weitere anzeigen",
|
"showMore": "{count} weitere anzeigen",
|
||||||
"showLess": "Weniger anzeigen",
|
"showLess": "Weniger anzeigen",
|
||||||
|
"readMore": "Weiterlesen",
|
||||||
|
"collapseReview": "Weniger anzeigen",
|
||||||
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
|
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@@ -84,10 +86,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Ausgewählte Projekte",
|
"title": "Ausgewählte Arbeiten",
|
||||||
"subtitle": "Eine Auswahl an Projekten, an denen ich gearbeitet habe – von Web-Apps bis zu Experimenten.",
|
"subtitle": "Projekte, die meine Grenzen erweitert haben.",
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
"viewAll": "Alle Projekte ansehen"
|
"viewAll": "Archiv ansehen",
|
||||||
|
"noProjects": "Noch keine Projekte."
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
|
|||||||
+6
-3
@@ -74,6 +74,8 @@
|
|||||||
"finishedAt": "Finished",
|
"finishedAt": "Finished",
|
||||||
"showMore": "{count} more",
|
"showMore": "{count} more",
|
||||||
"showLess": "Show less",
|
"showLess": "Show less",
|
||||||
|
"readMore": "Read more",
|
||||||
|
"collapseReview": "Show less",
|
||||||
"empty": "Books finished in Hardcover will appear here automatically."
|
"empty": "Books finished in Hardcover will appear here automatically."
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@@ -85,10 +87,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Selected Works",
|
"title": "Selected Work",
|
||||||
"subtitle": "A collection of projects I've worked on, ranging from web applications to experiments.",
|
"subtitle": "Projects that pushed my boundaries.",
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
"viewAll": "View All Projects"
|
"viewAll": "View Archive",
|
||||||
|
"noProjects": "No projects yet."
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Contact Me",
|
"title": "Contact Me",
|
||||||
|
|||||||
Generated
+55
-2
@@ -20,6 +20,7 @@
|
|||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
|
"boneyard-js": "^1.7.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"framer-motion": "^12.24.10",
|
"framer-motion": "^12.24.10",
|
||||||
@@ -595,6 +596,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@chenglou/pretext": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
@@ -5437,6 +5445,53 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boneyard-js": {
|
||||||
|
"version": "1.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/boneyard-js/-/boneyard-js-1.7.6.tgz",
|
||||||
|
"integrity": "sha512-9K3+cD684J131itS2iUI1+dsWqE7K3hSZid0nyXeBxtSTWQTFSpnGM7xmup16QwtmeGR70MPvpzluhA2Nl5LuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"boneyard-js": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@chenglou/pretext": "^0.0.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/core": ">=14",
|
||||||
|
"preact": ">=10",
|
||||||
|
"react": ">=18",
|
||||||
|
"react-native": ">=0.71",
|
||||||
|
"svelte": ">=5.29",
|
||||||
|
"vite": ">=5",
|
||||||
|
"vue": ">=3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@angular/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"preact": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/boolbase": {
|
"node_modules/boolbase": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
@@ -12015,7 +12070,6 @@
|
|||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.58.2"
|
||||||
@@ -12034,7 +12088,6 @@
|
|||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
|
"boneyard-js": "^1.7.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"framer-motion": "^12.24.10",
|
"framer-motion": "^12.24.10",
|
||||||
|
|||||||
Reference in New Issue
Block a user