Leaflet Blog in Deno Fresh

Work page and design tweaks

+45 -10
components/TextBlock.tsx
···
-
import { h } from "preact";
import { PubLeafletBlocksText } from "npm:@atcute/leaflet";
interface TextBlockProps {
plaintext: string;
facets?: PubLeafletBlocksText.Main["facets"];
+
}
+
+
interface LinkFeature {
+
$type: "pub.leaflet.richtext.facet#link";
+
uri: string;
+
}
+
+
function byteToCharIndex(text: string, byteIndex: number): number {
+
const textEncoder = new TextEncoder();
+
const textDecoder = new TextDecoder();
+
const fullBytes = textEncoder.encode(text);
+
const bytes = fullBytes.slice(0, byteIndex);
+
return textDecoder.decode(bytes).length;
}
export function TextBlock({ plaintext, facets }: TextBlockProps) {
// Only process facets if at least one facet has features
-
if (!facets || !facets.some(f => f.features && f.features.length > 0)) {
+
if (!facets || !facets.some((f) => f.features && f.features.length > 0)) {
return <>{plaintext}</>;
}
···
let lastIndex = 0;
facets.forEach((facet) => {
-
if (facet.index.byteStart > lastIndex) {
-
parts.push(plaintext.slice(lastIndex, facet.index.byteStart));
+
// Convert byte positions to character positions
+
const charStart = byteToCharIndex(plaintext, facet.index.byteStart);
+
const charEnd = byteToCharIndex(plaintext, facet.index.byteEnd);
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
+
+
if (charStart > charLastIndex) {
+
parts.push(plaintext.slice(charLastIndex, charStart));
}
-
const text = plaintext.slice(facet.index.byteStart, facet.index.byteEnd);
+
const text = plaintext.slice(charStart, charEnd);
const feature = facet.features?.[0];
if (!feature) {
···
parts.push({ text, type: feature.$type });
} else if (feature.$type === "pub.leaflet.richtext.facet#underline") {
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#link") {
+
const linkFeature = feature as LinkFeature;
+
parts.push({ text, type: feature.$type, uri: linkFeature.uri });
} else {
-
parts.push({ text, type: feature.$type });
+
parts.push(text);
}
lastIndex = facet.index.byteEnd;
});
-
if (lastIndex < plaintext.length) {
-
parts.push(plaintext.slice(lastIndex));
+
// Convert final lastIndex from bytes to characters
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
+
+
if (charLastIndex < plaintext.length) {
+
parts.push(plaintext.slice(charLastIndex));
}
return (
···
<mark
key={i}
className="bg-blue-100 dark:bg-blue-900 text-inherit rounded px-1"
-
style={{ borderRadius: '0.375rem' }}
+
style={{ borderRadius: "0.375rem" }}
>
{part.text}
</mark>
···
return <s key={i}>{part.text}</s>;
case "pub.leaflet.richtext.facet#underline":
return <u key={i}>{part.text}</u>;
+
case "pub.leaflet.richtext.facet#link":
+
return (
+
<a
+
key={i}
+
href={part.uri}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-600 dark:text-blue-400 hover:underline"
+
>
+
{part.text}
+
</a>
+
);
default:
return part.text;
}
})}
</>
);
-
}
+
}
+150
components/project-list-item.tsx
···
+
"use client";
+
+
import { useEffect, useRef, useState } from "preact/hooks";
+
import { cx } from "../lib/cx.ts";
+
import { Title } from "./typography.tsx";
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
export function ProjectListItem({ project }: { project: Project }) {
+
const [isHovered, setIsHovered] = useState(false);
+
const [isLeaving, setIsLeaving] = useState(false);
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+
// Clean up any timeouts on unmount
+
useEffect(() => {
+
return () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
};
+
}, []);
+
+
const handleMouseEnter = () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
setIsLeaving(false);
+
setIsHovered(true);
+
};
+
+
const handleMouseLeave = () => {
+
setIsLeaving(true);
+
timeoutRef.current = setTimeout(() => {
+
setIsHovered(false);
+
setIsLeaving(false);
+
}, 300); // Match animation duration
+
};
+
+
const getStatusColor = (status: string) => {
+
switch (status) {
+
case "active":
+
return "text-green-600 dark:text-green-400";
+
case "completed":
+
return "text-blue-600 dark:text-blue-400";
+
case "maintained":
+
return "text-yellow-600 dark:text-yellow-400";
+
case "archived":
+
return "text-slate-500 dark:text-slate-400";
+
default:
+
return "text-slate-600 dark:text-slate-300";
+
}
+
};
+
+
return (
+
<>
+
{isHovered && (
+
<div
+
className={cx(
+
"fixed inset-0 pointer-events-none z-0",
+
isLeaving ? "animate-fade-out" : "animate-fade-in",
+
)}
+
>
+
<div className="h-full w-full pt-[120px] flex items-center overflow-hidden">
+
<div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12 absolute left-0">
+
{Array(8).fill(project.title).join(" · ")}
+
</div>
+
</div>
+
</div>
+
)}
+
<a
+
href={project.demo || project.url}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="w-full group block"
+
onMouseEnter={handleMouseEnter}
+
onMouseLeave={handleMouseLeave}
+
>
+
<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">
+
<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)]" />
+
<div className="flex-1 py-2 px-4 z-10 relative w-full">
+
<div className="flex items-start justify-between gap-4">
+
<Title className="text-lg w-full flex-1" level="h3">
+
{project.title}
+
</Title>
+
<div className="flex items-center gap-2 shrink-0">
+
<span className="text-xs text-slate-500 dark:text-slate-400">
+
{project.year}
+
</span>
+
<span
+
className={cx(
+
"text-xs font-medium capitalize",
+
getStatusColor(project.status),
+
)}
+
>
+
{project.status}
+
</span>
+
</div>
+
</div>
+
+
<div className="flex flex-wrap gap-1 mt-2">
+
{project.technologies.slice(0, 4).map((tech) => (
+
<span
+
key={tech}
+
className="text-xs px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded-sm text-slate-600 dark:text-slate-300"
+
>
+
{tech}
+
</span>
+
))}
+
{project.technologies.length > 4 && (
+
<span className="text-xs px-2 py-0.5 text-slate-500 dark:text-slate-400">
+
+{project.technologies.length - 4} more
+
</span>
+
)}
+
</div>
+
+
<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">
+
<div className="overflow-hidden">
+
<p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3 mb-3">
+
{project.description}
+
</p>
+
<div className="flex gap-3">
+
{project.demo && (
+
<a
+
href={project.url}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
+
onClick={(e) => e.stopPropagation()}
+
>
+
Source
+
</a>
+
)}
+
</div>
+
</div>
+
</div>
+
</div>
+
</article>
+
</a>
+
</>
+
);
+
}
+4 -3
components/typography.tsx
···
);
}
-
export function Code(
-
{ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>,
-
) {
+
export function Code({
+
className,
+
...props
+
}: h.JSX.HTMLAttributes<HTMLElement>) {
return (
<code
className={cx(
+3 -8
deno.json
···
},
"lint": {
"rules": {
-
"tags": [
-
"fresh",
-
"recommended"
-
]
+
"tags": ["fresh", "recommended"]
}
},
-
"exclude": [
-
"**/_fresh/*"
-
],
+
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@atcute/atproto": "npm:@atcute/atproto@^3.0.1",
"@atcute/client": "npm:@atcute/client@^4.0.1",
-
"@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1",
+
"@atcute/leaflet": "npm:@atcute/leaflet@^1.0.2",
"@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",
+4
fresh.gen.ts
···
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $rss from "./routes/rss.ts";
+
import * as $work from "./routes/work.tsx";
import * as $layout from "./islands/layout.tsx";
import * as $post_list from "./islands/post-list.tsx";
+
import * as $project_list from "./islands/project-list.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
···
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/rss.ts": $rss,
+
"./routes/work.tsx": $work,
},
islands: {
"./islands/layout.tsx": $layout,
"./islands/post-list.tsx": $post_list,
+
"./islands/project-list.tsx": $project_list,
},
baseUrl: import.meta.url,
} satisfies Manifest;
+20 -6
islands/layout.tsx
···
export function Layout({ children }: { children: ComponentChildren }) {
const [isScrolled, setIsScrolled] = useState(false);
const [blogHovered, setBlogHovered] = useState(false);
+
const [workHovered, setWorkHovered] = useState(false);
const [aboutHovered, setAboutHovered] = useState(false);
const pathname = useSignal("");
···
useEffect(() => {
const handleScroll = () => {
-
setIsScrolled(window.scrollY > 0);
+
setIsScrolled(globalThis.scrollY > 0);
};
const handlePathChange = () => {
-
pathname.value = window.location.pathname;
+
pathname.value = globalThis.location.pathname;
};
-
window.addEventListener("scroll", handleScroll);
-
window.addEventListener("popstate", handlePathChange);
+
globalThis.addEventListener("scroll", handleScroll);
+
globalThis.addEventListener("popstate", handlePathChange);
handleScroll(); // Check initial scroll position
handlePathChange(); // Set initial path
return () => {
-
window.removeEventListener("scroll", handleScroll);
-
window.removeEventListener("popstate", handlePathChange);
+
globalThis.removeEventListener("scroll", handleScroll);
+
globalThis.removeEventListener("popstate", handlePathChange);
};
}, []);
···
>
<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 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="/work"
+
class="relative group"
+
data-current={isActive("/work")}
+
data-hovered={workHovered}
+
onMouseEnter={() => setWorkHovered(true)}
+
onMouseLeave={() => setWorkHovered(false)}
+
>
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
+
work
</span>
<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>
+8 -11
islands/post-list.tsx
···
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { PostListItem } from "../components/post-list-item.tsx";
+
import { PubLeafletDocument } from "@atcute/leaflet";
interface PostRecord {
-
value: any;
+
value: PubLeafletDocument.Main;
uri: string;
}
-
export default function PostList(
-
{ posts: initialPosts }: { posts: PostRecord[] },
-
) {
+
export default function PostList({
+
posts: initialPosts,
+
}: {
+
posts: PostRecord[];
+
}) {
const posts = useSignal(initialPosts);
useEffect(() => {
···
{posts.value?.map((record) => {
const post = record.value;
const rkey = record.uri.split("/").pop() || "";
-
return (
-
<PostListItem
-
key={record.uri}
-
post={post}
-
rkey={rkey}
-
/>
-
);
+
return <PostListItem key={record.uri} post={post} rkey={rkey} />;
})}
</>
);
+35
islands/project-list.tsx
···
+
import { useSignal } from "@preact/signals";
+
import { useEffect } from "preact/hooks";
+
import { ProjectListItem } from "../components/project-list-item.tsx";
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
export default function ProjectList(
+
{ projects: initialProjects }: { projects: Project[] },
+
) {
+
const projects = useSignal(initialProjects);
+
+
useEffect(() => {
+
projects.value = initialProjects;
+
}, [initialProjects]);
+
+
return (
+
<>
+
{projects.value?.map((project) => (
+
<ProjectListItem
+
key={project.id}
+
project={project}
+
/>
+
))}
+
</>
+
);
+
}
-6
package-lock.json
···
-
{
-
"name": "blog",
-
"lockfileVersion": 3,
-
"requires": true,
-
"packages": {}
-
}
-32
routes/index.tsx
···
export const dynamic = "force-static";
export const revalidate = 3600; // 1 hour
-
const stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware = [
-
"is looking into it",
-
"i think therefore imdb",
-
"isn't a real word",
-
"enjoys each protocol equally",
-
"is having a very semantic argument",
-
"wrote these derivitive taglines",
-
"is way too into css animations",
-
"uses dark mode at noon",
-
"overthinks variable names",
-
"git pushes with -f",
-
"formats on save",
-
"is praising kier",
-
"pretends to understand monads",
-
"brags about their vim config",
-
"documents their code (lies)",
-
"isn't mysterious or important",
-
"wants to be included in discourse",
-
"is deeply offended by semicolons",
-
"is morraly opposed to touching grass",
-
];
-
-
function getRandomTagline() {
-
return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[
-
Math.floor(
-
Math.random() *
-
stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length,
-
)
-
];
-
}
-
export default async function Home() {
const posts = await getPosts();
-
const tagline = getRandomTagline();
return (
<Layout>
+20 -26
routes/post/[slug].tsx
···
-
/** @jsxImportSource preact */
import { Handlers, PageProps } from "$fresh/server.ts";
import { Layout } from "../../islands/layout.tsx";
import { PostInfo } from "../../components/post-info.tsx";
···
}) {
let b = block;
-
// Debug log to check for duplicate rendering
-
console.log(
-
"Rendering block",
-
b.block.$type,
-
(b.block as any).plaintext || (b.block as any).text || ""
-
);
-
let className = `
postBlockWrapper
pt-1
···
return (
<ul className="-ml-[1px] sm:ml-[9px] pb-2">
{b.block.children.map((child, index) => (
-
<ListItem
-
item={child}
-
did={did}
-
key={index}
-
className={className}
-
/>
+
<ListItem item={child} did={did} key={index} className={className} />
))}
</ul>
);
···
width={width}
height={height}
className={`!pt-3 sm:!pt-4 ${className}`}
-
style={{ aspectRatio: width && height ? `${width} / ${height}` : undefined }}
+
style={{
+
aspectRatio: width && height ? `${width} / ${height}` : undefined,
+
}}
/>
);
}
···
const level = header.level || 1;
const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements;
// Add heading styles based on level
-
let headingStyle = "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
+
let headingStyle =
+
"font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
switch (level) {
case 1:
-
headingStyle += "text-4xl lg:text-5xl";
+
headingStyle += "text-3xl lg:text-4xl";
break;
case 2:
headingStyle += "text-3xl border-b pb-2 mb-6";
···
headingStyle += "text-2xl";
}
return (
-
<Tag className={headingStyle + ' ' + className}>
+
<Tag className={headingStyle + " " + className}>
<TextBlock plaintext={header.plaintext} facets={header.facets} />
</Tag>
);
···
}
// Deduplicate blocks by $type and plaintext
const seen = new Set();
-
const uniqueBlocks = blocks.filter(b => {
-
const key = b.block.$type + '|' + ((b.block as any).plaintext || '');
+
const uniqueBlocks = blocks.filter((b) => {
+
const key = b.block.$type + "|" + ((b.block as any).plaintext || "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const content = uniqueBlocks
-
.filter(b => b.block.$type === "pub.leaflet.blocks.text")
-
.map(b => (b.block as PubLeafletBlocksText.Main).plaintext)
-
.join(' ');
+
.filter((b) => b.block.$type === "pub.leaflet.blocks.text")
+
.map((b) => (b.block as PubLeafletBlocksText.Main).plaintext)
+
.join(" ");
return (
<>
···
<div class="max-w-[600px] mx-auto">
<article class="w-full space-y-8">
<div class="space-y-4 w-full">
-
<Title>{post.value.title || 'Untitled'}</Title>
+
<Title>{post.value.title || "Untitled"}</Title>
{post.value.description && (
-
<p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose">
+
<p class="text-xl italic md:text-2xl font-serif leading-relaxed max-w-prose">
{post.value.description}
</p>
)}
···
</div>
<div class="postContent flex flex-col">
{uniqueBlocks.map((block, index) => (
-
<Block block={block} did={post.uri.split('/')[2]} key={index} />
+
<Block
+
block={block}
+
did={post.uri.split("/")[2]}
+
key={index}
+
/>
))}
</div>
</article>
+51
routes/work.tsx
···
+
import ProjectList from "../islands/project-list.tsx";
+
import { Title } from "../components/typography.tsx";
+
import { Layout } from "../islands/layout.tsx";
+
+
export const dynamic = "force-static";
+
export const revalidate = 3600; // 1 hour
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
// Mock project data - replace with your actual projects
+
const projects: Project[] = [
+
{
+
id: "1",
+
title: "ATP Airport",
+
description: `The first ever graphical PDS migration tool for AT Protocol.
+
Allows users to migrate their data from one PDS to another without any
+
experience or technical knowledge.`,
+
technologies: ["AT Protocol", "Fresh", "Deno", "TypeScript"],
+
url: "https://github.com/knotbin/airport",
+
demo: "https://atpairport.com",
+
year: "2025",
+
status: "active",
+
},
+
];
+
+
export default function Work() {
+
return (
+
<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">
+
Work
+
</Title>
+
+
<div class="space-y-4 w-full">
+
<ProjectList projects={projects} />
+
</div>
+
</div>
+
</div>
+
</Layout>
+
);
+
}