tangled
alpha
login
or
join now
knotbin.com
/
blog
0
fork
atom
Leaflet Blog in Deno Fresh
0
fork
atom
overview
issues
pulls
pipelines
Work page and design tweaks
knotbin.com
7 months ago
36ede340
570a7da5
+340
-102
12 changed files
expand all
collapse all
unified
split
components
TextBlock.tsx
project-list-item.tsx
typography.tsx
deno.json
fresh.gen.ts
islands
layout.tsx
post-list.tsx
project-list.tsx
package-lock.json
routes
index.tsx
post
[slug].tsx
work.tsx
+45
-10
components/TextBlock.tsx
···
1
1
-
import { h } from "preact";
2
1
import { PubLeafletBlocksText } from "npm:@atcute/leaflet";
3
2
4
3
interface TextBlockProps {
5
4
plaintext: string;
6
5
facets?: PubLeafletBlocksText.Main["facets"];
6
6
+
}
7
7
+
8
8
+
interface LinkFeature {
9
9
+
$type: "pub.leaflet.richtext.facet#link";
10
10
+
uri: string;
11
11
+
}
12
12
+
13
13
+
function byteToCharIndex(text: string, byteIndex: number): number {
14
14
+
const textEncoder = new TextEncoder();
15
15
+
const textDecoder = new TextDecoder();
16
16
+
const fullBytes = textEncoder.encode(text);
17
17
+
const bytes = fullBytes.slice(0, byteIndex);
18
18
+
return textDecoder.decode(bytes).length;
7
19
}
8
20
9
21
export function TextBlock({ plaintext, facets }: TextBlockProps) {
10
22
// Only process facets if at least one facet has features
11
11
-
if (!facets || !facets.some(f => f.features && f.features.length > 0)) {
23
23
+
if (!facets || !facets.some((f) => f.features && f.features.length > 0)) {
12
24
return <>{plaintext}</>;
13
25
}
14
26
···
16
28
let lastIndex = 0;
17
29
18
30
facets.forEach((facet) => {
19
19
-
if (facet.index.byteStart > lastIndex) {
20
20
-
parts.push(plaintext.slice(lastIndex, facet.index.byteStart));
31
31
+
// Convert byte positions to character positions
32
32
+
const charStart = byteToCharIndex(plaintext, facet.index.byteStart);
33
33
+
const charEnd = byteToCharIndex(plaintext, facet.index.byteEnd);
34
34
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
35
35
+
36
36
+
if (charStart > charLastIndex) {
37
37
+
parts.push(plaintext.slice(charLastIndex, charStart));
21
38
}
22
39
23
23
-
const text = plaintext.slice(facet.index.byteStart, facet.index.byteEnd);
40
40
+
const text = plaintext.slice(charStart, charEnd);
24
41
const feature = facet.features?.[0];
25
42
26
43
if (!feature) {
···
38
55
parts.push({ text, type: feature.$type });
39
56
} else if (feature.$type === "pub.leaflet.richtext.facet#underline") {
40
57
parts.push({ text, type: feature.$type });
58
58
+
} else if (feature.$type === "pub.leaflet.richtext.facet#link") {
59
59
+
const linkFeature = feature as LinkFeature;
60
60
+
parts.push({ text, type: feature.$type, uri: linkFeature.uri });
41
61
} else {
42
42
-
parts.push({ text, type: feature.$type });
62
62
+
parts.push(text);
43
63
}
44
64
45
65
lastIndex = facet.index.byteEnd;
46
66
});
47
67
48
48
-
if (lastIndex < plaintext.length) {
49
49
-
parts.push(plaintext.slice(lastIndex));
68
68
+
// Convert final lastIndex from bytes to characters
69
69
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
70
70
+
71
71
+
if (charLastIndex < plaintext.length) {
72
72
+
parts.push(plaintext.slice(charLastIndex));
50
73
}
51
74
52
75
return (
···
64
87
<mark
65
88
key={i}
66
89
className="bg-blue-100 dark:bg-blue-900 text-inherit rounded px-1"
67
67
-
style={{ borderRadius: '0.375rem' }}
90
90
+
style={{ borderRadius: "0.375rem" }}
68
91
>
69
92
{part.text}
70
93
</mark>
···
75
98
return <s key={i}>{part.text}</s>;
76
99
case "pub.leaflet.richtext.facet#underline":
77
100
return <u key={i}>{part.text}</u>;
101
101
+
case "pub.leaflet.richtext.facet#link":
102
102
+
return (
103
103
+
<a
104
104
+
key={i}
105
105
+
href={part.uri}
106
106
+
target="_blank"
107
107
+
rel="noopener noreferrer"
108
108
+
className="text-blue-600 dark:text-blue-400 hover:underline"
109
109
+
>
110
110
+
{part.text}
111
111
+
</a>
112
112
+
);
78
113
default:
79
114
return part.text;
80
115
}
81
116
})}
82
117
</>
83
118
);
84
84
-
}
119
119
+
}
+150
components/project-list-item.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useEffect, useRef, useState } from "preact/hooks";
4
4
+
import { cx } from "../lib/cx.ts";
5
5
+
import { Title } from "./typography.tsx";
6
6
+
7
7
+
interface Project {
8
8
+
id: string;
9
9
+
title: string;
10
10
+
description: string;
11
11
+
technologies: string[];
12
12
+
url: string;
13
13
+
demo?: string;
14
14
+
year: string;
15
15
+
status: "active" | "completed" | "maintained" | "archived";
16
16
+
}
17
17
+
18
18
+
export function ProjectListItem({ project }: { project: Project }) {
19
19
+
const [isHovered, setIsHovered] = useState(false);
20
20
+
const [isLeaving, setIsLeaving] = useState(false);
21
21
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
22
+
23
23
+
// Clean up any timeouts on unmount
24
24
+
useEffect(() => {
25
25
+
return () => {
26
26
+
if (timeoutRef.current) {
27
27
+
clearTimeout(timeoutRef.current);
28
28
+
}
29
29
+
};
30
30
+
}, []);
31
31
+
32
32
+
const handleMouseEnter = () => {
33
33
+
if (timeoutRef.current) {
34
34
+
clearTimeout(timeoutRef.current);
35
35
+
}
36
36
+
setIsLeaving(false);
37
37
+
setIsHovered(true);
38
38
+
};
39
39
+
40
40
+
const handleMouseLeave = () => {
41
41
+
setIsLeaving(true);
42
42
+
timeoutRef.current = setTimeout(() => {
43
43
+
setIsHovered(false);
44
44
+
setIsLeaving(false);
45
45
+
}, 300); // Match animation duration
46
46
+
};
47
47
+
48
48
+
const getStatusColor = (status: string) => {
49
49
+
switch (status) {
50
50
+
case "active":
51
51
+
return "text-green-600 dark:text-green-400";
52
52
+
case "completed":
53
53
+
return "text-blue-600 dark:text-blue-400";
54
54
+
case "maintained":
55
55
+
return "text-yellow-600 dark:text-yellow-400";
56
56
+
case "archived":
57
57
+
return "text-slate-500 dark:text-slate-400";
58
58
+
default:
59
59
+
return "text-slate-600 dark:text-slate-300";
60
60
+
}
61
61
+
};
62
62
+
63
63
+
return (
64
64
+
<>
65
65
+
{isHovered && (
66
66
+
<div
67
67
+
className={cx(
68
68
+
"fixed inset-0 pointer-events-none z-0",
69
69
+
isLeaving ? "animate-fade-out" : "animate-fade-in",
70
70
+
)}
71
71
+
>
72
72
+
<div className="h-full w-full pt-[120px] flex items-center overflow-hidden">
73
73
+
<div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12 absolute left-0">
74
74
+
{Array(8).fill(project.title).join(" · ")}
75
75
+
</div>
76
76
+
</div>
77
77
+
</div>
78
78
+
)}
79
79
+
<a
80
80
+
href={project.demo || project.url}
81
81
+
target="_blank"
82
82
+
rel="noopener noreferrer"
83
83
+
className="w-full group block"
84
84
+
onMouseEnter={handleMouseEnter}
85
85
+
onMouseLeave={handleMouseLeave}
86
86
+
>
87
87
+
<article className="w-full flex flex-row border-b items-stretch relative transition-colors duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
88
88
+
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity duration-300 ease-[cubic-bezier(0.33,0,0.67,1)]" />
89
89
+
<div className="flex-1 py-2 px-4 z-10 relative w-full">
90
90
+
<div className="flex items-start justify-between gap-4">
91
91
+
<Title className="text-lg w-full flex-1" level="h3">
92
92
+
{project.title}
93
93
+
</Title>
94
94
+
<div className="flex items-center gap-2 shrink-0">
95
95
+
<span className="text-xs text-slate-500 dark:text-slate-400">
96
96
+
{project.year}
97
97
+
</span>
98
98
+
<span
99
99
+
className={cx(
100
100
+
"text-xs font-medium capitalize",
101
101
+
getStatusColor(project.status),
102
102
+
)}
103
103
+
>
104
104
+
{project.status}
105
105
+
</span>
106
106
+
</div>
107
107
+
</div>
108
108
+
109
109
+
<div className="flex flex-wrap gap-1 mt-2">
110
110
+
{project.technologies.slice(0, 4).map((tech) => (
111
111
+
<span
112
112
+
key={tech}
113
113
+
className="text-xs px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded-sm text-slate-600 dark:text-slate-300"
114
114
+
>
115
115
+
{tech}
116
116
+
</span>
117
117
+
))}
118
118
+
{project.technologies.length > 4 && (
119
119
+
<span className="text-xs px-2 py-0.5 text-slate-500 dark:text-slate-400">
120
120
+
+{project.technologies.length - 4} more
121
121
+
</span>
122
122
+
)}
123
123
+
</div>
124
124
+
125
125
+
<div className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] grid-rows-[0fr] group-hover:grid-rows-[1fr] opacity-0 group-hover:opacity-100 mt-3">
126
126
+
<div className="overflow-hidden">
127
127
+
<p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3 mb-3">
128
128
+
{project.description}
129
129
+
</p>
130
130
+
<div className="flex gap-3">
131
131
+
{project.demo && (
132
132
+
<a
133
133
+
href={project.url}
134
134
+
target="_blank"
135
135
+
rel="noopener noreferrer"
136
136
+
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
137
137
+
onClick={(e) => e.stopPropagation()}
138
138
+
>
139
139
+
Source
140
140
+
</a>
141
141
+
)}
142
142
+
</div>
143
143
+
</div>
144
144
+
</div>
145
145
+
</div>
146
146
+
</article>
147
147
+
</a>
148
148
+
</>
149
149
+
);
150
150
+
}
+4
-3
components/typography.tsx
···
56
56
);
57
57
}
58
58
59
59
-
export function Code(
60
60
-
{ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>,
61
61
-
) {
59
59
+
export function Code({
60
60
+
className,
61
61
+
...props
62
62
+
}: h.JSX.HTMLAttributes<HTMLElement>) {
62
63
return (
63
64
<code
64
65
className={cx(
+3
-8
deno.json
···
11
11
},
12
12
"lint": {
13
13
"rules": {
14
14
-
"tags": [
15
15
-
"fresh",
16
16
-
"recommended"
17
17
-
]
14
14
+
"tags": ["fresh", "recommended"]
18
15
}
19
16
},
20
20
-
"exclude": [
21
21
-
"**/_fresh/*"
22
22
-
],
17
17
+
"exclude": ["**/_fresh/*"],
23
18
"imports": {
24
19
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
25
20
"@atcute/atproto": "npm:@atcute/atproto@^3.0.1",
26
21
"@atcute/client": "npm:@atcute/client@^4.0.1",
27
27
-
"@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1",
22
22
+
"@atcute/leaflet": "npm:@atcute/leaflet@^1.0.2",
28
23
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
29
24
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
30
25
"@preact-icons/fi": "jsr:@preact-icons/fi@^1.0.13",
+4
fresh.gen.ts
···
9
9
import * as $index from "./routes/index.tsx";
10
10
import * as $post_slug_ from "./routes/post/[slug].tsx";
11
11
import * as $rss from "./routes/rss.ts";
12
12
+
import * as $work from "./routes/work.tsx";
12
13
import * as $layout from "./islands/layout.tsx";
13
14
import * as $post_list from "./islands/post-list.tsx";
15
15
+
import * as $project_list from "./islands/project-list.tsx";
14
16
import type { Manifest } from "$fresh/server.ts";
15
17
16
18
const manifest = {
···
22
24
"./routes/index.tsx": $index,
23
25
"./routes/post/[slug].tsx": $post_slug_,
24
26
"./routes/rss.ts": $rss,
27
27
+
"./routes/work.tsx": $work,
25
28
},
26
29
islands: {
27
30
"./islands/layout.tsx": $layout,
28
31
"./islands/post-list.tsx": $post_list,
32
32
+
"./islands/project-list.tsx": $project_list,
29
33
},
30
34
baseUrl: import.meta.url,
31
35
} satisfies Manifest;
+20
-6
islands/layout.tsx
···
6
6
export function Layout({ children }: { children: ComponentChildren }) {
7
7
const [isScrolled, setIsScrolled] = useState(false);
8
8
const [blogHovered, setBlogHovered] = useState(false);
9
9
+
const [workHovered, setWorkHovered] = useState(false);
9
10
const [aboutHovered, setAboutHovered] = useState(false);
10
11
const pathname = useSignal("");
11
12
···
18
19
19
20
useEffect(() => {
20
21
const handleScroll = () => {
21
21
-
setIsScrolled(window.scrollY > 0);
22
22
+
setIsScrolled(globalThis.scrollY > 0);
22
23
};
23
24
24
25
const handlePathChange = () => {
25
25
-
pathname.value = window.location.pathname;
26
26
+
pathname.value = globalThis.location.pathname;
26
27
};
27
28
28
28
-
window.addEventListener("scroll", handleScroll);
29
29
-
window.addEventListener("popstate", handlePathChange);
29
29
+
globalThis.addEventListener("scroll", handleScroll);
30
30
+
globalThis.addEventListener("popstate", handlePathChange);
30
31
handleScroll(); // Check initial scroll position
31
32
handlePathChange(); // Set initial path
32
33
33
34
return () => {
34
34
-
window.removeEventListener("scroll", handleScroll);
35
35
-
window.removeEventListener("popstate", handlePathChange);
35
35
+
globalThis.removeEventListener("scroll", handleScroll);
36
36
+
globalThis.removeEventListener("popstate", handlePathChange);
36
37
};
37
38
}, []);
38
39
···
61
62
>
62
63
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
63
64
blog
65
65
+
</span>
66
66
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
67
67
+
</a>
68
68
+
<a
69
69
+
href="/work"
70
70
+
class="relative group"
71
71
+
data-current={isActive("/work")}
72
72
+
data-hovered={workHovered}
73
73
+
onMouseEnter={() => setWorkHovered(true)}
74
74
+
onMouseLeave={() => setWorkHovered(false)}
75
75
+
>
76
76
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
77
77
+
work
64
78
</span>
65
79
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
66
80
</a>
+8
-11
islands/post-list.tsx
···
1
1
import { useSignal } from "@preact/signals";
2
2
import { useEffect } from "preact/hooks";
3
3
import { PostListItem } from "../components/post-list-item.tsx";
4
4
+
import { PubLeafletDocument } from "@atcute/leaflet";
4
5
5
6
interface PostRecord {
6
6
-
value: any;
7
7
+
value: PubLeafletDocument.Main;
7
8
uri: string;
8
9
}
9
10
10
10
-
export default function PostList(
11
11
-
{ posts: initialPosts }: { posts: PostRecord[] },
12
12
-
) {
11
11
+
export default function PostList({
12
12
+
posts: initialPosts,
13
13
+
}: {
14
14
+
posts: PostRecord[];
15
15
+
}) {
13
16
const posts = useSignal(initialPosts);
14
17
15
18
useEffect(() => {
···
21
24
{posts.value?.map((record) => {
22
25
const post = record.value;
23
26
const rkey = record.uri.split("/").pop() || "";
24
24
-
return (
25
25
-
<PostListItem
26
26
-
key={record.uri}
27
27
-
post={post}
28
28
-
rkey={rkey}
29
29
-
/>
30
30
-
);
27
27
+
return <PostListItem key={record.uri} post={post} rkey={rkey} />;
31
28
})}
32
29
</>
33
30
);
+35
islands/project-list.tsx
···
1
1
+
import { useSignal } from "@preact/signals";
2
2
+
import { useEffect } from "preact/hooks";
3
3
+
import { ProjectListItem } from "../components/project-list-item.tsx";
4
4
+
5
5
+
interface Project {
6
6
+
id: string;
7
7
+
title: string;
8
8
+
description: string;
9
9
+
technologies: string[];
10
10
+
url: string;
11
11
+
demo?: string;
12
12
+
year: string;
13
13
+
status: "active" | "completed" | "maintained" | "archived";
14
14
+
}
15
15
+
16
16
+
export default function ProjectList(
17
17
+
{ projects: initialProjects }: { projects: Project[] },
18
18
+
) {
19
19
+
const projects = useSignal(initialProjects);
20
20
+
21
21
+
useEffect(() => {
22
22
+
projects.value = initialProjects;
23
23
+
}, [initialProjects]);
24
24
+
25
25
+
return (
26
26
+
<>
27
27
+
{projects.value?.map((project) => (
28
28
+
<ProjectListItem
29
29
+
key={project.id}
30
30
+
project={project}
31
31
+
/>
32
32
+
))}
33
33
+
</>
34
34
+
);
35
35
+
}
-6
package-lock.json
···
1
1
-
{
2
2
-
"name": "blog",
3
3
-
"lockfileVersion": 3,
4
4
-
"requires": true,
5
5
-
"packages": {}
6
6
-
}
-32
routes/index.tsx
···
6
6
export const dynamic = "force-static";
7
7
export const revalidate = 3600; // 1 hour
8
8
9
9
-
const stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware = [
10
10
-
"is looking into it",
11
11
-
"i think therefore imdb",
12
12
-
"isn't a real word",
13
13
-
"enjoys each protocol equally",
14
14
-
"is having a very semantic argument",
15
15
-
"wrote these derivitive taglines",
16
16
-
"is way too into css animations",
17
17
-
"uses dark mode at noon",
18
18
-
"overthinks variable names",
19
19
-
"git pushes with -f",
20
20
-
"formats on save",
21
21
-
"is praising kier",
22
22
-
"pretends to understand monads",
23
23
-
"brags about their vim config",
24
24
-
"documents their code (lies)",
25
25
-
"isn't mysterious or important",
26
26
-
"wants to be included in discourse",
27
27
-
"is deeply offended by semicolons",
28
28
-
"is morraly opposed to touching grass",
29
29
-
];
30
30
-
31
31
-
function getRandomTagline() {
32
32
-
return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[
33
33
-
Math.floor(
34
34
-
Math.random() *
35
35
-
stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length,
36
36
-
)
37
37
-
];
38
38
-
}
39
39
-
40
9
export default async function Home() {
41
10
const posts = await getPosts();
42
42
-
const tagline = getRandomTagline();
43
11
44
12
return (
45
13
<Layout>
+20
-26
routes/post/[slug].tsx
···
1
1
-
/** @jsxImportSource preact */
2
1
import { Handlers, PageProps } from "$fresh/server.ts";
3
2
import { Layout } from "../../islands/layout.tsx";
4
3
import { PostInfo } from "../../components/post-info.tsx";
···
49
48
}) {
50
49
let b = block;
51
50
52
52
-
// Debug log to check for duplicate rendering
53
53
-
console.log(
54
54
-
"Rendering block",
55
55
-
b.block.$type,
56
56
-
(b.block as any).plaintext || (b.block as any).text || ""
57
57
-
);
58
58
-
59
51
let className = `
60
52
postBlockWrapper
61
53
pt-1
···
67
59
return (
68
60
<ul className="-ml-[1px] sm:ml-[9px] pb-2">
69
61
{b.block.children.map((child, index) => (
70
70
-
<ListItem
71
71
-
item={child}
72
72
-
did={did}
73
73
-
key={index}
74
74
-
className={className}
75
75
-
/>
62
62
+
<ListItem item={child} did={did} key={index} className={className} />
76
63
))}
77
64
</ul>
78
65
);
···
95
82
width={width}
96
83
height={height}
97
84
className={`!pt-3 sm:!pt-4 ${className}`}
98
98
-
style={{ aspectRatio: width && height ? `${width} / ${height}` : undefined }}
85
85
+
style={{
86
86
+
aspectRatio: width && height ? `${width} / ${height}` : undefined,
87
87
+
}}
99
88
/>
100
89
);
101
90
}
···
113
102
const level = header.level || 1;
114
103
const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements;
115
104
// Add heading styles based on level
116
116
-
let headingStyle = "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
105
105
+
let headingStyle =
106
106
+
"font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
117
107
switch (level) {
118
108
case 1:
119
119
-
headingStyle += "text-4xl lg:text-5xl";
109
109
+
headingStyle += "text-3xl lg:text-4xl";
120
110
break;
121
111
case 2:
122
112
headingStyle += "text-3xl border-b pb-2 mb-6";
···
137
127
headingStyle += "text-2xl";
138
128
}
139
129
return (
140
140
-
<Tag className={headingStyle + ' ' + className}>
130
130
+
<Tag className={headingStyle + " " + className}>
141
131
<TextBlock plaintext={header.plaintext} facets={header.facets} />
142
132
</Tag>
143
133
);
···
187
177
}
188
178
// Deduplicate blocks by $type and plaintext
189
179
const seen = new Set();
190
190
-
const uniqueBlocks = blocks.filter(b => {
191
191
-
const key = b.block.$type + '|' + ((b.block as any).plaintext || '');
180
180
+
const uniqueBlocks = blocks.filter((b) => {
181
181
+
const key = b.block.$type + "|" + ((b.block as any).plaintext || "");
192
182
if (seen.has(key)) return false;
193
183
seen.add(key);
194
184
return true;
195
185
});
196
186
197
187
const content = uniqueBlocks
198
198
-
.filter(b => b.block.$type === "pub.leaflet.blocks.text")
199
199
-
.map(b => (b.block as PubLeafletBlocksText.Main).plaintext)
200
200
-
.join(' ');
188
188
+
.filter((b) => b.block.$type === "pub.leaflet.blocks.text")
189
189
+
.map((b) => (b.block as PubLeafletBlocksText.Main).plaintext)
190
190
+
.join(" ");
201
191
202
192
return (
203
193
<>
···
215
205
<div class="max-w-[600px] mx-auto">
216
206
<article class="w-full space-y-8">
217
207
<div class="space-y-4 w-full">
218
218
-
<Title>{post.value.title || 'Untitled'}</Title>
208
208
+
<Title>{post.value.title || "Untitled"}</Title>
219
209
{post.value.description && (
220
220
-
<p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose">
210
210
+
<p class="text-xl italic md:text-2xl font-serif leading-relaxed max-w-prose">
221
211
{post.value.description}
222
212
</p>
223
213
)}
···
231
221
</div>
232
222
<div class="postContent flex flex-col">
233
223
{uniqueBlocks.map((block, index) => (
234
234
-
<Block block={block} did={post.uri.split('/')[2]} key={index} />
224
224
+
<Block
225
225
+
block={block}
226
226
+
did={post.uri.split("/")[2]}
227
227
+
key={index}
228
228
+
/>
235
229
))}
236
230
</div>
237
231
</article>
+51
routes/work.tsx
···
1
1
+
import ProjectList from "../islands/project-list.tsx";
2
2
+
import { Title } from "../components/typography.tsx";
3
3
+
import { Layout } from "../islands/layout.tsx";
4
4
+
5
5
+
export const dynamic = "force-static";
6
6
+
export const revalidate = 3600; // 1 hour
7
7
+
8
8
+
interface Project {
9
9
+
id: string;
10
10
+
title: string;
11
11
+
description: string;
12
12
+
technologies: string[];
13
13
+
url: string;
14
14
+
demo?: string;
15
15
+
year: string;
16
16
+
status: "active" | "completed" | "maintained" | "archived";
17
17
+
}
18
18
+
19
19
+
// Mock project data - replace with your actual projects
20
20
+
const projects: Project[] = [
21
21
+
{
22
22
+
id: "1",
23
23
+
title: "ATP Airport",
24
24
+
description: `The first ever graphical PDS migration tool for AT Protocol.
25
25
+
Allows users to migrate their data from one PDS to another without any
26
26
+
experience or technical knowledge.`,
27
27
+
technologies: ["AT Protocol", "Fresh", "Deno", "TypeScript"],
28
28
+
url: "https://github.com/knotbin/airport",
29
29
+
demo: "https://atpairport.com",
30
30
+
year: "2025",
31
31
+
status: "active",
32
32
+
},
33
33
+
];
34
34
+
35
35
+
export default function Work() {
36
36
+
return (
37
37
+
<Layout>
38
38
+
<div class="p-8 pb-20 gap-16 sm:p-20">
39
39
+
<div class="max-w-[600px] mx-auto">
40
40
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
41
41
+
Work
42
42
+
</Title>
43
43
+
44
44
+
<div class="space-y-4 w-full">
45
45
+
<ProjectList projects={projects} />
46
46
+
</div>
47
47
+
</div>
48
48
+
</div>
49
49
+
</Layout>
50
50
+
);
51
51
+
}