Compare commits
5 Commits
additional
...
1100e35091
| Author | SHA1 | Date | |
|---|---|---|---|
| 1100e35091 | |||
| 246d7339fb | |||
| fb379b912a | |||
| ffa475e876 | |||
| d85cc205de |
165
src/app/_components/Home/HomeHero.tsx
Normal file
165
src/app/_components/Home/HomeHero.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
:
|
||||
<></>
|
||||
|
||||
@@ -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> :
|
||||
<></>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,26 @@ function Figure({
|
||||
);
|
||||
}
|
||||
|
||||
// A bare markdown image () fills the prose width. Pass a numeric
|
||||
// markdown title —  — 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,
|
||||
|
||||
Reference in New Issue
Block a user