5 Commits

Author SHA1 Message Date
1100e35091 Merge branch 'additional-ai-tools' 2026-06-18 05:37:16 +02:00
246d7339fb homepage, cv page layout 2026-06-18 05:37:11 +02:00
fb379b912a Merge branch 'additional-ai-tools' 2026-06-18 03:38:06 +02:00
ffa475e876 Merge branch 'blog-scroll' 2026-06-18 02:57:44 +02:00
d85cc205de blog slug scroll 2026-06-18 02:57:36 +02:00
8 changed files with 328 additions and 51 deletions

View File

@@ -0,0 +1,165 @@
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import Link from "next/link";
import { useRef } from "react";
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the
// outline on with a stroke-dash trick, fades the fill in, then keeps everything
// gently alive (floating logo, counter-rotating rings). A curved arrow in the
// lower-right corner, with its label above the tail, points at the chat FAB to
// nudge people toward the assistant.
export default function HomeHero() {
const root = useRef<HTMLDivElement>(null);
const gPath = useRef<SVGPathElement>(null);
useGSAP(
() => {
const logo = gPath.current;
if (logo) {
const len = logo.getTotalLength();
gsap.set(logo, { strokeDasharray: len, strokeDashoffset: len });
}
gsap.set(".hero-fill", { fillOpacity: 0 });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.from(
".hero-ring",
{ scale: 0, opacity: 0, duration: 0.9, stagger: 0.12, svgOrigin: "100 100" },
0,
);
if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35");
tl.from(".hero-line", { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.12 }, "-=0.25");
tl.from(".hero-arrow-label", { yPercent: 120, opacity: 0, duration: 0.6 }, "-=0.1");
tl.from(
".hero-arrow-svg",
{ opacity: 0, scale: 0.7, transformOrigin: "top left", duration: 0.6 },
"<+=0.1",
);
// Idle life — runs forever once the entrance has settled.
gsap.to(".hero-logo", { y: -12, duration: 3.2, ease: "sine.inOut", yoyo: true, repeat: -1 });
gsap.to(".hero-ring-spin", { rotation: 360, duration: 44, ease: "none", repeat: -1, svgOrigin: "100 100" });
gsap.to(".hero-ring-spin-rev", { rotation: -360, duration: 32, ease: "none", repeat: -1, svgOrigin: "100 100" });
gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
},
{ scope: root },
);
return (
<div
ref={root}
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center"
>
{/* Logo + orbiting rings */}
<div className="hero-logo relative h-56 w-56 sm:h-64 sm:w-64">
<svg
className="absolute inset-0 h-full w-full text-primary/40"
viewBox="0 0 200 200"
fill="none"
aria-hidden="true"
>
<circle
className="hero-ring hero-ring-spin"
cx="100"
cy="100"
r="92"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="2 10"
/>
<circle
className="hero-ring hero-ring-spin-rev"
cx="100"
cy="100"
r="78"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray="14 8"
opacity="0.6"
/>
<circle
className="hero-ring"
cx="100"
cy="100"
r="64"
stroke="currentColor"
strokeWidth="1"
opacity="0.35"
/>
</svg>
<svg
className="absolute inset-0 h-full w-full p-10 text-foreground"
viewBox="0 0 74.193405 74.232162"
aria-label="Gregor Lohaus logo"
>
<g transform="translate(-24.550957,-64.437925)">
<path
ref={gPath}
className="hero-fill"
d="m 61.66652,64.437927 c -20.498425,1.81e-4 -37.115669,16.617653 -37.115564,37.116083 -1.05e-4,20.49842 16.617139,37.1159 37.115564,37.11608 16.081184,-0.0265 30.316081,-10.4061 35.258313,-25.70903 1.144195,-3.51294 1.757471,-7.1771 1.819527,-10.87117 H 87.864404 67.217603 v 10.87117 h 17.977714 c -4.361366,9.03731 -13.494221,14.79672 -23.528797,14.83786 -14.494622,0 -26.244916,-11.75029 -26.244909,-26.24491 -7e-6,-14.494627 11.750287,-26.244918 26.244909,-26.244912 z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.9"
/>
<rect
className="hero-fill"
width="31.802109"
height="11.397169"
x="-96.2453"
y="67.460899"
transform="rotate(-90)"
fill="currentColor"
/>
</g>
</svg>
</div>
{/* Headline */}
<div className="mt-10 max-w-xl">
<h1 className="overflow-hidden pb-2">
<span className="hero-line block text-4xl font-semibold leading-tight tracking-tight sm:text-6xl">
Gregor Lohaus
</span>
</h1>
<div className="mt-4 overflow-hidden">
<p className="hero-line text-lg text-muted-foreground sm:text-xl">
Full Stack Developer
</p>
</div>
</div>
{/* Lower-right arrow pointing at the chat FAB */}
<Link
href="/assistant"
aria-label="Chat with my AI assistant"
className="hero-arrow-wrap absolute bottom-10 right-8 z-40 flex flex-col items-start text-foreground transition-opacity hover:opacity-80 sm:right-16"
>
<span className="mb-1 max-w-[11rem] -translate-x-20 -translate-y-2 overflow-hidden sm:-translate-x-24">
<span className="hero-arrow-label block font-semibold leading-snug text-foreground drop-shadow-[0_2px_10px_rgba(0,0,0,0.7)] sm:text-lg">
Chat with my AI&nbsp;assistant
</span>
</span>
<svg
className="hero-arrow-svg h-28 w-32 drop-shadow-[0_2px_10px_rgba(0,0,0,0.4)] sm:h-32 sm:w-36"
viewBox="0 0 776.09175 693.66538"
fill="currentColor"
aria-hidden="true"
>
<g transform="matrix(2.7190747,0,0,3.1037754,-326.9763,-1172.9045)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m 130.838,381.118 c 1.125,28.749 5.277,54.82 12.695,78.018 7.205,22.53 18.847,40.222 36.812,53.747 52.018,39.16 153.369,16.572 153.369,16.572 l -4.632,-32.843 72.918,42.778 -58.597,58.775 -3.85,-27.303 c 0,0 -100.347,18.529 -163.905,-34.881 -37.659,-31.646 -53.293,-84.021 -51.593,-153.962 0.266,-0.247 4.728,-0.908 6.783,-0.901 z"
/>
</g>
</svg>
</Link>
</div>
);
}

