Compare commits
7 Commits
background
...
mdx-refact
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ba2b573d | |||
| c1fe73dbd0 | |||
| af4ff18917 | |||
| b59fb2b3af | |||
| 58dc4ce53f | |||
| 1b3f30cc90 | |||
| fd5063d1c4 |
3
bun.lock
3
bun.lock
@@ -16,6 +16,8 @@
|
|||||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@mdx-js/mdx": "^3.1.1",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -79,7 +81,6 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.77.0",
|
"react-hook-form": "^7.77.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@mdx-js/mdx": "^3.1.1",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -94,7 +96,6 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.77.0",
|
"react-hook-form": "^7.77.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Control, FieldValues, Path } from "react-hook-form";
|
|||||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { ClientMdx } from "~/components/ClientMdx";
|
||||||
import {
|
import {
|
||||||
InternalLinkTextarea,
|
InternalLinkTextarea,
|
||||||
type AutocompleteTriggerConfig,
|
type AutocompleteTriggerConfig,
|
||||||
@@ -81,7 +82,7 @@ export default function MdeFormField<T extends FieldValues>(params: {
|
|||||||
),
|
),
|
||||||
preview: params.renderPreview
|
preview: params.renderPreview
|
||||||
? (source) => params.renderPreview?.(source) ?? <></>
|
? (source) => params.renderPreview?.(source) ?? <></>
|
||||||
: undefined,
|
: (source) => <ClientMdx source={source} fallback={source} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { MDXRemote } from 'next-mdx-remote'
|
import { MDXRemote } from 'next-mdx-remote'
|
||||||
import { serialize } from 'next-mdx-remote/serialize'
|
import { serialize } from 'next-mdx-remote/serialize'
|
||||||
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||||
import { mdxComponents } from '~/app/blog/_components/mdx-components'
|
import { mdxComponents } from '~/components/mdx-components'
|
||||||
|
|
||||||
export default function BlogMdxEditorPreview(params: { source: string }) {
|
export default function BlogMdxEditorPreview(params: { source: string }) {
|
||||||
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import matter from "gray-matter";
|
||||||
import { servTrpc } from "~/app/_trpc/ServerClient";
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { mdxComponents } from "../_components/mdx-components";
|
import { mdxComponents } from "~/components/mdx-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -12,37 +13,47 @@ type Props = {
|
|||||||
export default async function BlogPostPage({ params }: Props) {
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
let post: Awaited<ReturnType<typeof servTrpc.blog.bySlug>>;
|
let post: Awaited<ReturnType<typeof servTrpc.blog.metadataBySlug>>;
|
||||||
try {
|
try {
|
||||||
post = await servTrpc.blog.bySlug(slug);
|
post = await servTrpc.blog.metadataBySlug(slug);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetch(post.fileUrl, { next: { revalidate: 3600 } });
|
||||||
|
if (!response.ok) notFound();
|
||||||
|
|
||||||
|
const parsed = matter(await response.text());
|
||||||
|
const tags = Array.isArray(parsed.data.tags)
|
||||||
|
? parsed.data.tags.map((tag) => String(tag).trim()).filter(Boolean)
|
||||||
|
: post.tags;
|
||||||
|
const title = typeof parsed.data.title === "string" ? parsed.data.title : post.title;
|
||||||
|
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
{post.date && (
|
{date && (
|
||||||
<time className="text-muted-foreground text-sm">
|
<time className="text-muted-foreground text-sm">
|
||||||
{new Date(post.date).toLocaleDateString("en-US", {
|
{new Date(date).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
)}
|
)}
|
||||||
{post.tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{post.tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<article className="prose dark:prose-invert max-w-none">
|
<article className="prose dark:prose-invert max-w-none">
|
||||||
<MDXRemote source={post.content} components={mdxComponents} />
|
<MDXRemote source={parsed.content} components={mdxComponents} />
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import Markdown from "react-markdown";
|
import { ClientMdx } from "~/components/ClientMdx";
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
|
|
||||||
export const AssistantMessage = (props: { message: UIMessage }) => {
|
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||||
let message = props.message;
|
let message = props.message;
|
||||||
@@ -16,9 +15,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
|||||||
{message.parts.map((part, i) => {
|
{message.parts.map((part, i) => {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<Markdown key={crypto.randomUUID()}>
|
<ClientMdx key={i} source={part.text} fallback={part.text} />
|
||||||
{part.text}
|
|
||||||
</Markdown>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (part.type === 'tool-scheduleMeeting') {
|
if (part.type === 'tool-scheduleMeeting') {
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { trpc } from "~/app/_trpc/Client"
|
|
||||||
import CvEntry from "./CvEntry"
|
import CvEntry from "./CvEntry"
|
||||||
import type { RouterOutputs } from "~/server/routers/_app"
|
import type { RouterOutputs } from "~/server/routers/_app"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
import type { ArrayElement } from "type-fest"
|
import type { ArrayElement } from "type-fest"
|
||||||
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp";
|
import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
|
||||||
|
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp"
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
|
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
|
||||||
|
|
||||||
type CvCategoryProps = {
|
type CvCategoryProps = {
|
||||||
initialData: ArrayElement<RouterOutputs['categoryv2']['listByLayoutPosition']>,
|
category: CvCategoryData,
|
||||||
layout: "row" | "col",
|
layout: "row" | "col",
|
||||||
position?: number,
|
position?: number,
|
||||||
children?: React.ReactElement<Parameters<typeof CvEntry>>
|
|
||||||
}
|
}
|
||||||
export default function CvCategory(props:CvCategoryProps) {
|
export default function CvCategory({ category, layout, position = 0 }: CvCategoryProps) {
|
||||||
const category = trpc.categoryv2.getById.useQuery(props.initialData? props.initialData.id : "");
|
const entries = category.cvEntry
|
||||||
const entries = trpc.entryv2.byCategoryAndToDateDescending.useQuery(category.data?.id || "")
|
|
||||||
const position = props.position ?? 0
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard position={position} className={cn(props.layout == "row" ? "w-full" : "", "h-screen")}>
|
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<AnimateTextIn position={position + 0.2} animation="slide">
|
||||||
{category.data?.name}
|
<CardTitle>{category.name}</CardTitle>
|
||||||
</CardTitle>
|
</AnimateTextIn>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{(entries.data?.length ? entries.data?.length : 0 ) > 0 ?
|
{entries.length > 0 ?
|
||||||
<CardContent className={cn(props.layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col","gap-4","overflow-scroll")}>
|
<CardContent className={cn(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
{entries.data?.map((entry,i) => (
|
{entries.map((entry, i) => (
|
||||||
<AnimatePopUp position={position + 0.4 + i * 0.2} key={entry.id}>
|
<AnimatePopUp position={position + 0.4 + i * 0.2} key={entry.id}>
|
||||||
<CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} initialData={entry}/>
|
<CvEntry position={position + 0.4 + i * 0.2} entry={entry} className={layout == "row" ? "w-full lg:w-fit" : undefined} />
|
||||||
</AnimatePopUp>
|
</AnimatePopUp>
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,68 +1,47 @@
|
|||||||
import { trpc } from "~/app/_trpc/Client"
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
import { Skeleton } from "~/components/ui/skeleton"
|
import { cn } from "~/lib/utils"
|
||||||
import { cn, type Defined } from "~/lib/utils"
|
|
||||||
import Markdown from 'react-markdown'
|
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
|
||||||
import rehypeRaw from 'rehype-raw'
|
|
||||||
import type { RouterOutputs } from "~/server/routers/_app"
|
|
||||||
import type { ArrayElement } from "type-fest"
|
import type { ArrayElement } from "type-fest"
|
||||||
export default function CvEntry(params: {
|
import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
|
||||||
initialData: ArrayElement<Defined<RouterOutputs['categoryv2']['getById']>['cvEntry']>,
|
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp"
|
||||||
className?: string
|
import type { CvCategoryData } from "./CvCategory"
|
||||||
|
import { ClientMdx } from "~/components/ClientMdx"
|
||||||
|
|
||||||
|
export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']>
|
||||||
|
|
||||||
|
export default function CvEntry({ entry, className, position = 0 }: {
|
||||||
|
entry: CvEntryData,
|
||||||
|
className?: string,
|
||||||
|
position?: number
|
||||||
}) {
|
}) {
|
||||||
const query = trpc.entryv2.getById.useQuery(params.initialData.id);
|
|
||||||
const { data, isError, error } = query
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card className={className ? cn("w-fit", className) : "w-fit"}>
|
||||||
{
|
{entry.title ?
|
||||||
data ?
|
|
||||||
<>
|
|
||||||
<Card className={params.className ? cn("w-fit", params.className) : "w-fit"}>
|
|
||||||
{
|
|
||||||
data.title ?
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle> {data.title} </CardTitle>
|
<AnimateTextIn position={position} animation="slide">
|
||||||
|
<CardTitle> {entry.title} </CardTitle>
|
||||||
|
</AnimateTextIn>
|
||||||
</CardHeader> :
|
</CardHeader> :
|
||||||
<></>
|
<></>
|
||||||
}
|
}
|
||||||
{
|
{entry.description ?
|
||||||
data.description ?
|
|
||||||
<CardContent className="text-sm lg:text-base">
|
<CardContent className="text-sm lg:text-base">
|
||||||
|
<AnimatePopUp position={position + 0.2}>
|
||||||
<article className="prose prose-zinc dark:prose-invert max-w-none">
|
<article className="prose prose-zinc dark:prose-invert max-w-none">
|
||||||
<Markdown rehypePlugins={[rehypeHighlight, rehypeRaw]}>{data.description}</Markdown>
|
<ClientMdx source={entry.description} fallback={entry.description} />
|
||||||
</article>
|
</article>
|
||||||
|
</AnimatePopUp>
|
||||||
</CardContent> :
|
</CardContent> :
|
||||||
<></>
|
<></>
|
||||||
}
|
}
|
||||||
{
|
{!entry.hideDates ?
|
||||||
!data.hideDates ?
|
|
||||||
<CardFooter className="text-sm">
|
<CardFooter className="text-sm">
|
||||||
{`von ${format((new Date()).setTime(Date.parse(data.fromTime)), 'M. yyyy')} bis zum ${format((new Date()).setTime(Date.parse(data.toTime)), 'M. yyyy')}`}
|
<AnimateTextIn position={position + 0.4}>
|
||||||
|
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`}
|
||||||
|
</AnimateTextIn>
|
||||||
</CardFooter> :
|
</CardFooter> :
|
||||||
<></>
|
<></>
|
||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
</> :
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<CardTitle> <Skeleton className="h-2rem w-5rem" /> </CardTitle>
|
|
||||||
<span className="ml-auto text-sm"> <Skeleton className="h-1rem w-3rem" /> - <Skeleton className="h-1rem w-3rem" /> </span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-4 w-60" />
|
|
||||||
<Skeleton className="h-4 w-50" />
|
|
||||||
<Skeleton className="h-4 w-50" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,25 @@ import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sideba
|
|||||||
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
||||||
import CvCategory from "./_components/CvCategory";
|
import CvCategory from "./_components/CvCategory";
|
||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
|
const cv = trpc.categoryv2.listAllWithEntries.useQuery();
|
||||||
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
|
useTimeLine(cv.data)
|
||||||
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
|
const byPosition = (pos: "sidebar" | "header" | "col1" | "col2") =>
|
||||||
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
|
cv.data?.filter((c) => c.layoutPosition === pos) ?? []
|
||||||
useTimeLine(col2Categories.data)
|
const sidebarCategories = byPosition("sidebar")
|
||||||
|
const headerCategories = byPosition("header")
|
||||||
|
const col1Categories = byPosition("col1")
|
||||||
|
const col2Categories = byPosition("col2")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
{sidebarCategories.data &&
|
{sidebarCategories.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<SidebarTriggerDisappearsOnMobile />
|
<SidebarTriggerDisappearsOnMobile />
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarContent className="p-2 lg:pt-[3.2rem]">
|
<SidebarContent className="p-2 lg:pt-[3.2rem]">
|
||||||
{sidebarCategories.data?.map((cat, i) => {
|
{sidebarCategories.map((cat, i) => (
|
||||||
if (cat !== undefined) {
|
<CvCategory layout="col" position={i} category={cat} key={cat.id} />
|
||||||
return (
|
))}
|
||||||
<CvCategory layout="col" position={i} initialData={cat} key={cat.id} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</>
|
</>
|
||||||
@@ -32,19 +31,19 @@ export default function CvPage() {
|
|||||||
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
|
<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="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">
|
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap">
|
||||||
{headerCategories.data?.map((cat, i) => (
|
{headerCategories.map((cat, i) => (
|
||||||
<CvCategory layout="row" position={i} initialData={cat} key={cat.id} />
|
<CvCategory layout="row" position={i} category={cat} key={cat.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4">
|
<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.data?.length ? col1Categories.data?.length : 0 > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
<div id="col1" className={`flex flex-col w-full ${col1Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
||||||
{col1Categories.data?.map((cat, i) => (
|
{col1Categories.map((cat, i) => (
|
||||||
<CvCategory layout="col" position={i} initialData={cat} key={cat.id} />
|
<CvCategory layout="col" position={i} category={cat} key={cat.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div id="col2" className={`flex flex-col w-full ${col2Categories.data?.length ? col2Categories.data?.length : 0 > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
<div id="col2" className={`flex flex-col w-full ${col2Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
||||||
{col2Categories.data?.map((cat, i) => (
|
{col2Categories.map((cat, i) => (
|
||||||
<CvCategory layout="col" position={i} initialData={cat} key={cat.id} />
|
<CvCategory layout="col" position={i} category={cat} key={cat.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { trpc } from "~/app/_trpc/Client";
|
|||||||
import * as Card from "~/components/ui/card";
|
import * as Card from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { StackBadge } from "~/components/StackBadge";
|
import { StackBadge } from "~/components/StackBadge";
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||||
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||||
import { useTimeLine } from "../_providers/GsapProvicer";
|
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import remarkGfm from "remark-gfm"
|
import { ClientMdx } from "~/components/ClientMdx";
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||||
@@ -63,7 +62,7 @@ export default function ProjectsPage() {
|
|||||||
{project.description && (
|
{project.description && (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||||
<AnimatePopUp once position={i + 1.4} duration={10}>
|
<AnimatePopUp once position={i + 1.4} duration={10}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
<ClientMdx source={project.description} fallback={project.description} />
|
||||||
</AnimatePopUp>
|
</AnimatePopUp>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,11 +110,6 @@ export default function ProjectsPage() {
|
|||||||
<div className="pt-5" />
|
<div className="pt-5" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Scroll runway: lets the last cards' ScrollTrigger exit points be
|
|
||||||
reached past the visible area, so on short viewports the second-to-last
|
|
||||||
card animates out off-screen instead of being frozen mid-exit at the
|
|
||||||
bottom of the scroll. Tune the height if a card is still caught. */}
|
|
||||||
<div aria-hidden className="h-[70dvh] shrink-0" />
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/components/ClientMdx.tsx
Normal file
79
src/components/ClientMdx.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { evaluate } from "@mdx-js/mdx";
|
||||||
|
import { MDXProvider, useMDXComponents } from "@mdx-js/react";
|
||||||
|
import type { MDXComponents } from "mdx/types";
|
||||||
|
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||||
|
import * as runtime from "react/jsx-runtime";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { mdxComponents } from "~/components/mdx-components";
|
||||||
|
|
||||||
|
type MdxModule = {
|
||||||
|
default: ComponentType<{ components?: MDXComponents }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientMdxProps = {
|
||||||
|
source: string;
|
||||||
|
components?: MDXComponents;
|
||||||
|
format?: "md" | "mdx";
|
||||||
|
fallback?: ReactNode;
|
||||||
|
errorFallback?: (error: Error) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientMdx({
|
||||||
|
source,
|
||||||
|
components = mdxComponents,
|
||||||
|
format = "md",
|
||||||
|
fallback = null,
|
||||||
|
errorFallback,
|
||||||
|
}: ClientMdxProps) {
|
||||||
|
const [Content, setContent] = useState<MdxModule["default"] | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const trimmed = source.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
setContent(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void evaluate(trimmed, {
|
||||||
|
...runtime,
|
||||||
|
baseUrl: import.meta.url,
|
||||||
|
format,
|
||||||
|
useMDXComponents,
|
||||||
|
rehypePlugins: [rehypeHighlight],
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
})
|
||||||
|
.then((mod) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setContent(() => (mod as MdxModule).default);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((nextError: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setContent(null);
|
||||||
|
setError(nextError instanceof Error ? nextError : new Error("Failed to render MDX"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [format, source]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return errorFallback ? errorFallback(error) : <p>{source}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Content) return <>{fallback}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MDXProvider components={components}>
|
||||||
|
<Content components={components} />
|
||||||
|
</MDXProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
import {
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -305,6 +305,25 @@ export const blogRouter = router({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
metadataBySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: post.slug,
|
||||||
|
title: post.title,
|
||||||
|
date: post.date,
|
||||||
|
description: post.description,
|
||||||
|
tags: post.tags ?? [],
|
||||||
|
fileUrl: post.fileUrl,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
syncFromUploadThing: publicProcedure.mutation(async () => {
|
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||||
await assertAdmin();
|
await assertAdmin();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export const cvCategoryRouter = router({
|
|||||||
console.log(res);
|
console.log(res);
|
||||||
return res;
|
return res;
|
||||||
}),
|
}),
|
||||||
|
// Single round-trip for the whole CV page: every category (across all layout
|
||||||
|
// positions) with its entries already populated. Lets the page fetch-then-render
|
||||||
|
// instead of waterfalling per-category/per-entry queries, so all content is
|
||||||
|
// present before the entrance animation runs.
|
||||||
|
listAllWithEntries: publicProcedure.query(async () => {
|
||||||
|
const res = await db.query.cvCategory.findMany({
|
||||||
|
with: {
|
||||||
|
cvEntry: {
|
||||||
|
orderBy: (t, { desc }) => desc(t.toTime),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res;
|
||||||
|
}),
|
||||||
getById: publicProcedure.input(z.string()).query(async ({input}) => {
|
getById: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||||
const res = await db.query.cvCategory.findFirst({
|
const res = await db.query.cvCategory.findFirst({
|
||||||
where(fields, operators) {
|
where(fields, operators) {
|
||||||
|
|||||||
Reference in New Issue
Block a user