Leaflet Blog in Deno Fresh

some stuff

Changed files
+57 -18
components
islands
routes
+16 -5
components/footer.tsx
··· 1 1 import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons"; 2 - 2 + import { useState } from "preact/hooks"; 3 3 import { env } from "../lib/env.ts"; 4 4 5 5 export function Footer() { 6 + const [blueskyHovered, setBlueskyHovered] = useState(false); 7 + const [githubHovered, setGithubHovered] = useState(false); 8 + 6 9 return ( 7 10 <footer class="py-8 flex gap-6 flex-wrap items-center justify-center text-sm"> 8 11 <a 9 - class="flex items-center gap-2 hover:underline hover:underline-offset-4" 12 + class="flex items-center gap-2 relative group" 10 13 href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`} 11 14 target="_blank" 12 15 rel="noopener noreferrer" 16 + data-hovered={blueskyHovered} 17 + onMouseEnter={() => setBlueskyHovered(true)} 18 + onMouseLeave={() => setBlueskyHovered(false)} 13 19 > 14 20 <svg 15 21 width={16} ··· 19 25 > 20 26 <path d={BlueskyIcon.path} /> 21 27 </svg> 22 - Bluesky 28 + <span class="opacity-50 group-hover:opacity-100 transition-opacity">Bluesky</span> 29 + <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" /> 23 30 </a> 24 31 <a 25 - class="flex items-center gap-2 hover:underline hover:underline-offset-4" 32 + class="flex items-center gap-2 relative group" 26 33 href="https://github.com/knotbin" 27 34 target="_blank" 28 35 rel="noopener noreferrer" 36 + data-hovered={githubHovered} 37 + onMouseEnter={() => setGithubHovered(true)} 38 + onMouseLeave={() => setGithubHovered(false)} 29 39 > 30 40 <svg 31 41 width={16} ··· 35 45 > 36 46 <path d={GithubIcon.path} /> 37 47 </svg> 38 - GitHub 48 + <span class="opacity-50 group-hover:opacity-100 transition-opacity">GitHub</span> 49 + <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" /> 39 50 </a> 40 51 </footer> 41 52 );
+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 + {" "}&middot;{" "} 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
··· 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
··· 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
··· 11 11 <Layout> 12 12 <div class="p-8 pb-20 gap-16 sm:p-20"> 13 13 <div class="max-w-[600px] mx-auto"> 14 - <Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12"> 14 + <Title class="font-serif-italic text-`4xl sm:text-5xl lowercase mb-12"> 15 15 About 16 16 </Title> 17 17
+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>