+19
components/post-info.tsx
+19
components/post-info.tsx
···
1
1
import { date } from "../lib/date.ts";
2
2
import { env } from "../lib/env.ts";
3
+
import { CgTimelapse } from "jsr:@preact-icons/cg";
3
4
4
5
import { Paragraph } from "./typography.tsx";
5
6
import type { ComponentChildren } from "preact";
7
+
import { h } from "preact";
8
+
9
+
// Wrapper component for the icon to handle compatibility issues
10
+
const TimeIcon = () => h(CgTimelapse, { size: 13 });
11
+
12
+
// Calculate reading time based on content length
13
+
function getReadingTime(content: string): number {
14
+
const wordsPerMinute = 200;
15
+
const words = content.trim().split(/\s+/).length;
16
+
const minutes = Math.max(1, Math.ceil(words / wordsPerMinute));
17
+
return minutes;
18
+
}
6
19
7
20
export function PostInfo({
8
21
createdAt,
···
17
30
className?: string;
18
31
children?: ComponentChildren;
19
32
}) {
33
+
const readingTime = getReadingTime(content);
34
+
20
35
return (
21
36
<Paragraph className={className}>
22
37
{includeAuthor && (
···
33
48
{createdAt && (
34
49
<>
35
50
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>
51
+
{" "}·{" "}
36
52
</>
37
53
)}
54
+
<span >
55
+
<span style={{ lineHeight: 1, marginRight: '0.25rem' }}>{readingTime} min read</span>
56
+
</span>
38
57
{children}
39
58
</Paragraph>
40
59
);
+3
deno.json
+3
deno.json
···
23
23
"imports": {
24
24
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
25
25
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
26
+
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
27
+
"@preact-icons/fi": "jsr:@preact-icons/fi@^1.0.13",
28
+
"@tabler/icons-preact": "npm:@tabler/icons-preact@^3.31.0",
26
29
"preact": "https://esm.sh/preact@10.22.0",
27
30
"preact/": "https://esm.sh/preact@10.22.0/",
28
31
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
+16
-4
islands/layout.tsx
+16
-4
islands/layout.tsx
···
4
4
5
5
export function Layout({ children }: { children: ComponentChildren }) {
6
6
const [isScrolled, setIsScrolled] = useState(false);
7
+
const [blogHovered, setBlogHovered] = useState(false);
8
+
const [aboutHovered, setAboutHovered] = useState(false);
7
9
8
10
// Get current path to determine active nav item
9
11
const path = typeof window !== "undefined" ? window.location.pathname : "";
···
27
29
28
30
return (
29
31
<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">
32
+
<nav class="w-full sticky top-0 z-50 backdrop-blur-sm transition-[padding,border-color] duration-200">
31
33
<div class="relative">
32
34
<div
33
35
class="absolute inset-x-0 bottom-0 h-2 diagonal-pattern opacity-0 transition-opacity duration-300"
···
40
42
</a>
41
43
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
42
44
<div class="text-base flex items-center gap-7">
43
-
<a href="/" class="relative group" data-current={isActive("/")}>
45
+
<a
46
+
href="/"
47
+
class="relative group"
48
+
data-current={isActive("/")}
49
+
data-hovered={blogHovered}
50
+
onMouseEnter={() => setBlogHovered(true)}
51
+
onMouseLeave={() => setBlogHovered(false)}
52
+
>
44
53
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
45
54
blog
46
55
</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" />
56
+
<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" />
48
57
</a>
49
58
<a
50
59
href="/about"
51
60
class="relative group"
52
61
data-current={isActive("/about")}
62
+
data-hovered={aboutHovered}
63
+
onMouseEnter={() => setAboutHovered(true)}
64
+
onMouseLeave={() => setAboutHovered(false)}
53
65
>
54
66
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
55
67
about
56
68
</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" />
69
+
<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" />
58
70
</a>
59
71
</div>
60
72
</div>
+1
-1
routes/about.tsx
+1
-1
routes/about.tsx
+2
-8
routes/post/[slug].tsx
+2
-8
routes/post/[slug].tsx
···
111
111
<Head>
112
112
<title>{post.value.title} — knotbin</title>
113
113
<meta name="description" content="by Roscoe Rubin-Rottenberg" />
114
-
{/* Merge GFM’s default styles with our dark-mode overrides */}
114
+
{/* Merge GFM's default styles with our dark-mode overrides */}
115
115
<style
116
116
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
117
117
/>
···
121
121
<div class="p-8 pb-20 gap-16 sm:p-20">
122
122
<link rel="alternate" href={post.uri} />
123
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
124
<article class="w-full space-y-8">
131
125
<div class="space-y-4 w-full">
132
126
<Title>{post.value.title}</Title>
···
134
128
content={post.value.content}
135
129
createdAt={post.value.createdAt}
136
130
includeAuthor
137
-
class="text-sm"
131
+
className="text-sm"
138
132
/>
139
133
<div class="diagonal-pattern w-full h-3" />
140
134
</div>