Leaflet Blog in Deno Fresh

some stuff

Changed files
+57 -18
components
islands
routes
+16 -5
components/footer.tsx
···
import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons";
-
+
import { useState } from "preact/hooks";
import { env } from "../lib/env.ts";
export function Footer() {
+
const [blueskyHovered, setBlueskyHovered] = useState(false);
+
const [githubHovered, setGithubHovered] = useState(false);
+
return (
<footer class="py-8 flex gap-6 flex-wrap items-center justify-center text-sm">
<a
-
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
class="flex items-center gap-2 relative group"
href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`}
target="_blank"
rel="noopener noreferrer"
+
data-hovered={blueskyHovered}
+
onMouseEnter={() => setBlueskyHovered(true)}
+
onMouseLeave={() => setBlueskyHovered(false)}
>
<svg
width={16}
···
>
<path d={BlueskyIcon.path} />
</svg>
-
Bluesky
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">Bluesky</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
<a
-
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
class="flex items-center gap-2 relative group"
href="https://github.com/knotbin"
target="_blank"
rel="noopener noreferrer"
+
data-hovered={githubHovered}
+
onMouseEnter={() => setGithubHovered(true)}
+
onMouseLeave={() => setGithubHovered(false)}
>
<svg
width={16}
···
>
<path d={GithubIcon.path} />
</svg>
-
GitHub
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">GitHub</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
</footer>
);
+19
components/post-info.tsx
···
import { date } from "../lib/date.ts";
import { env } from "../lib/env.ts";
+
import { CgTimelapse } from "jsr:@preact-icons/cg";
import { Paragraph } from "./typography.tsx";
import type { ComponentChildren } from "preact";
+
import { h } from "preact";
+
+
// Wrapper component for the icon to handle compatibility issues
+
const TimeIcon = () => h(CgTimelapse, { size: 13 });
+
+
// Calculate reading time based on content length
+
function getReadingTime(content: string): number {
+
const wordsPerMinute = 200;
+
const words = content.trim().split(/\s+/).length;
+
const minutes = Math.max(1, Math.ceil(words / wordsPerMinute));
+
return minutes;
+
}
export function PostInfo({
createdAt,
···
className?: string;
children?: ComponentChildren;
}) {
+
const readingTime = getReadingTime(content);
+
return (
<Paragraph className={className}>
{includeAuthor && (
···
{createdAt && (
<>
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>
+
{" "}&middot;{" "}
</>
)}
+
<span >
+
<span style={{ lineHeight: 1, marginRight: '0.25rem' }}>{readingTime} min read</span>
+
</span>
{children}
</Paragraph>
);
+3
deno.json
···
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
+
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
+
"@preact-icons/fi": "jsr:@preact-icons/fi@^1.0.13",
+
"@tabler/icons-preact": "npm:@tabler/icons-preact@^3.31.0",
"preact": "https://esm.sh/preact@10.22.0",
"preact/": "https://esm.sh/preact@10.22.0/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
+16 -4
islands/layout.tsx
···
export function Layout({ children }: { children: ComponentChildren }) {
const [isScrolled, setIsScrolled] = useState(false);
+
const [blogHovered, setBlogHovered] = useState(false);
+
const [aboutHovered, setAboutHovered] = useState(false);
// Get current path to determine active nav item
const path = typeof window !== "undefined" ? window.location.pathname : "";
···
return (
<div class="flex flex-col min-h-dvh">
-
<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">
+
<nav class="w-full sticky top-0 z-50 backdrop-blur-sm transition-[padding,border-color] duration-200">
<div class="relative">
<div
class="absolute inset-x-0 bottom-0 h-2 diagonal-pattern opacity-0 transition-opacity duration-300"
···
</a>
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
<div class="text-base flex items-center gap-7">
-
<a href="/" class="relative group" data-current={isActive("/")}>
+
<a
+
href="/"
+
class="relative group"
+
data-current={isActive("/")}
+
data-hovered={blogHovered}
+
onMouseEnter={() => setBlogHovered(true)}
+
onMouseLeave={() => setBlogHovered(false)}
+
>
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
blog
</span>
-
<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" />
+
<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" />
</a>
<a
href="/about"
class="relative group"
data-current={isActive("/about")}
+
data-hovered={aboutHovered}
+
onMouseEnter={() => setAboutHovered(true)}
+
onMouseLeave={() => setAboutHovered(false)}
>
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
about
</span>
-
<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" />
+
<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" />
</a>
</div>
</div>
+1 -1
routes/about.tsx
···
<Layout>
<div class="p-8 pb-20 gap-16 sm:p-20">
<div class="max-w-[600px] mx-auto">
-
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
+
<Title class="font-serif-italic text-`4xl sm:text-5xl lowercase mb-12">
About
</Title>
+2 -8
routes/post/[slug].tsx
···
<Head>
<title>{post.value.title} — knotbin</title>
<meta name="description" content="by Roscoe Rubin-Rottenberg" />
-
{/* Merge GFM’s default styles with our dark-mode overrides */}
+
{/* Merge GFM's default styles with our dark-mode overrides */}
<style
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
/>
···
<div class="p-8 pb-20 gap-16 sm:p-20">
<link rel="alternate" href={post.uri} />
<div class="max-w-[600px] mx-auto">
-
<a
-
href="/"
-
class="hover:underline hover:underline-offset-4 font-medium block mb-8"
-
>
-
Back
-
</a>
<article class="w-full space-y-8">
<div class="space-y-4 w-full">
<Title>{post.value.title}</Title>
···
content={post.value.content}
createdAt={post.value.createdAt}
includeAuthor
-
class="text-sm"
+
className="text-sm"
/>
<div class="diagonal-pattern w-full h-3" />
</div>