feat: add weather widget
All checks were successful
renovate / renovate (push) Successful in 1m3s
test-build / build (push) Successful in 1m47s

This commit is contained in:
2026-02-10 21:42:04 -06:00
parent 63cbcdf39b
commit b6dfc738f1
15 changed files with 164 additions and 25 deletions

View File

@@ -16,14 +16,14 @@ const currentYear = new Date().getFullYear();
transition:animate="none" transition:animate="none"
> >
<div class="relative px-4 pt-16 pb-12 sm:px-6"> <div class="relative px-4 pt-16 pb-12 sm:px-6">
<div class="mx-auto max-w-[85rem]"> <div class="mx-auto max-w-340">
<div class="grid grid-cols-1 gap-10 md:grid-cols-12"> <div class="grid grid-cols-1 gap-10 md:grid-cols-12">
<!-- Brand section --> <!-- Brand section -->
<div class="col-span-1 md:col-span-3"> <div class="col-span-1 md:col-span-3">
<a href="/" class="group inline-block"> <a href="/" class="group inline-block">
<div class="flex items-center"> <div class="flex items-center">
<div class="mx-auto aspect-square overflow-hidden rounded-lg"> <div class="mx-auto aspect-square overflow-hidden rounded-lg">
<BrandLogo class="max-h-[40px] max-w-[40px] rounded-full" /> <BrandLogo class="max-h-10 max-w-10 rounded-full" />
</div> </div>
<span class="ml-3 text-xl font-bold text-neutral-800 dark:text-neutral-200"> <span class="ml-3 text-xl font-bold text-neutral-800 dark:text-neutral-200">

View File

@@ -33,7 +33,7 @@ const currentPath = pathname.slice(1);
aria-label="Toggle navigation" aria-label="Toggle navigation"
> >
<svg <svg
class="hs-collapse-open:hidden h-[1.25rem] w-[1.25rem] flex-shrink-0" class="hs-collapse-open:hidden h-5 w-5 shrink-0"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -48,7 +48,7 @@ const currentPath = pathname.slice(1);
<line x1="3" x2="21" y1="18" y2="18"></line> <line x1="3" x2="21" y1="18" y2="18"></line>
</svg> </svg>
<svg <svg
class="hs-collapse-open:block hidden h-[1.25rem] w-[1.25rem] flex-shrink-0" class="hs-collapse-open:block hidden h-5 w-5 shrink-0"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -2,7 +2,6 @@
import ImageTheme from '@components/ui/images/ImageTheme.astro'; import ImageTheme from '@components/ui/images/ImageTheme.astro';
const { srcLight, srcDark, alt } = Astro.props; const { srcLight, srcDark, alt } = Astro.props;
--- ---
<ImageTheme <ImageTheme

View File

@@ -11,7 +11,7 @@ interface Props {
} }
--- ---
<section class="lg:px- relative mx-auto mb-20 max-w-[85rem] px-4 pt-30 pb-30 sm:px-6"> <section class="lg:px- relative mx-auto mb-20 max-w-340 px-4 pt-30 pb-30 sm:px-6">
<div <div
class="smooth-reveal absolute top-[55%] left-0 scale-90 md:top-[20%] xl:top-[25%] xl:left-[10%]" class="smooth-reveal absolute top-[55%] left-0 scale-90 md:top-[20%] xl:top-[25%] xl:left-[10%]"
> >

View File

@@ -18,7 +18,7 @@ const recentPosts = posts
.slice(0, 3); .slice(0, 3);
--- ---
<section class="mx-auto mb-20 max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full"> <section class="mx-auto mb-20 max-w-340 px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full">
<div class="mx-auto mb-10 max-w-2xl text-center lg:mb-14"> <div class="mx-auto mb-10 max-w-2xl text-center lg:mb-14">
<h1 <h1
class="smooth-reveal block text-4xl font-bold text-neutral-800 md:text-5xl md:leading-tight lg:text-5xl dark:text-neutral-200" class="smooth-reveal block text-4xl font-bold text-neutral-800 md:text-5xl md:leading-tight lg:text-5xl dark:text-neutral-200"

View File

