feat: add weather widget
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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%]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
58
src/components/ui/sections/WeatherSection.astro
Normal file
58
src/components/ui/sections/WeatherSection.astro
Normal 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>
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
67
src/support/weather.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user