+20
-12
components/post-list-item.tsx
+20
-12
components/post-list-item.tsx
···
41
41
timeoutRef.current = setTimeout(() => {
42
42
setIsHovered(false);
43
43
setIsLeaving(false);
44
-
}, 300); // Match the animation duration
44
+
}, 300); // Match animation duration
45
45
};
46
46
47
47
return (
···
49
49
{isHovered && (
50
50
<div
51
51
className={cx(
52
-
"fixed inset-0 pointer-events-none z-0 overflow-hidden flex items-center",
52
+
"fixed inset-0 pointer-events-none z-0",
53
53
isLeaving ? "animate-fade-out" : "animate-fade-in",
54
54
)}
55
55
>
56
-
<div className="absolute whitespace-nowrap animate-marquee font-serif font-medium uppercase overflow-visible flex items-center justify-center leading-none">
57
-
{Array(10).fill(post.title).join(" · ")}
56
+
<div className="h-full w-full pt-[120px] flex items-center justify-center">
57
+
<div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12">
58
+
{Array(3).fill(post.title).join(" · ")}
59
+
</div>
58
60
</div>
59
61
</div>
60
62
)}
61
63
<a
62
64
href={`/post/${rkey}`}
63
-
className="w-full group"
65
+
className="w-full group block"
64
66
onMouseEnter={handleMouseEnter}
65
67
onMouseLeave={handleMouseLeave}
66
68
>
67
-
<article className="w-full flex flex-row border-b items-stretch relative transition-color backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
68
-
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity" />
69
-
<div className="flex-1 py-2 px-4 z-10 relative">
70
-
<Title className="text-lg" level="h3">
69
+
<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">
70
+
<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)]" />
71
+
<div className="flex-1 py-2 px-4 z-10 relative w-full">
72
+
<Title className="text-lg w-full" level="h3">
71
73
{post.title}
72
74
</Title>
73
75
<PostInfo
74
76
content={post.content}
75
77
createdAt={post.createdAt}
76
-
className="text-xs mt-1"
77
-
>
78
-
</PostInfo>
78
+
className="text-xs mt-1 w-full"
79
+
/>
80
+
<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-2">
81
+
<div className="overflow-hidden">
82
+
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3 break-words">
83
+
{post.content.substring(0, 280)}
84
+
</p>
85
+
</div>
86
+
</div>
79
87
</div>
80
88
</article>
81
89
</a>
+4
fresh.gen.ts
+4
fresh.gen.ts
···
4
4
5
5
import * as $_404 from "./routes/_404.tsx";
6
6
import * as $_app from "./routes/_app.tsx";
7
+
import * as $about from "./routes/about.tsx";
7
8
import * as $index from "./routes/index.tsx";
8
9
import * as $post_slug_ from "./routes/post/[slug].tsx";
9
10
import * as $rss from "./routes/rss.ts";
10
11
import * as $CommentSection from "./islands/CommentSection.tsx";
12
+
import * as $layout from "./islands/layout.tsx";
11
13
import * as $post_list from "./islands/post-list.tsx";
12
14
import type { Manifest } from "$fresh/server.ts";
13
15
···
15
17
routes: {
16
18
"./routes/_404.tsx": $_404,
17
19
"./routes/_app.tsx": $_app,
20
+
"./routes/about.tsx": $about,
18
21
"./routes/index.tsx": $index,
19
22
"./routes/post/[slug].tsx": $post_slug_,
20
23
"./routes/rss.ts": $rss,
21
24
},
22
25
islands: {
23
26
"./islands/CommentSection.tsx": $CommentSection,
27
+
"./islands/layout.tsx": $layout,
24
28
"./islands/post-list.tsx": $post_list,
25
29
},
26
30
baseUrl: import.meta.url,
+70
islands/layout.tsx
+70
islands/layout.tsx
···
1
+
import { Footer } from "../components/footer.tsx";
2
+
import type { ComponentChildren } from "preact";
3
+
import { useEffect, useState } from "preact/hooks";
4
+
5
+
export function Layout({ children }: { children: ComponentChildren }) {
6
+
const [isScrolled, setIsScrolled] = useState(false);
7
+
8
+
// Get current path to determine active nav item
9
+
const path = typeof window !== "undefined" ? window.location.pathname : "";
10
+
const isActive = (href: string) => {
11
+
if (href === "/") {
12
+
return path === "/" || path.startsWith("/post/");
13
+
}
14
+
return path === href;
15
+
};
16
+
17
+
useEffect(() => {
18
+
const handleScroll = () => {
19
+
setIsScrolled(window.scrollY > 0);
20
+
};
21
+
22
+
window.addEventListener("scroll", handleScroll);
23
+
handleScroll(); // Check initial scroll position
24
+
25
+
return () => window.removeEventListener("scroll", handleScroll);
26
+
}, []);
27
+
28
+
return (
29
+
<div class="flex flex-col min-h-dvh">
30
+
<nav class="w-full sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-black/80 transition-[padding,border-color] duration-200">
31
+
<div class="relative">
32
+
<div
33
+
class="absolute inset-x-0 bottom-0 h-2 diagonal-pattern opacity-0 transition-opacity duration-300"
34
+
style={{ opacity: isScrolled ? 0.25 : 0 }}
35
+
/>
36
+
<div class="max-w-screen-2xl mx-auto px-8 py-5 flex justify-between items-center">
37
+
<div class="flex items-center gap-7">
38
+
<a href="/" class="font-serif text-xl">
39
+
knotbin
40
+
</a>
41
+
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
42
+
<div class="text-base flex items-center gap-7">
43
+
<a href="/" class="relative group" data-current={isActive("/")}>
44
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
45
+
blog
46
+
</span>
47
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current origin-left scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out" />
48
+
</a>
49
+
<a
50
+
href="/about"
51
+
class="relative group"
52
+
data-current={isActive("/about")}
53
+
>
54
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
55
+
about
56
+
</span>
57
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current origin-left scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out" />
58
+
</a>
59
+
</div>
60
+
</div>
61
+
</div>
62
+
</div>
63
+
</nav>
64
+
65
+
<main class="flex-1">{children}</main>
66
+
67
+
<Footer />
68
+
</div>
69
+
);
70
+
}
+14
-15
routes/_404.tsx
+14
-15
routes/_404.tsx
···
1
+
import { Title } from "../components/typography.tsx";
1
2
import { Head } from "$fresh/runtime.ts";
3
+
import { Layout } from "../islands/layout.tsx";
2
4
3
5
export default function Error404() {
4
6
return (
···
6
8
<Head>
7
9
<title>404 - Page not found</title>
8
10
</Head>
9
-
<div class="px-4 py-8 mx-auto bg-[#86efac]">
10
-
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
11
-
<img
12
-
class="my-6"
13
-
src="/logo.svg"
14
-
width="128"
15
-
height="128"
16
-
alt="the Fresh logo: a sliced lemon dripping with juice"
17
-
/>
18
-
<h1 class="text-4xl font-bold">404 - Page not found</h1>
19
-
<p class="my-4">
20
-
The page you were looking for doesn't exist.
21
-
</p>
22
-
<a href="/" class="underline">Go back home</a>
11
+
<Layout>
12
+
<div class="flex-1 flex items-center justify-center">
13
+
<div class="p-8 pb-20 sm:p-20 text-center">
14
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-6">
15
+
Page not found.
16
+
</Title>
17
+
<p class="my-4">The page you were looking for doesn't exist.</p>
18
+
<a href="/" class="underline">
19
+
Go back home
20
+
</a>
21
+
</div>
23
22
</div>
24
-
</div>
23
+
</Layout>
25
24
</>
26
25
);
27
26
}
+38
routes/about.tsx
+38
routes/about.tsx
···
1
+
import { Title } from "../components/typography.tsx";
2
+
import { Head } from "$fresh/runtime.ts";
3
+
import { Layout } from "../islands/layout.tsx";
4
+
5
+
export default function About() {
6
+
return (
7
+
<>
8
+
<Head>
9
+
<title>About - knotbin</title>
10
+
</Head>
11
+
<Layout>
12
+
<div class="p-8 pb-20 gap-16 sm:p-20">
13
+
<div class="max-w-[600px] mx-auto">
14
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
15
+
About
16
+
</Title>
17
+
18
+
<div class="prose prose-slate dark:prose-invert space-y-8">
19
+
<p>
20
+
I'm a fifteen year old software developer. I'm experienced in
21
+
iOS development, and a winner of the 2024 Apple Swift Student
22
+
Challenge. I'm very interested in decentralized systems and AT
23
+
Protocol in particular. I love designing and building beautiful
24
+
interfaces, and learning about amazing systems.
25
+
</p>
26
+
27
+
<p>
28
+
Currently, I'm working with Spark to build a shortform video
29
+
platform on the AT Protocol. I'm also working on my own
30
+
projects, and always thinking about big ideas and small details.
31
+
</p>
32
+
</div>
33
+
</div>
34
+
</div>
35
+
</Layout>
36
+
</>
37
+
);
38
+
}
+18
-19
routes/index.tsx
+18
-19
routes/index.tsx
···
1
-
import { Footer } from "../components/footer.tsx";
2
1
import PostList from "../islands/post-list.tsx";
3
2
import { Title } from "../components/typography.tsx";
4
3
import { getPosts } from "../lib/api.ts";
4
+
import { Layout } from "../islands/layout.tsx";
5
5
6
6
export const dynamic = "force-static";
7
7
export const revalidate = 3600; // 1 hour
···
29
29
];
30
30
31
31
function getRandomTagline() {
32
-
return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[Math.floor(Math.random() * stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length)];
32
+
return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[
33
+
Math.floor(
34
+
Math.random() *
35
+
stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length,
36
+
)
37
+
];
33
38
}
34
39
35
40
export default async function Home() {
···
37
42
const tagline = getRandomTagline();
38
43
39
44
return (
40
-
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-dvh p-8 pb-20 gap-16 sm:p-20">
41
-
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px]">
42
-
<div className="self-center flex flex-col">
43
-
<div className="relative">
44
-
<Title className="m-0 mb-6 font-serif-italic text-4xl sm:text-5xl lowercase">
45
-
knotbin
46
-
</Title>
47
-
<span className="absolute bottom-3 -right-2 font-bold text-xs opacity-50 text-right whitespace-nowrap">
48
-
{tagline}
49
-
</span>
50
-
</div>
51
-
</div>
45
+
<Layout>
46
+
<div class="p-8 pb-20 gap-16 sm:p-20">
47
+
<div class="max-w-[600px] mx-auto">
48
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
49
+
Knotbin
50
+
</Title>
52
51
53
-
<div className="flex flex-col gap-4 w-full">
54
-
<PostList posts={posts} />
52
+
<div class="space-y-4 w-full">
53
+
<PostList posts={posts} />
54
+
</div>
55
55
</div>
56
-
</main>
57
-
<Footer />
58
-
</div>
56
+
</div>
57
+
</Layout>
59
58
);
60
59
}
+34
-32
routes/post/[slug].tsx
+34
-32
routes/post/[slug].tsx
···
2
2
import { CSS, render } from "@deno/gfm";
3
3
import { Handlers, PageProps } from "$fresh/server.ts";
4
4
5
-
import { Footer } from "../../components/footer.tsx";
5
+
import { Layout } from "../../islands/layout.tsx";
6
6
import { PostInfo } from "../../components/post-info.tsx";
7
7
import { Title } from "../../components/typography.tsx";
8
8
import { getPost } from "../../lib/api.ts";
···
117
117
/>
118
118
</Head>
119
119
120
-
<div className="grid grid-rows-[20px_1fr_20px] justify-items-center min-h-dvh py-8 px-4 xs:px-8 pb-20 gap-16 sm:p-20">
121
-
<link rel="alternate" href={post.uri} />
122
-
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px] overflow-hidden">
123
-
<article className="w-full space-y-8">
124
-
<div className="space-y-4 w-full">
125
-
<a
126
-
href="/"
127
-
className="hover:underline hover:underline-offset-4 font-medium"
128
-
>
129
-
Back
130
-
</a>
131
-
<Title>{post.value.title}</Title>
132
-
<PostInfo
133
-
content={post.value.content}
134
-
createdAt={post.value.createdAt}
135
-
includeAuthor
136
-
className="text-sm"
137
-
/>
138
-
<div className="diagonal-pattern w-full h-3" />
139
-
</div>
140
-
<div className="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0">
141
-
{/* Render GFM HTML via dangerouslySetInnerHTML */}
142
-
<div
143
-
class="mt-8 markdown-body"
144
-
dangerouslySetInnerHTML={{ __html: render(post.value.content) }}
145
-
/>
146
-
</div>
147
-
</article>
148
-
</main>
149
-
<Footer />
150
-
</div>
120
+
<Layout>
121
+
<div class="p-8 pb-20 gap-16 sm:p-20">
122
+
<link rel="alternate" href={post.uri} />
123
+
<div class="max-w-[600px] mx-auto">
124
+
<a
125
+
href="/"
126
+
class="hover:underline hover:underline-offset-4 font-medium block mb-8"
127
+
>
128
+
Back
129
+
</a>
130
+
<article class="w-full space-y-8">
131
+
<div class="space-y-4 w-full">
132
+
<Title>{post.value.title}</Title>
133
+
<PostInfo
134
+
content={post.value.content}
135
+
createdAt={post.value.createdAt}
136
+
includeAuthor
137
+
class="text-sm"
138
+
/>
139
+
<div class="diagonal-pattern w-full h-3" />
140
+
</div>
141
+
<div class="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0">
142
+
<div
143
+
class="mt-8 markdown-body"
144
+
dangerouslySetInnerHTML={{
145
+
__html: render(post.value.content),
146
+
}}
147
+
/>
148
+
</div>
149
+
</article>
150
+
</div>
151
+
</div>
152
+
</Layout>
151
153
</>
152
154
);
153
155
}