@@ -54,7 +54,7 @@ const shadowClasses = 'shadow-xs hover:shadow-lg';
<div class="relative h-1.5 w-full overflow-hidden rounded-full bg-stone-500/20 sm:h-2 dark:bg-stone-500/20"> <div class="relative h-1.5 w-full overflow-hidden rounded-full bg-stone-500/20 sm:h-2 dark:bg-stone-500/20">
<div <div
class="progress-bar-animate from-steel via-bermuda to-steel absolute top-0 left-0 h-full rounded-full bg-gradient-to-r transition-all duration-1000" class="progress-bar-animate from-steel via-bermuda to-steel absolute top-0 left-0 h-full rounded-full bg-linear-to-r transition-all duration-1000"
style={`width: ${skill.level}%`} style={`width: ${skill.level}%`}
/> />
</div> </div>
@@ -70,13 +70,13 @@ const shadowClasses = 'shadow-xs hover:shadow-lg';
} }
</div> </div>
<!-- Gradient overlays for smooth fade effect --> <!-- Gradient overlays -->
<div <div
class="absolute top-0 bottom-0 left-0 z-10 w-12 bg-gradient-to-r from-neutral-200 to-transparent sm:w-24 dark:from-stone-700" class="absolute top-0 bottom-0 left-0 z-10 w-12 bg-linear-to-r from-neutral-200 to-transparent sm:w-24 dark:from-stone-700"
> >
</div> </div>
<div <div
class="absolute top-0 right-0 bottom-0 z-10 w-12 bg-gradient-to-l from-neutral-200 to-transparent sm:w-24 dark:from-stone-700" class="absolute top-0 right-0 bottom-0 z-10 w-12 bg-linear-to-l from-neutral-200 to-transparent sm:w-24 dark:from-stone-700"
> >
</div> </div>
</div> </div>
@@ -85,7 +85,7 @@ const shadowClasses = 'shadow-xs hover:shadow-lg';
<script> <script>
document.addEventListener('astro:page-load', () => { document.addEventListener('astro:page-load', () => {
// Create seamless infinite scrolling effect // Create infinite scrolling effect
function setupInfiniteScroll() { function setupInfiniteScroll() {
const cards = document.querySelectorAll('.skill-card'); const cards = document.querySelectorAll('.skill-card');
if (!cards.length) return; if (!cards.length) return;

View File

@@ -0,0 +1,58 @@
---
import { getFiveDayForecast } from '@support/weather';
const { latitude = "44.95", longitude = "-93.09", cityName = "St. Paul, Minnesota" } = Astro.props;
const { forecastDays, error } = await getFiveDayForecast(latitude, longitude);
const borderClasses = 'border border-neutral-100 dark:border-stone-500/20';
const bgColorClasses = 'bg-neutral-100/80 hover:bg-neutral-100 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const shadowClasses = 'shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg';
---
<section class="mx-auto mb-20 max-w-340 px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full">
<div class="mx-auto mb-10 max-w-2xl text-center lg:mb-14">
<h1 class="smooth-reveal block text-4xl font-bold text-neutral-800 md:text-5xl md:leading-tight lg:text-5xl dark:text-neutral-200">
Weather in my Area
</h1>
<div class="smooth-reveal mx-auto mt-5 max-w-3xl text-center">
<p class="text-lg text-pretty text-neutral-600 dark:text-neutral-400">
5 day forecast for {cityName}
</p>
</div>
</div>
{error ? (
<div class={`rounded-xl p-10 text-center text-yellow-500 ${borderClasses} ${bgColorClasses}`}>
{error}
</div>
) : (
<div class="flex flex-wrap justify-center gap-4 lg:gap-6">
{forecastDays.map((day) => (
<div class="smooth-reveal-2 group flex flex-col">
<div class={`rounded-xl duration-300 transition-all w-32 md:w-44 ${borderClasses} ${bgColorClasses} ${shadowClasses}`}>
<div class="p-4 text-center">
<span class="block text-xs font-bold tracking-widest text-neutral-500 uppercase dark:text-neutral-400">
{day.dayName}
</span>
<div class="flex justify-center my-2">
<img
src={`https://openweathermap.org/img/wn/${day.icon}@2x.png`}
alt={day.label}
class="h-12 w-12 drop-shadow-sm group-hover:scale-110 transition-transform duration-300"
/>
</div>
<div class="mt-2">
<span class="group-hover:text-steel dark:group-hover:text-bermuda transition-all duration-300 block text-2xl font-bold text-neutral-600 dark:text-neutral-300">
{day.temp}°F
</span>
<span class="mt-1 block text-xs text-neutral-500 dark:text-neutral-400 capitalize">
{day.label}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</section>

View File

@@ -55,13 +55,7 @@ const normalizeTitle = !title ? global.name : `${title} | ${global.name}`;
</script> </script>
</head> </head>
<body class="bg-stone-200 selection:bg-yellow-400 selection:text-neutral-700 dark:bg-stone-700"> <body class="bg-stone-200 selection:bg-yellow-400 selection:text-neutral-700 dark:bg-stone-700">
<!-- <div class="fixed inset-0 -z-10"> <div class="mx-auto w-full max-w-(--breakpoint-2xl) grow px-4 sm:px-6 lg:px-8">
<div
class="bg-grid-pattern absolute inset-0 [mask-image:radial-gradient(white,transparent_85%)] bg-[center_top_-1px]"
>
</div>
</div> -->
<div class="mx-auto w-full max-w-(--breakpoint-2xl) flex-grow px-4 sm:px-6 lg:px-8">
<Header /> <Header />
<main class="min-h-screen"> <main class="min-h-screen">
<slot /> <slot />

View File

@@ -2,6 +2,7 @@ import { createDirectus, rest } from '@directus/sdk';
import type { import type {
Global, Global,
Weather,
Post, Post,
Application, Application,
Experience, Experience,
@@ -15,6 +16,7 @@ import { getDirectusURL } from '@lib/directusFunctions';
type Schema = { type Schema = {
site_global: Global; site_global: Global;
site_weather: Weather;
posts: Post[]; posts: Post[];
site_applications: Application; site_applications: Application;
site_experience: Experience; site_experience: Experience;

View File

@@ -23,6 +23,13 @@ export type Global = {
footer_image_alt: string; footer_image_alt: string;
}; };
export type Weather = {
id: string;
location: string;
latitude: string;
longitude: string;
}
export type Post = { export type Post = {
slug: string; slug: string;
title: string; title: string;

View File

@@ -29,7 +29,7 @@ const global = await directus.request(readSingleton('site_global'));
}} }}
> >
<section class="mt-20 grid place-content-center"> <section class="mt-20 grid place-content-center">
<div class="mx-auto max-w-screen-xl px-4 py-8 lg:px-6 lg:py-16"> <div class="mx-auto max-w-7xl px-4 py-8 lg:px-6 lg:py-16">
<div class="mx-auto max-w-screen-sm text-center"> <div class="mx-auto max-w-screen-sm text-center">
<div class="glitch-wrapper smooth-reveal"> <div class="glitch-wrapper smooth-reveal">
<h1 <h1

View File

@@ -5,11 +5,13 @@ import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro'; import BaseLayout from '@layouts/BaseLayout.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro'; import HeroSection from '@components/ui/sections/HeroSection.astro';
import FeaturesSection from '@components/ui/sections/FeaturesSection.astro'; import FeaturesSection from '@components/ui/sections/FeaturesSection.astro';
import WeatherSection from '@components/ui/sections/WeatherSection.astro';
import LatestPosts from '@components/ui/sections/LatestPosts.astro'; import LatestPosts from '@components/ui/sections/LatestPosts.astro';
import HeroSectionAlt from '@components/ui/sections/HeroSectionAlt.astro'; import HeroSectionAlt from '@components/ui/sections/HeroSectionAlt.astro';
import homeImg from '@images/autumn_mountain.png'; import homeImg from '@images/autumn_mountain.png';
const global = await directus.request(readSingleton('site_global')); const global = await directus.request(readSingleton('site_global'));
const weather = await directus.request(readSingleton('site_weather'));
--- ---
<BaseLayout <BaseLayout
@@ -42,6 +44,12 @@ const global = await directus.request(readSingleton('site_global'));
<FeaturesSection /> <FeaturesSection />
<WeatherSection
latitude={weather.latitude}
longitude={weather.longitude}
cityName={weather.location}
/>
<LatestPosts /> <LatestPosts />
<HeroSectionAlt <HeroSectionAlt

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
export interface BlurImageMetadata { interface BlurImageMetadata {
/** /**
* The width of the origin image * The width of the origin image
*/ */
@@ -23,7 +23,7 @@ export interface BlurImageMetadata {
blurHeight: number; blurHeight: number;
} }
export async function blurStyle(filePath: string) { async function blurStyle(filePath: string) {
const image = await blurImageMetadata(filePath); const image = await blurImageMetadata(filePath);
const svg = blurImageSVG(image); const svg = blurImageSVG(image);
return { return {
@@ -64,3 +64,5 @@ async function blurImageMetadata(filepath: string): Promise<BlurImageMetadata> {
return { blurDataURL, blurHeight, blurWidth, width, height }; return { blurDataURL, blurHeight, blurWidth, width, height };
} }
export { blurStyle };

View File

@@ -1,6 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
export function resolveFilePath(path: string) { function resolveFilePath(path: string) {
if (path.startsWith('/')) { if (path.startsWith('/')) {
return resolveFilePathPublic(path); return resolveFilePathPublic(path);
} }
@@ -8,12 +8,14 @@ export function resolveFilePath(path: string) {
return resolveFilePathInternal(path); return resolveFilePathInternal(path);
} }
export function resolveFilePathPublic(path: string) { function resolveFilePathPublic(path: string) {
return join(process.cwd(), path); return join(process.cwd(), path);
} }
export function resolveFilePathInternal(path: string) { function resolveFilePathInternal(path: string) {
const normalizePath = path.startsWith('@') ? path.replace('@', '') : path; const normalizePath = path.startsWith('@') ? path.replace('@', '') : path;
return join(process.cwd(), 'src/', normalizePath); return join(process.cwd(), 'src/', normalizePath);
} }
export { resolveFilePath, resolveFilePathPublic, resolveFilePathInternal };

67
src/support/weather.ts Normal file
View File

@@ -0,0 +1,67 @@
interface DayForecast {
date: string;
temp: number;
code: number;
label: string;
icon: string;
dayName: string;
}
interface WeatherResult {
forecastDays: DayForecast[];
error: string | null;
}
const getWeatherInfo = (code: number) => {
if (code === 0) return { label: 'Clear', icon: '01d' };
if (code >= 1 && code <= 3) return { label: 'Partly Cloudy', icon: '02d' };
if (code === 45 || code === 48) return { label: 'Foggy', icon: '50d' };
if (code >= 51 && code <= 55) return { label: 'Drizzle', icon: '09d' };
if (code >= 61 && code <= 65) return { label: 'Rainy', icon: '10d' };
if (code === 66 || code === 67) return { label: 'Freezing Rain', icon: '13d' };
if (code >= 71 && code <= 75) return { label: 'Snowy', icon: '13d' };
if (code === 77) return { label: 'Snow Grains', icon: '13d' };
if (code >= 80 && code <= 82) return { label: 'Showers', icon: '09d' };
if (code === 85 || code === 86) return { label: 'Snow Showers', icon: '13d' };
if (code >= 95 && code <= 99) return { label: 'Stormy', icon: '11d' };
return { label: 'Unknown', icon: '03d' };
};
const getDayName = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' });
};
async function getFiveDayForecast(latitude: string, longitude: string): Promise<WeatherResult> {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weather_code,temperature_2m_max&timezone=auto&temperature_unit=fahrenheit`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Weather service unavailable");
const data = await response.json();
const forecastDays = data.daily.time.map((date: string, index: number): DayForecast => {
const code = data.daily.weather_code[index];
const info = getWeatherInfo(code);
return {
date,
temp: Math.round(data.daily.temperature_2m_max[index]),
code,
label: info.label,
icon: info.icon,
dayName: getDayName(date)
};
}).slice(0, 5);
return { forecastDays, error: null };
} catch (e: unknown) {
return {
forecastDays: [],
error: e instanceof Error ? e.message : "An unexpected error occurred"
};
}
}
export { getFiveDayForecast };