Leaflet Blog in Deno Fresh

Work page and design tweaks

+340 -102
+45 -10
components/TextBlock.tsx
··· 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 + } 7 + 8 + interface LinkFeature { 9 + $type: "pub.leaflet.richtext.facet#link"; 10 + uri: string; 11 + } 12 + 13 + function byteToCharIndex(text: string, byteIndex: number): number { 14 + const textEncoder = new TextEncoder(); 15 + const textDecoder = new TextDecoder(); 16 + const fullBytes = textEncoder.encode(text); 17 + const bytes = fullBytes.slice(0, byteIndex); 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 - if (!facets || !facets.some(f => f.features && f.features.length > 0)) { 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 - if (facet.index.byteStart > lastIndex) { 20 - parts.push(plaintext.slice(lastIndex, facet.index.byteStart)); 31 + // Convert byte positions to character positions 32 + const charStart = byteToCharIndex(plaintext, facet.index.byteStart); 33 + const charEnd = byteToCharIndex(plaintext, facet.index.byteEnd); 34 + const charLastIndex = byteToCharIndex(plaintext, lastIndex); 35 + 36 + if (charStart > charLastIndex) { 37 + parts.push(plaintext.slice(charLastIndex, charStart)); 21 38 } 22 39 23 - const text = plaintext.slice(facet.index.byteStart, facet.index.byteEnd); 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 + } else if (feature.$type === "pub.leaflet.richtext.facet#link") { 59 + const linkFeature = feature as LinkFeature; 60 + parts.push({ text, type: feature.$type, uri: linkFeature.uri }); 41 61 } else { 42 - parts.push({ text, type: feature.$type }); 62 + parts.push(text); 43 63 } 44 64 45 65 lastIndex = facet.index.byteEnd; 46 66 }); 47 67 48 - if (lastIndex < plaintext.length) { 49 - parts.push(plaintext.slice(lastIndex)); 68 + // Convert final lastIndex from bytes to characters 69 + const charLastIndex = byteToCharIndex(plaintext, lastIndex); 70 + 71 + if (charLastIndex < plaintext.length) { 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 - style={{ borderRadius: '0.375rem' }} 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 + case "pub.leaflet.richtext.facet#link": 102 + return ( 103 + <a 104 + key={i} 105 + href={part.uri} 106 + target="_blank" 107 + rel="noopener noreferrer" 108 + className="text-blue-600 dark:text-blue-400 hover:underline" 109 + > 110 + {part.text} 111 + </a> 112 + ); 78 113 default: 79 114 return part.text; 80 115 } 81 116 })} 82 117 </> 83 118 ); 84 - } 119 + }
+150
components/project-list-item.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState } from "preact/hooks"; 4 + import { cx } from "../lib/cx.ts"; 5 + import { Title } from "./typography.tsx"; 6 + 7 + interface Project { 8 + id: string; 9 + title: string; 10 + description: string; 11 + technologies: string[]; 12 + url: string; 13 + demo?: string; 14 + year: string; 15 + status: "active" | "completed" | "maintained" | "archived"; 16 + } 17 + 18 + export function ProjectListItem({ project }: { project: Project }) { 19 + const [isHovered, setIsHovered] = useState(false); 20 + const [isLeaving, setIsLeaving] = useState(false); 21 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 22 + 23 + // Clean up any timeouts on unmount 24 + useEffect(() => { 25 + return () => { 26 + if (timeoutRef.current) { 27 + clearTimeout(timeoutRef.current); 28 + } 29 + }; 30 + }, []); 31 + 32 + const handleMouseEnter = () => { 33 + if (timeoutRef.current) { 34 + clearTimeout(timeoutRef.current); 35 + } 36 + setIsLeaving(false); 37 + setIsHovered(true); 38 + }; 39 + 40 + const handleMouseLeave = () => { 41 + setIsLeaving(true); 42 + timeoutRef.current = setTimeout(() => { 43 + setIsHovered(false); 44 + setIsLeaving(false); 45 + }, 300); // Match animation duration 46 + }; 47 + 48 + const getStatusColor = (status: string) => { 49 + switch (status) { 50 + case "active": 51 + return "text-green-600 dark:text-green-400"; 52 + case "completed": 53 + return "text-blue-600 dark:text-blue-400"; 54 + case "maintained": 55 + return "text-yellow-600 dark:text-yellow-400"; 56 + case "archived": 57 + return "text-slate-500 dark:text-slate-400"; 58 + default: 59 + return "text-slate-600 dark:text-slate-300"; 60 + } 61 + }; 62 + 63 + return ( 64 + <> 65 + {isHovered && ( 66 + <div 67 + className={cx( 68 + "fixed inset-0 pointer-events-none z-0", 69 + isLeaving ? "animate-fade-out" : "animate-fade-in", 70 + )} 71 + > 72 + <div className="h-full w-full pt-[120px] flex items-center overflow-hidden"> 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 + {Array(8).fill(project.title).join(" · ")} 75 + </div> 76 + </div> 77 + </div> 78 + )} 79 + <a 80 + href={project.demo || project.url} 81 + target="_blank" 82 + rel="noopener noreferrer" 83 + className="w-full group block" 84 + onMouseEnter={handleMouseEnter} 85 + onMouseLeave={handleMouseLeave} 86 + > 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 + <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 + <div className="flex-1 py-2 px-4 z-10 relative w-full"> 90 + <div className="flex items-start justify-between gap-4"> 91 + <Title className="text-lg w-full flex-1" level="h3"> 92 + {project.title} 93 + </Title> 94 + <div className="flex items-center gap-2 shrink-0"> 95 + <span className="text-xs text-slate-500 dark:text-slate-400"> 96 + {project.year} 97 + </span> 98 + <span 99 + className={cx( 100 + "text-xs font-medium capitalize", 101 + getStatusColor(project.status), 102 + )} 103 + > 104 + {project.status} 105 + </span> 106 + </div> 107 + </div> 108 + 109 + <div className="flex flex-wrap gap-1 mt-2"> 110 + {project.technologies.slice(0, 4).map((tech) => ( 111 + <span 112 + key={tech} 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 + > 115 + {tech} 116 + </span> 117 + ))} 118 + {project.technologies.length > 4 && ( 119 + <span className="text-xs px-2 py-0.5 text-slate-500 dark:text-slate-400"> 120 + +{project.technologies.length - 4} more 121 + </span> 122 + )} 123 + </div> 124 + 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 + <div className="overflow-hidden"> 127 + <p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3 mb-3"> 128 + {project.description} 129 + </p> 130 + <div className="flex gap-3"> 131 + {project.demo && ( 132 + <a 133 + href={project.url} 134 + target="_blank" 135 + rel="noopener noreferrer" 136 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 137 + onClick={(e) => e.stopPropagation()} 138 + > 139 + Source 140 + </a> 141 + )} 142 + </div> 143 + </div> 144 + </div> 145 + </div> 146 + </article> 147 + </a> 148 + </> 149 + ); 150 + }
+4 -3
components/typography.tsx
··· 56 56 ); 57 57 } 58 58 59 - export function Code( 60 - { className, ...props }: h.JSX.HTMLAttributes<HTMLElement>, 61 - ) { 59 + export function Code({ 60 + className, 61 + ...props 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 - "tags": [ 15 - "fresh", 16 - "recommended" 17 - ] 14 + "tags": ["fresh", "recommended"] 18 15 } 19 16 }, 20 - "exclude": [ 21 - "**/_fresh/*" 22 - ], 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 - "@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1", 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 + 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 + 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 + "./routes/work.tsx": $work, 25 28 }, 26 29 islands: { 27 30 "./islands/layout.tsx": $layout, 28 31 "./islands/post-list.tsx": $post_list, 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 + 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 - setIsScrolled(window.scrollY > 0); 22 + setIsScrolled(globalThis.scrollY > 0); 22 23 }; 23 24 24 25 const handlePathChange = () => { 25 - pathname.value = window.location.pathname; 26 + pathname.value = globalThis.location.pathname; 26 27 }; 27 28 28 - window.addEventListener("scroll", handleScroll); 29 - window.addEventListener("popstate", handlePathChange); 29 + globalThis.addEventListener("scroll", handleScroll); 30 + globalThis.addEventListener("popstate", handlePathChange); 30 31 handleScroll(); // Check initial scroll position 31 32 handlePathChange(); // Set initial path 32 33 33 34 return () => { 34 - window.removeEventListener("scroll", handleScroll); 35 - window.removeEventListener("popstate", handlePathChange); 35 + globalThis.removeEventListener("scroll", handleScroll); 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 + </span> 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 + </a> 68 + <a 69 + href="/work" 70 + class="relative group" 71 + data-current={isActive("/work")} 72 + data-hovered={workHovered} 73 + onMouseEnter={() => setWorkHovered(true)} 74 + onMouseLeave={() => setWorkHovered(false)} 75 + > 76 + <span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity"> 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 + import { PubLeafletDocument } from "@atcute/leaflet"; 4 5 5 6 interface PostRecord { 6 - value: any; 7 + value: PubLeafletDocument.Main; 7 8 uri: string; 8 9 } 9 10 10 - export default function PostList( 11 - { posts: initialPosts }: { posts: PostRecord[] }, 12 - ) { 11 + export default function PostList({ 12 + posts: initialPosts, 13 + }: { 14 + posts: PostRecord[]; 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 - return ( 25 - <PostListItem 26 - key={record.uri} 27 - post={post} 28 - rkey={rkey} 29 - /> 30 - ); 27 + return <PostListItem key={record.uri} post={post} rkey={rkey} />; 31 28 })} 32 29 </> 33 30 );
+35
islands/project-list.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect } from "preact/hooks"; 3 + import { ProjectListItem } from "../components/project-list-item.tsx"; 4 + 5 + interface Project { 6 + id: string; 7 + title: string; 8 + description: string; 9 + technologies: string[]; 10 + url: string; 11 + demo?: string; 12 + year: string; 13 + status: "active" | "completed" | "maintained" | "archived"; 14 + } 15 + 16 + export default function ProjectList( 17 + { projects: initialProjects }: { projects: Project[] }, 18 + ) { 19 + const projects = useSignal(initialProjects); 20 + 21 + useEffect(() => { 22 + projects.value = initialProjects; 23 + }, [initialProjects]); 24 + 25 + return ( 26 + <> 27 + {projects.value?.map((project) => ( 28 + <ProjectListItem 29 + key={project.id} 30 + project={project} 31 + /> 32 + ))} 33 + </> 34 + ); 35 + }
-6
package-lock.json
··· 1 - { 2 - "name": "blog", 3 - "lockfileVersion": 3, 4 - "requires": true, 5 - "packages": {} 6 - }
-32
routes/index.tsx
··· 6 6 export const dynamic = "force-static"; 7 7 export const revalidate = 3600; // 1 hour 8 8 9 - const stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware = [ 10 - "is looking into it", 11 - "i think therefore imdb", 12 - "isn't a real word", 13 - "enjoys each protocol equally", 14 - "is having a very semantic argument", 15 - "wrote these derivitive taglines", 16 - "is way too into css animations", 17 - "uses dark mode at noon", 18 - "overthinks variable names", 19 - "git pushes with -f", 20 - "formats on save", 21 - "is praising kier", 22 - "pretends to understand monads", 23 - "brags about their vim config", 24 - "documents their code (lies)", 25 - "isn't mysterious or important", 26 - "wants to be included in discourse", 27 - "is deeply offended by semicolons", 28 - "is morraly opposed to touching grass", 29 - ]; 30 - 31 - function getRandomTagline() { 32 - return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[ 33 - Math.floor( 34 - Math.random() * 35 - stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length, 36 - ) 37 - ]; 38 - } 39 - 40 9 export default async function Home() { 41 10 const posts = await getPosts(); 42 - const tagline = getRandomTagline(); 43 11 44 12 return ( 45 13 <Layout>
+20 -26
routes/post/[slug].tsx
··· 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 - // Debug log to check for duplicate rendering 53 - console.log( 54 - "Rendering block", 55 - b.block.$type, 56 - (b.block as any).plaintext || (b.block as any).text || "" 57 - ); 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 - <ListItem 71 - item={child} 72 - did={did} 73 - key={index} 74 - className={className} 75 - /> 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 - style={{ aspectRatio: width && height ? `${width} / ${height}` : undefined }} 85 + style={{ 86 + aspectRatio: width && height ? `${width} / ${height}` : undefined, 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 - let headingStyle = "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap "; 105 + let headingStyle = 106 + "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap "; 117 107 switch (level) { 118 108 case 1: 119 - headingStyle += "text-4xl lg:text-5xl"; 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 - <Tag className={headingStyle + ' ' + className}> 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 - const uniqueBlocks = blocks.filter(b => { 191 - const key = b.block.$type + '|' + ((b.block as any).plaintext || ''); 180 + const uniqueBlocks = blocks.filter((b) => { 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 - .filter(b => b.block.$type === "pub.leaflet.blocks.text") 199 - .map(b => (b.block as PubLeafletBlocksText.Main).plaintext) 200 - .join(' '); 188 + .filter((b) => b.block.$type === "pub.leaflet.blocks.text") 189 + .map((b) => (b.block as PubLeafletBlocksText.Main).plaintext) 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 - <Title>{post.value.title || 'Untitled'}</Title> 208 + <Title>{post.value.title || "Untitled"}</Title> 219 209 {post.value.description && ( 220 - <p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose"> 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 - <Block block={block} did={post.uri.split('/')[2]} key={index} /> 224 + <Block 225 + block={block} 226 + did={post.uri.split("/")[2]} 227 + key={index} 228 + /> 235 229 ))} 236 230 </div> 237 231 </article>
+51
routes/work.tsx
··· 1 + import ProjectList from "../islands/project-list.tsx"; 2 + import { Title } from "../components/typography.tsx"; 3 + import { Layout } from "../islands/layout.tsx"; 4 + 5 + export const dynamic = "force-static"; 6 + export const revalidate = 3600; // 1 hour 7 + 8 + interface Project { 9 + id: string; 10 + title: string; 11 + description: string; 12 + technologies: string[]; 13 + url: string; 14 + demo?: string; 15 + year: string; 16 + status: "active" | "completed" | "maintained" | "archived"; 17 + } 18 + 19 + // Mock project data - replace with your actual projects 20 + const projects: Project[] = [ 21 + { 22 + id: "1", 23 + title: "ATP Airport", 24 + description: `The first ever graphical PDS migration tool for AT Protocol. 25 + Allows users to migrate their data from one PDS to another without any 26 + experience or technical knowledge.`, 27 + technologies: ["AT Protocol", "Fresh", "Deno", "TypeScript"], 28 + url: "https://github.com/knotbin/airport", 29 + demo: "https://atpairport.com", 30 + year: "2025", 31 + status: "active", 32 + }, 33 + ]; 34 + 35 + export default function Work() { 36 + return ( 37 + <Layout> 38 + <div class="p-8 pb-20 gap-16 sm:p-20"> 39 + <div class="max-w-[600px] mx-auto"> 40 + <Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12"> 41 + Work 42 + </Title> 43 + 44 + <div class="space-y-4 w-full"> 45 + <ProjectList projects={projects} /> 46 + </div> 47 + </div> 48 + </div> 49 + </Layout> 50 + ); 51 + }