Skip to content

Commit 91bf0b3

Browse files
committed
ui(projects): restore collapsible list with sorted chronological categories and simple framed cover
1 parent 2f01640 commit 91bf0b3

5 files changed

Lines changed: 637 additions & 23 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import type { Metadata } from "next"
2+
import Link from "next/link"
3+
import { notFound } from "next/navigation"
4+
import Script from "next/script"
5+
import {
6+
findNeighbour,
7+
getDocBySlug,
8+
getDocsByCategory,
9+
} from "@/data/doc/documents"
10+
import { USER } from "@/data/portfolio/user"
11+
import { getTableOfContents } from "fumadocs-core/content/toc"
12+
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
13+
import type { BlogPosting as PageSchema, WithContext } from "schema-dts"
14+
15+
import type { Doc } from "@/types/document"
16+
import { SITE_INFO, X_HANDLE } from "@/config/site"
17+
import { cn } from "@/lib/utils"
18+
import { Button } from "@/components/ui/button"
19+
import { Kbd } from "@/components/ui/kbd"
20+
import { Prose } from "@/components/ui/typography"
21+
import {
22+
Tooltip,
23+
TooltipContent,
24+
TooltipTrigger,
25+
} from "@/components/base/ui/tooltip"
26+
import { DocKeyboardShortcuts } from "@/components/doc/doc-keyboard-shortcuts"
27+
import {
28+
DocContainer,
29+
DocContentCol,
30+
DocGrid,
31+
DocLeftCol,
32+
DocRightCol,
33+
} from "@/components/doc/doc-layout"
34+
import { LLMCopyButtonWithViewOptions } from "@/components/doc/doc-page-actions"
35+
import { DocPageRoot } from "@/components/doc/doc-page-root"
36+
import { DocShareMenu } from "@/components/doc/doc-share-menu"
37+
import { FramedImage } from "@/components/embed"
38+
import { MDX } from "@/components/mdx"
39+
import { TOCInline } from "@/components/toc-inline"
40+
import { TOCMinimap } from "@/components/toc-minimap"
41+
42+
export const revalidate = false
43+
export const dynamic = "force-static"
44+
export const dynamicParams = true
45+
46+
export async function generateStaticParams() {
47+
const docs = getDocsByCategory("projects")
48+
return docs.map((doc) => ({ slug: doc.slug }))
49+
}
50+
51+
export async function generateMetadata({
52+
params,
53+
}: PageProps<"/projects/[slug]">): Promise<Metadata> {
54+
const slug = (await params).slug
55+
const doc = getDocBySlug(slug)
56+
57+
if (!doc || doc.metadata.category !== "projects") {
58+
return notFound()
59+
}
60+
61+
const { title, description, image, createdAt, updatedAt } = doc.metadata
62+
63+
const postUrl = getDocUrl(doc)
64+
const ogImage =
65+
image ||
66+
`/og/simple?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`
67+
68+
return {
69+
title,
70+
description,
71+
alternates: {
72+
canonical: postUrl,
73+
},
74+
openGraph: {
75+
url: postUrl,
76+
type: "article",
77+
publishedTime: new Date(createdAt).toISOString(),
78+
modifiedTime: new Date(updatedAt).toISOString(),
79+
images: {
80+
url: ogImage,
81+
width: 1200,
82+
height: 630,
83+
alt: title,
84+
},
85+
},
86+
twitter: {
87+
card: "summary_large_image",
88+
site: X_HANDLE,
89+
creator: X_HANDLE,
90+
images: [ogImage],
91+
},
92+
}
93+
}
94+
95+
function getPageJsonLd(doc: Doc): WithContext<PageSchema> {
96+
return {
97+
"@context": "https://schema.org",
98+
"@type": "BlogPosting",
99+
headline: doc.metadata.title,
100+
description: doc.metadata.description,
101+
image:
102+
doc.metadata.image ||
103+
`/og/simple?title=${encodeURIComponent(doc.metadata.title)}&description=${encodeURIComponent(doc.metadata.description)}`,
104+
url: `${SITE_INFO.url}${getDocUrl(doc)}`,
105+
datePublished: new Date(doc.metadata.createdAt).toISOString(),
106+
dateModified: new Date(doc.metadata.updatedAt).toISOString(),
107+
author: {
108+
"@type": "Person",
109+
name: USER.displayName,
110+
identifier: USER.username,
111+
image: USER.avatar,
112+
},
113+
}
114+
}
115+
116+
export default async function Page({ params }: PageProps<"/projects/[slug]">) {
117+
const slug = (await params).slug
118+
const doc = getDocBySlug(slug)
119+
120+
if (!doc || doc.metadata.category !== "projects") {
121+
notFound()
122+
}
123+
124+
const toc = getTableOfContents(doc.content)
125+
126+
const allProjects = getDocsByCategory("projects")
127+
const { previous, next } = findNeighbour(allProjects, slug)
128+
129+
return (
130+
<>
131+
<Script
132+
id="schema-jsonld"
133+
type="application/ld+json"
134+
dangerouslySetInnerHTML={{
135+
__html: JSON.stringify(getPageJsonLd(doc)).replace(/</g, "\\u003c"),
136+
}}
137+
/>
138+
139+
<DocKeyboardShortcuts
140+
previous={previous ? `/projects/${previous.slug}` : null}
141+
next={next ? `/projects/${next.slug}` : null}
142+
/>
143+
144+
<DocPageRoot>
145+
<DocContainer>
146+
<div className="screen-line-bottom h-px" />
147+
148+
<div className="flex items-center justify-between p-2 pl-4">
149+
<Button
150+
className="h-7 gap-2 border-none px-0 text-muted-foreground hover:text-foreground hover:no-underline"
151+
variant="link"
152+
size="sm"
153+
asChild
154+
>
155+
<Link href="/projects">
156+
<ArrowLeftIcon />
157+
Projects
158+
</Link>
159+
</Button>
160+
161+
<div className="flex items-center gap-2">
162+
<LLMCopyButtonWithViewOptions
163+
markdownUrl={`${getDocUrl(doc)}.mdx`}
164+
isComponent={false}
165+
/>
166+
167+
<DocShareMenu title={doc.metadata.title} url={getDocUrl(doc)} />
168+
169+
{previous && (
170+
<Tooltip>
171+
<TooltipTrigger
172+
render={
173+
<Button
174+
className="size-7 border-none"
175+
variant="secondary"
176+
size="icon-sm"
177+
asChild
178+
>
179+
<Link
180+
href={`/projects/${previous.slug}`}
181+
aria-label="Previous Project"
182+
>
183+
<ArrowLeftIcon />
184+
</Link>
185+
</Button>
186+
}
187+
/>
188+
<TooltipContent className="pr-2 pl-3">
189+
<div className="flex items-center gap-3">
190+
Previous Project
191+
<Kbd>
192+
<ArrowLeftIcon />
193+
</Kbd>
194+
</div>
195+
</TooltipContent>
196+
</Tooltip>
197+
)}
198+
199+
{next && (
200+
<Tooltip>
201+
<TooltipTrigger
202+
render={
203+
<Button
204+
className="size-7 border-none"
205+
variant="secondary"
206+
size="icon-sm"
207+
asChild
208+
>
209+
<Link
210+
href={`/projects/${next.slug}`}
211+
aria-label="Next Project"
212+
>
213+
<ArrowRightIcon />
214+
</Link>
215+
</Button>
216+
}
217+
/>
218+
<TooltipContent className="pr-2 pl-3">
219+
<div className="flex items-center gap-3">
220+
Next Project
221+
<Kbd>
222+
<ArrowRightIcon />
223+
</Kbd>
224+
</div>
225+
</TooltipContent>
226+
</Tooltip>
227+
)}
228+
</div>
229+
</div>
230+
231+
<div className="screen-line-top screen-line-bottom">
232+
<div
233+
className={cn(
234+
"h-8",
235+
"before:absolute before:left-[-100vw] before:-z-1 before:h-full before:w-[200vw]",
236+
"before:bg-[repeating-linear-gradient(315deg,var(--pattern-foreground)_0,var(--pattern-foreground)_1px,transparent_0,transparent_50%)] before:bg-size-[10px_10px] before:[--pattern-foreground:var(--color-line)]/56"
237+
)}
238+
/>
239+
</div>
240+
241+
<h1
242+
data-slot="doc-title"
243+
className="screen-line-bottom px-4 text-3xl font-semibold tracking-tight text-balance"
244+
>
245+
{doc.metadata.title}
246+
</h1>
247+
</DocContainer>
248+
249+
<DocGrid>
250+
<DocLeftCol />
251+
252+
<DocContentCol>
253+
<Prose className="px-(--page-padding) pt-8 [--page-padding:--spacing(4)]">
254+
<p className="text-muted-foreground">
255+
{doc.metadata.description}
256+
</p>
257+
258+
{doc.metadata.image && (
259+
<FramedImage
260+
src={doc.metadata.image}
261+
alt={doc.metadata.title}
262+
className="my-6 aspect-video w-full object-cover"
263+
/>
264+
)}
265+
266+
<TOCInline className="lg:hidden" items={toc} />
267+
268+
<div>
269+
<MDX code={doc.content} />
270+
</div>
271+
</Prose>
272+
273+
<div className="screen-line-top h-4" />
274+
</DocContentCol>
275+
276+
<DocRightCol>
277+
<TOCMinimap items={toc} />
278+
</DocRightCol>
279+
</DocGrid>
280+
</DocPageRoot>
281+
</>
282+
)
283+
}
284+
285+
function getDocUrl(doc: Doc) {
286+
return `/projects/${doc.slug}`
287+
}

0 commit comments

Comments
 (0)