View File

@@ -32,7 +32,7 @@ export default async function BlogPostPage({ params }: Props) {
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-3xl font-bold">{title}</h1>
{date && (

View File

@@ -6,7 +6,7 @@ export default async function BlogPage() {
const posts = await servTrpc.blog.list();
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet.</p>

View File

@@ -5,7 +5,7 @@ import { cn } from "~/lib/utils"
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import type { ArrayElement } from "type-fest"
import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp"
import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
import { ScrollArea } from "~/components/ui/scroll-area";
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
@@ -18,22 +18,46 @@ type CvCategoryProps = {
}
export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
const entries = category.cvEntry
const isRowLayout = layout === "row"
const entryStart = position + 1
const entryStagger = 1.1
const entryItems = entries.map((entry, i) => {
const entryPosition = entryStart + i * entryStagger
return (
<AnimatedDiv
className={cn(isRowLayout ? "min-w-[min(100%,18rem)] flex-1" : "w-full", "opacity-0 -translate-x-6")}
position={entryPosition}
debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${entryPosition}`}
opacity={1}
x={0}
duration={0.4}
ease="power2.out"
key={entry.id}
>
<CvEntry position={entryPosition} entry={entry} description={descriptions[entry.id]} row={isRowLayout} className="w-full" />
</AnimatedDiv>
)
})
return (
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}>
<AnimatedCard position={position} className={cn(isRowLayout ? "h-fit min-w-[min(100%,18rem)] flex-1" : "lg:h-full lg:min-h-0")}>
<CardHeader>
<AnimateTextIn once position={position + 0.2} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.2}`}>
<AnimateTextIn once position={position + 0.35} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.35}`}>
<CardTitle>{category.name}</CardTitle>
</AnimateTextIn>
</CardHeader>
{entries.length > 0 ?
<CardContent className={cn(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}>
<ScrollArea>
{entries.map((entry, i) => (
<AnimatePopUp position={position + 0.4 + i * 0.2} debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${position + 0.4 + i * 0.2}`} key={entry.id}>
<CvEntry position={position + 0.4 + i * 0.2} entry={entry} description={descriptions[entry.id]} className={layout == "row" ? "w-full lg:w-fit" : undefined} />
</AnimatePopUp>
))}
</ScrollArea>
<CardContent className={cn(isRowLayout ? "flex flex-row flex-wrap items-stretch justify-center lg:justify-between" : "flex flex-col flex-1 min-h-0", "gap-4")}>
{isRowLayout ? (
entryItems
) : (
<ScrollArea className="min-h-0 w-full flex-1">
<div className="flex flex-col gap-4 pr-2">
{entryItems}
</div>
</ScrollArea>
)}
</CardContent>
:
<></>

View File

@@ -9,30 +9,34 @@ import type { CvCategoryData } from "./CvCategory"
export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']>
export default function CvEntry({ entry, description, className, position = 0 }: {
export default function CvEntry({ entry, description, className, position = 0, row = false }: {
entry: CvEntryData,
description?: ReactNode,
className?: string,
position?: number
position?: number,
row?: boolean,
}) {
const from = format(new Date(entry.fromTime), 'MMMM yyyy')
const to = format(new Date(entry.toTime), 'MMMM yyyy')
return (
<Card className={className ? cn("w-fit", className) : "w-fit"}>
<Card className={cn("w-full ring-0", row && "h-full", className)}>
{entry.title ?
<CardHeader>
<AnimateTextIn position={position} animation="slide" debugId={`cv-entry-title:${entry.title}:${position}`}>
<AnimateTextIn once position={position + 0.25} animation="slide" debugId={`cv-entry-title:${entry.title}:${position + 0.25}`}>
<CardTitle> {entry.title} </CardTitle>
</AnimateTextIn>
</CardHeader> :
<></>
}
{entry.description ?
<CardContent className="text-sm lg:text-base">
<CardContent className={cn("text-sm lg:text-base", row && "flex flex-1 items-center justify-center")}>
{/* Fade the description in place instead of collapsing its height:
the outer entry pop-up (CvCategory) measures height:auto when it
plays, so the description must stay laid out at full height or the
entry reveals too short. */}
<AnimatedDiv once position={position + 0.2} className="opacity-0" opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.2}`}>
<article className="prose prose-zinc dark:prose-invert max-w-none">
<AnimatedDiv once position={position + 0.75} className={cn("opacity-0", row && "w-full")} opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.75}`}>
<article className={cn("prose prose-zinc dark:prose-invert max-w-none", row && "text-center")}>
{description ?? entry.description}
</article>
</AnimatedDiv>
@@ -40,9 +44,9 @@ export default function CvEntry({ entry, description, className, position = 0 }:
<></>
}
{!entry.hideDates ?
<CardFooter className="text-sm">
<AnimateTextIn position={position + 0.4} debugId={`cv-entry-dates:${entry.title}:${position + 0.4}`}>
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`}
<CardFooter className="border-t-0 text-sm">
<AnimateTextIn once position={position + 1.15} debugId={`cv-entry-dates:${entry.title}:${position + 1.15}`}>
{`${from} to ${to}`}
</AnimateTextIn>
</CardFooter> :
<></>

View File

@@ -1,10 +1,12 @@
'use client'
import type { ReactNode } from "react";
import { Sidebar, SidebarContent, SidebarProvider } from "~/components/ui/sidebar";
import { Sidebar, SidebarContent, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
import { ScrollArea } from "~/components/ui/scroll-area";
import type { RouterOutputs } from "~/server/routers/_app"
import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
import CvCategory from "./CvCategory";
import { useTimeLine } from "~/app/_providers/GsapProvicer";
import { cn } from "~/lib/utils";
export default function CvPage(props: {
cv: RouterOutputs['categoryv2']['listAllWithEntries'],
descriptions: Record<string, ReactNode>,
@@ -17,43 +19,104 @@ export default function CvPage(props: {
const headerCategories = byPosition("header")
const col1Categories = byPosition("col1")
const col2Categories = byPosition("col2")
const hasSidebar = sidebarCategories.length > 0
const hasTwoMainColumns = col1Categories.length > 0 && col2Categories.length > 0
const mainColumnWidthClass = hasTwoMainColumns ? "lg:w-1/2 lg:h-full" : ""
const sequencePositions = <T extends { cvEntry: unknown[] }>(categories: T[], start = 0) => {
let cursor = start
const positions = categories.map((category) => {
const position = cursor
cursor += 1.8 + category.cvEntry.length * 1.2
return position
})
return { end: cursor, positions }
}
const headerSequence = sequencePositions(headerCategories)
const sidebarSequence = sequencePositions(sidebarCategories)
const contentStart = Math.max(headerSequence.end, sidebarSequence.end)
const col1Sequence = sequencePositions(col1Categories, contentStart)
const col2Sequence = sequencePositions(col2Categories, contentStart)
return (
<>
<SidebarProvider>
<SidebarProvider className="h-full min-h-0 overflow-hidden">
{sidebarCategories.length > 0 &&
<>
<SidebarTriggerDisappearsOnMobile />
<Sidebar>
<SidebarContent className="p-2 lg:pt-[3.2rem]">
{sidebarCategories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
<CvCategory layout="col" position={sidebarSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</SidebarContent>
</Sidebar>
</>
}
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap">
{headerCategories.map((cat, i) => (
<CvCategory layout="row" position={i} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4">
<div id="col1" className={`flex flex-col w-full ${col1Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col1Categories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="col2" className={`flex flex-col w-full ${col2Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</div>
<MainContent
hasSidebar={hasSidebar}
mainColumnWidthClass={mainColumnWidthClass}
headerCategories={headerCategories}
headerSequence={headerSequence}
col1Categories={col1Categories}
col1Sequence={col1Sequence}
col2Categories={col2Categories}
col2Sequence={col2Sequence}
descriptions={descriptions}
/>
</SidebarProvider>
</>
)
}
type Sequence = { positions: number[] }
type Category = NonNullable<RouterOutputs['categoryv2']['listAllWithEntries']>[number]
function MainContent(props: {
hasSidebar: boolean,
mainColumnWidthClass: string,
headerCategories: Category[],
headerSequence: Sequence,
col1Categories: Category[],
col1Sequence: Sequence,
col2Categories: Category[],
col2Sequence: Sequence,
descriptions: Record<string, ReactNode>,
}) {
const {
hasSidebar, mainColumnWidthClass, headerCategories, headerSequence,
col1Categories, col1Sequence, col2Categories, col2Sequence, descriptions,
} = props
const { open } = useSidebar()
return (
<ScrollArea className="h-full min-h-0 w-full flex-1">
<div
id="mainwrap"
className={cn(
"flex w-full flex-col gap-4 p-4 pt-8",
!hasSidebar && "lg:px-[15vw]",
hasSidebar && !open && "lg:px-[8vw]",
)}
>
<div id="header" className="flex w-full flex-row flex-wrap items-stretch gap-4">
{headerCategories.map((cat, i) => (
<CvCategory layout="row" position={headerSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="colwrapper" className="flex w-full flex-col gap-4 lg:h-[80vh] lg:flex-row">
<div id="col1" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col1Categories.map((cat, i) => (
<CvCategory layout="col" position={col1Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="col2" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={col2Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</ScrollArea>
)
}

View File

@@ -1,9 +1,9 @@
import HomeHero from "./_components/Home/HomeHero";
export default function HomePage() {
return (
<main>
<div>
hello world
</div>
<main className="h-full w-full">
<HomeHero />
</main>
);
}

View File

@@ -91,6 +91,26 @@ function Figure({
);
}
// A bare markdown image (![alt](src)) fills the prose width. Pass a numeric
// markdown title — ![alt](src "160") — to render it as a fixed-size, circular
// avatar instead (used by the CV header). Untitled images keep the old look.
function Img({ src, alt, title }: { src: string; alt?: string; title?: string }) {
const size = title && /^\d+$/.test(title) ? Number(title) : undefined;
if (size) {
return (
<img
src={src}
alt={alt ?? ""}
width={size}
height={size}
style={{ width: size, height: size }}
className="mx-auto !my-0 shrink-0 rounded-full object-cover ring-2 ring-foreground/10"
/>
);
}
return <img src={src} alt={alt ?? ""} className="w-full rounded-md border object-cover" />;
}
function PullQuote({ children }: { children: ReactNode }) {
return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
@@ -134,6 +154,7 @@ export const mdxComponents = {
ButtonLink,
Callout,
Figure,
img: Img,
Lead,
PullQuote,
TagList,