chore: add weather component
c690f6e1
3 file(s) · +155 −27
| 1 | + | --- |
|
| 2 | + | interface Props { |
|
| 3 | + | weather: string; |
|
| 4 | + | leadingSeparator?: boolean; |
|
| 5 | + | } |
|
| 6 | + | ||
| 7 | + | const { weather, leadingSeparator } = Astro.props; |
|
| 8 | + | ||
| 9 | + | type WeatherCategory = |
|
| 10 | + | | "storm" |
|
| 11 | + | | "sleet" |
|
| 12 | + | | "snow" |
|
| 13 | + | | "rain" |
|
| 14 | + | | "fog" |
|
| 15 | + | | "partlyCloudy" |
|
| 16 | + | | "cloudy" |
|
| 17 | + | | "clear" |
|
| 18 | + | | "unknown"; |
|
| 19 | + | ||
| 20 | + | const categoryRules: { category: WeatherCategory; keywords: string[] }[] = [ |
|
| 21 | + | { category: "storm", keywords: ["thunder", "tstorm", "storm"] }, |
|
| 22 | + | { category: "sleet", keywords: ["sleet", "freez", "frzg", "mix"] }, |
|
| 23 | + | { category: "snow", keywords: ["snow", "flurr"] }, |
|
| 24 | + | { category: "rain", keywords: ["rain", "shower", "drizzle"] }, |
|
| 25 | + | { category: "fog", keywords: ["fog", "mist", "haze"] }, |
|
| 26 | + | { category: "partlyCloudy", keywords: ["partly", "variable"] }, |
|
| 27 | + | { category: "cloudy", keywords: ["overcast", "cloud"] }, |
|
| 28 | + | { category: "clear", keywords: ["clear", "sunny", "sun", "fair"] }, |
|
| 29 | + | ]; |
|
| 30 | + | ||
| 31 | + | function categorize(conditions: string): WeatherCategory { |
|
| 32 | + | const c = conditions.toLowerCase(); |
|
| 33 | + | for (const rule of categoryRules) { |
|
| 34 | + | for (const kw of rule.keywords) { |
|
| 35 | + | if (c.includes(kw)) return rule.category; |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | return "unknown"; |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | const weatherIcons: Partial<Record<WeatherCategory, string>> = { |
|
| 42 | + | partlyCloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M164 76a71.9 71.9 0 0 0-22.14 3.48A51.8 51.8 0 0 0 129 63.83l11.56-16.51a4 4 0 0 0-6.56-4.59l-11.55 16.51A52 52 0 0 0 96 52c-1.71 0-3.4.09-5.06.25l-3.5-19.85a4 4 0 0 0-7.88 1.39l3.5 19.84A52.2 52.2 0 0 0 55.85 71L39.32 59.42A4 4 0 0 0 34.73 66l16.53 11.54A51.63 51.63 0 0 0 44 104c0 1.69.09 3.37.25 5l-19.85 3.5a4 4 0 0 0 .69 7.94a4 4 0 0 0 .7-.06l19.85-3.5A52.1 52.1 0 0 0 54 134.6A48 48 0 0 0 84 220h80a72 72 0 0 0 0-144M52 104a44 44 0 0 1 82.33-21.61a72.23 72.23 0 0 0-38.82 43A48.3 48.3 0 0 0 84 124a47.76 47.76 0 0 0-23.4 6.11A44 44 0 0 1 52 104m112 108H84a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 92 143.77a4 4 0 0 0 8 .46a64.3 64.3 0 0 1 2-12.67c0-.12.07-.24.09-.36A64.06 64.06 0 1 1 164 212"/></svg>`, |
|
| 43 | + | clear: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M124 40v-8a4 4 0 0 1 8 0v8a4 4 0 0 1-8 0m64 88a60 60 0 1 1-60-60a60.07 60.07 0 0 1 60 60m-8 0a52 52 0 1 0-52 52a52.06 52.06 0 0 0 52-52M61.17 66.83a4 4 0 0 0 5.66-5.66l-8-8a4 4 0 0 0-5.66 5.66Zm0 122.34l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 0 0-5.66-5.66m136-136l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 1 0-5.66-5.66m-2.34 136a4 4 0 0 0-5.66 5.66l8 8a4 4 0 0 0 5.66-5.66ZM40 124h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8m88 88a4 4 0 0 0-4 4v8a4 4 0 0 0 8 0v-8a4 4 0 0 0-4-4m96-88h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8"/></svg>`, |
|
| 44 | + | cloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M160 44a84.11 84.11 0 0 0-76.41 49.12A60.7 60.7 0 0 0 72 92a60 60 0 0 0 0 120h88a84 84 0 0 0 0-168m0 160H72a52 52 0 1 1 8.55-103.3A83.7 83.7 0 0 0 76 128a4 4 0 0 0 8 0a76 76 0 1 1 76 76"/></svg>`, |
|
| 45 | + | storm: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M156 20a72.19 72.19 0 0 0-68.49 49.39A48 48 0 1 0 76 164h44.94l-20.37 33.94A4 4 0 0 0 104 204h32.94l-20.37 33.94a4 4 0 0 0 6.86 4.12l24-40A4 4 0 0 0 144 196h-32.94l19.2-32H156a72 72 0 0 0 0-144m0 136H76a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 84 87.77a4 4 0 0 0 8 .46A64.06 64.06 0 1 1 156 156"/></svg>`, |
|
| 46 | + | rain: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="m155.33 194.22l-32 48a4 4 0 1 1-6.66-4.44l32-48a4 4 0 0 1 6.66 4.44M228 92a72.08 72.08 0 0 1-72 72h-25.86l-30.81 46.22a4 4 0 1 1-6.66-4.44L120.53 164H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 47 | + | snow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 48 | + | sleet: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 49 | + | fog: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M120 204H72a4 4 0 0 1 0-8h48a4 4 0 0 1 0 8m64-8h-24a4 4 0 0 0 0 8h24a4 4 0 0 0 0-8m-24 32h-56a4 4 0 0 0 0 8h56a4 4 0 0 0 0-8m68-128a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 100m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 164h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 50 | + | }; |
|
| 51 | + | ||
| 52 | + | const parts = weather.split(",").map((p) => p.trim()); |
|
| 53 | + | ||
| 54 | + | const valid = parts.length >= 4; |
|
| 55 | + | ||
| 56 | + | const qualifierRE = |
|
| 57 | + | /^(slight chance|chance|isolated|scattered|numerous|widespread|patchy|areas|periods|occasional|frequent)\s+(of\s+)?/i; |
|
| 58 | + | const trailingQualifierRE = /\s+(likely)$/i; |
|
| 59 | + | ||
| 60 | + | const conditions = valid |
|
| 61 | + | ? parts[0].replace(qualifierRE, "").replace(trailingQualifierRE, "").trim() |
|
| 62 | + | : ""; |
|
| 63 | + | const temperature = valid ? `${parts[1]}°F` : ""; |
|
| 64 | + | const location = valid ? `${parts[2]}, ${parts[3]}` : ""; |
|
| 65 | + | const icon = valid ? weatherIcons[categorize(conditions)] : undefined; |
|
| 66 | + | --- |
|
| 67 | + | ||
| 68 | + | {valid && ( |
|
| 69 | + | <> |
|
| 70 | + | {leadingSeparator && |
|
| 71 | + | <span>•</span> |
|
| 72 | + | } |
|
| 73 | + | <span class="inline-flex items-center gap-1.5"> |
|
| 74 | + | {icon && <span class="inline-flex items-center [&>svg]:size-4" set:html={icon} />} |
|
| 75 | + | {conditions}{" "}{temperature} |
|
| 76 | + | </span> |
|
| 77 | + | <span>•</span> |
|
| 78 | + | <span class="inline-flex items-center gap-1.5"> |
|
| 79 | + | <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M128 68a36 36 0 1 0 36 36a36 36 0 0 0-36-36m0 64a28 28 0 1 1 28-28a28 28 0 0 1-28 28m0-112a84.09 84.09 0 0 0-84 84c0 30.42 14.17 62.79 41 93.62a250 250 0 0 0 40.73 37.66a4 4 0 0 0 4.58 0A250 250 0 0 0 171 197.62c26.81-30.83 41-63.2 41-93.62a84.09 84.09 0 0 0-84-84m37.1 172.23A254.6 254.6 0 0 1 128 227a254.6 254.6 0 0 1-37.1-34.81C73.15 171.8 52 139.9 52 104a76 76 0 0 1 152 0c0 35.9-21.15 67.8-38.9 88.23"/></svg> |
|
| 80 | + | {location} |
|
| 81 | + | </span> |
|
| 82 | + | </> |
|
| 83 | + | )} |
| 1 | 1 | --- |
|
| 2 | 2 | import PageLayout from "@/layouts/Base.astro"; |
|
| 3 | + | import Weather from "@/components/now/Weather.astro"; |
|
| 3 | 4 | import { POSTS_API } from "@/data/constants"; |
|
| 4 | 5 | import { createMarkdownRenderer } from "@/utils"; |
|
| 5 | 6 | ||
| 27 | 28 | content: string; |
|
| 28 | 29 | created_at: string; |
|
| 29 | 30 | updated_at: string; |
|
| 31 | + | weather: string | null; |
|
| 30 | 32 | } |
|
| 31 | 33 | ||
| 32 | 34 | let title: string | null = "Post"; |
|
| 34 | 36 | let contentHTML = ""; |
|
| 35 | 37 | let publishedAt = ""; |
|
| 36 | 38 | let errorMessage = ""; |
|
| 39 | + | let weatherRaw = ""; |
|
| 37 | 40 | let isError = false; |
|
| 38 | 41 | ||
| 39 | 42 | try { |
|
| 56 | 59 | ? new Date(post.published_date).toLocaleDateString() |
|
| 57 | 60 | : ""; |
|
| 58 | 61 | contentHTML = md.render(post.content || ""); |
|
| 62 | + | weatherRaw = post.weather || ""; |
|
| 59 | 63 | } |
|
| 60 | 64 | } catch (err) { |
|
| 61 | 65 | console.error("Error fetching post:", err); |
|
| 82 | 86 | ) : ( |
|
| 83 | 87 | <> |
|
| 84 | 88 | {title && <h1 class="title mb-2">{title}</h1>} |
|
| 85 | - | <time class="text-sm text-zinc-400">{publishedAt}</time> |
|
| 89 | + | <div class="flex text-sm text-zinc-400 items-center gap-x-3 gap-y-1 flex-wrap"> |
|
| 90 | + | <time>{publishedAt}</time> |
|
| 91 | + | {weatherRaw && <Weather leadingSeparator weather={weatherRaw} />} |
|
| 92 | + | </div> |
|
| 86 | 93 | <div class="prose prose-invert max-w-none my-4"> |
|
| 87 | 94 | <Fragment set:html={contentHTML} /> |
|
| 88 | 95 | </div> |
|
| 3 | 3 | ||
| 4 | 4 | import PageLayout from "@/layouts/Base.astro"; |
|
| 5 | 5 | import NowUpdates from "@/components/now/NowUpdates.astro"; |
|
| 6 | + | import Weather from "@/components/now/Weather.astro"; |
|
| 6 | 7 | ||
| 7 | 8 | const meta = { |
|
| 8 | 9 | title: "/now", |
|
| 9 | 10 | description: "What I'm up to recently", |
|
| 10 | 11 | }; |
|
| 12 | + | ||
| 13 | + | const STATION = "MRX"; |
|
| 14 | + | const GRID_X = "23"; |
|
| 15 | + | const GRID_Y = "5"; |
|
| 16 | + | const LOCATION = "Lookout Mountain, TN"; |
|
| 17 | + | ||
| 18 | + | const forecastRequest = await fetch( |
|
| 19 | + | `https://api.weather.gov/gridpoints/${STATION}/${GRID_X},${GRID_Y}/forecast/hourly`, |
|
| 20 | + | { |
|
| 21 | + | method: "GET", |
|
| 22 | + | headers: { |
|
| 23 | + | accept: "application/geo+json", |
|
| 24 | + | "User-Agent": "(stevedylan.dev, contact@stevedylan.dev)", |
|
| 25 | + | }, |
|
| 26 | + | }, |
|
| 27 | + | ); |
|
| 28 | + | const forecast = await forecastRequest.json(); |
|
| 29 | + | const { shortForecast, temperature } = forecast.properties.periods[0]; |
|
| 30 | + | const weather = `${shortForecast},${temperature},${LOCATION}`; |
|
| 11 | 31 | --- |
|
| 32 | + | ||
| 12 | 33 | <PageLayout meta={meta}> |
|
| 13 | 34 | <div class="space-y-6"> |
|
| 14 | 35 | <h1 class="title">/now</h1> |
|
| 36 | + | <div class="flex text-sm text-zinc-400 items-center gap-x-3 gap-y-1 flex-wrap"> |
|
| 37 | + | <Weather weather={weather} /> |
|
| 38 | + | </div> |
|
| 15 | 39 | <p>What I'm up to recently</p> |
|
| 16 | 40 | <ul class="list-disc pl-4 space-y-4"> |
|
| 17 | 41 | <li>Working as Senior Solutions Engineer at Stablecore</li> |
|
| 18 | - | <li>Building personal software with <a class="style-link" target="_blank" rel="noreferrer" href="https://andromeda.build">Andromeda</a></li> |
|
| 42 | + | <li> |
|
| 43 | + | Building personal software with <a |
|
| 44 | + | class="style-link" |
|
| 45 | + | target="_blank" |
|
| 46 | + | rel="noreferrer" |
|
| 47 | + | href="https://andromeda.build">Andromeda</a> |
|
| 48 | + | </li> |
|
| 19 | 49 | <li>Updating this site with fun stuff</li> |
|
| 20 | 50 | <li>Learning how to build HTTP from TCP in Go</li> |
|
| 21 | 51 | <li>Exploring Chinese loose leaf tea</li> |
|
| 23 | 53 | <li>Reading Dubliners</li> |
|
| 24 | 54 | </ul> |
|
| 25 | 55 | <p class="text-zinc-400">Last updated: May 9th, 2026</p> |
|
| 26 | - | <div class="flex flex-row justify-between items-center w-full pt-12 pb-6"> |
|
| 27 | - | <h2 class="title">Updates</h2> |
|
| 28 | - | <div class="flex gap-2 items-center"> |
|
| 29 | - | <a |
|
| 30 | - | class="inline-block p-2 sm:hover:text-link" |
|
| 31 | - | href="https://posts.stevedylan.dev/feed.xml" |
|
| 32 | - | target="_blank" |
|
| 33 | - | rel="noopener noreferrer" |
|
| 34 | - | > |
|
| 35 | - | <svg |
|
| 36 | - | xmlns="http://www.w3.org/2000/svg" |
|
| 37 | - | class="h-6 w-6" |
|
| 38 | - | viewBox="0 0 24 24" |
|
| 39 | - | stroke-width="1.5" |
|
| 40 | - | stroke="currentColor" |
|
| 41 | - | fill="none" |
|
| 42 | - | stroke-linecap="round" |
|
| 43 | - | stroke-linejoin="round" |
|
| 44 | - | > |
|
| 45 | - | <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></g> |
|
| 46 | - | </svg> |
|
| 47 | - | <span class="sr-only">RSS</span> |
|
| 48 | - | </a> |
|
| 56 | + | <div class="flex flex-row justify-between items-center w-full pt-12 pb-6"> |
|
| 57 | + | <h2 class="title">Updates</h2> |
|
| 58 | + | <div class="flex gap-2 items-center"> |
|
| 59 | + | <a |
|
| 60 | + | class="inline-block p-2 sm:hover:text-link" |
|
| 61 | + | href="https://posts.stevedylan.dev/feed.xml" |
|
| 62 | + | target="_blank" |
|
| 63 | + | rel="noopener noreferrer" |
|
| 64 | + | > |
|
| 65 | + | <svg |
|
| 66 | + | xmlns="http://www.w3.org/2000/svg" |
|
| 67 | + | class="h-6 w-6" |
|
| 68 | + | viewBox="0 0 24 24" |
|
| 69 | + | stroke-width="1.5" |
|
| 70 | + | stroke="currentColor" |
|
| 71 | + | fill="none" |
|
| 72 | + | stroke-linecap="round" |
|
| 73 | + | stroke-linejoin="round" |
|
| 74 | + | > |
|
| 75 | + | <g |
|
| 76 | + | fill="none" |
|
| 77 | + | stroke="currentColor" |
|
| 78 | + | stroke-linecap="round" |
|
| 79 | + | stroke-linejoin="round" |
|
| 80 | + | stroke-width="2" |
|
| 81 | + | ><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16" |
|
| 82 | + | ></path><circle cx="5" cy="19" r="1"></circle></g |
|
| 83 | + | > |
|
| 84 | + | </svg> |
|
| 85 | + | <span class="sr-only">RSS</span> |
|
| 86 | + | </a> |
|
| 87 | + | </div> |
|
| 49 | 88 | </div> |
|
| 50 | - | </div> |
|
| 51 | - | <NowUpdates /> |
|
| 89 | + | <NowUpdates /> |
|
| 52 | 90 | </div> |
|
| 53 | 91 | </PageLayout> |
|