version 1.0

This commit is contained in:
Lukas Werner
2025-06-25 18:58:43 -07:00
parent fa543bab08
commit 4cd69217cc
28 changed files with 1349 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+324
View File
@@ -0,0 +1,324 @@
## Get Forecast
`GET https://api.weather.gov/gridpoints/PQR/116,103/forecast`
```json
{
"@context": [
"https://geojson.org/geojson-ld/geojson-context.jsonld",
{
"@version": "1.1",
"wx": "https://api.weather.gov/ontology#",
"geo": "http://www.opengis.net/ont/geosparql#",
"unit": "http://codes.wmo.int/common/unit/",
"@vocab": "https://api.weather.gov/ontology#"
}
],
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-122.5566,
45.5102
],
[
-122.56280000000001,
45.531
],
[
-122.5926,
45.5267
],
[
-122.5864,
45.505899899999996
],
[
-122.5566,
45.5102
]
]
]
},
"properties": {
"units": "us",
"forecastGenerator": "BaselineForecastGenerator",
"generatedAt": "2025-06-26T01:32:44+00:00",
"updateTime": "2025-06-26T00:40:50+00:00",
"validTimes": "2025-06-25T18:00:00+00:00/P7DT10H",
"elevation": {
"unitCode": "wmoUnit:m",
"value": 85.9536
},
"periods": [
{
"number": 1,
"name": "Tonight",
"startTime": "2025-06-25T18:00:00-07:00",
"endTime": "2025-06-26T06:00:00-07:00",
"isDaytime": false,
"temperature": 58,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 31
},
"windSpeed": "2 to 6 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/night/ovc/rain,30?size=medium",
"shortForecast": "Cloudy then Chance Drizzle",
"detailedForecast": "A chance of drizzle after 5am. Cloudy, with a low around 58. North northwest wind 2 to 6 mph. Chance of precipitation is 30%."
},
{
"number": 2,
"name": "Thursday",
"startTime": "2025-06-26T06:00:00-07:00",
"endTime": "2025-06-26T18:00:00-07:00",
"isDaytime": true,
"temperature": 69,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 31
},
"windSpeed": "2 to 6 mph",
"windDirection": "SW",
"icon": "https://api.weather.gov/icons/land/day/rain,30/rain,20?size=medium",
"shortForecast": "Chance Drizzle",
"detailedForecast": "A chance of drizzle before 2pm. Mostly cloudy, with a high near 69. Southwest wind 2 to 6 mph. Chance of precipitation is 30%."
},
{
"number": 3,
"name": "Thursday Night",
"startTime": "2025-06-26T18:00:00-07:00",
"endTime": "2025-06-27T06:00:00-07:00",
"isDaytime": false,
"temperature": 56,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 8
},
"windSpeed": "2 to 6 mph",
"windDirection": "NW",
"icon": "https://api.weather.gov/icons/land/night/bkn?size=medium",
"shortForecast": "Mostly Cloudy",
"detailedForecast": "Mostly cloudy, with a low around 56. Northwest wind 2 to 6 mph."
},
{
"number": 4,
"name": "Friday",
"startTime": "2025-06-27T06:00:00-07:00",
"endTime": "2025-06-27T18:00:00-07:00",
"isDaytime": true,
"temperature": 73,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 3
},
"windSpeed": "2 to 6 mph",
"windDirection": "WNW",
"icon": "https://api.weather.gov/icons/land/day/bkn?size=medium",
"shortForecast": "Partly Sunny",
"detailedForecast": "Partly sunny, with a high near 73. West northwest wind 2 to 6 mph."
},
{
"number": 5,
"name": "Friday Night",
"startTime": "2025-06-27T18:00:00-07:00",
"endTime": "2025-06-28T06:00:00-07:00",
"isDaytime": false,
"temperature": 55,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 2
},
"windSpeed": "2 to 6 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/night/sct?size=medium",
"shortForecast": "Partly Cloudy",
"detailedForecast": "Partly cloudy, with a low around 55. North northwest wind 2 to 6 mph."
},
{
"number": 6,
"name": "Saturday",
"startTime": "2025-06-28T06:00:00-07:00",
"endTime": "2025-06-28T18:00:00-07:00",
"isDaytime": true,
"temperature": 80,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 0
},
"windSpeed": "2 to 9 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/day/few?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 80."
},
{
"number": 7,
"name": "Saturday Night",
"startTime": "2025-06-28T18:00:00-07:00",
"endTime": "2025-06-29T06:00:00-07:00",
"isDaytime": false,
"temperature": 56,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 0
},
"windSpeed": "3 to 9 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/night/skc?size=medium",
"shortForecast": "Clear",
"detailedForecast": "Clear, with a low around 56."
},
{
"number": 8,
"name": "Sunday",
"startTime": "2025-06-29T06:00:00-07:00",
"endTime": "2025-06-29T18:00:00-07:00",
"isDaytime": true,
"temperature": 88,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 0
},
"windSpeed": "3 to 10 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/day/skc?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 88."
},
{
"number": 9,
"name": "Sunday Night",
"startTime": "2025-06-29T18:00:00-07:00",
"endTime": "2025-06-30T06:00:00-07:00",
"isDaytime": false,
"temperature": 60,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 1
},
"windSpeed": "2 to 10 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/night/skc?size=medium",
"shortForecast": "Clear",
"detailedForecast": "Clear, with a low around 60."
},
{
"number": 10,
"name": "Monday",
"startTime": "2025-06-30T06:00:00-07:00",
"endTime": "2025-06-30T18:00:00-07:00",
"isDaytime": true,
"temperature": 90,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 7
},
"windSpeed": "2 to 10 mph",
"windDirection": "NNW",
"icon": "https://api.weather.gov/icons/land/day/few?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 90."
},
{
"number": 11,
"name": "Monday Night",
"startTime": "2025-06-30T18:00:00-07:00",
"endTime": "2025-07-01T06:00:00-07:00",
"isDaytime": false,
"temperature": 61,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 7
},
"windSpeed": "2 to 10 mph",
"windDirection": "NW",
"icon": "https://api.weather.gov/icons/land/night/sct?size=medium",
"shortForecast": "Partly Cloudy",
"detailedForecast": "Partly cloudy, with a low around 61."
},
{
"number": 12,
"name": "Tuesday",
"startTime": "2025-07-01T06:00:00-07:00",
"endTime": "2025-07-01T18:00:00-07:00",
"isDaytime": true,
"temperature": 87,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 4
},
"windSpeed": "2 to 10 mph",
"windDirection": "NW",
"icon": "https://api.weather.gov/icons/land/day/sct?size=medium",
"shortForecast": "Mostly Sunny",
"detailedForecast": "Mostly sunny, with a high near 87."
},
{
"number": 13,
"name": "Tuesday Night",
"startTime": "2025-07-01T18:00:00-07:00",
"endTime": "2025-07-02T06:00:00-07:00",
"isDaytime": false,
"temperature": 59,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 4
},
"windSpeed": "2 to 10 mph",
"windDirection": "NW",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 59."
},
{
"number": 14,
"name": "Wednesday",
"startTime": "2025-07-02T06:00:00-07:00",
"endTime": "2025-07-02T18:00:00-07:00",
"isDaytime": true,
"temperature": 83,
"temperatureUnit": "F",
"temperatureTrend": "",
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 3
},
"windSpeed": "2 to 9 mph",
"windDirection": "NW",
"icon": "https://api.weather.gov/icons/land/day/few?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 83."
}
]
}
}
```
+5
View File
@@ -12,13 +12,18 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@lucide/svelte": "^0.523.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"clsx": "^2.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0",
"vite": "^6.2.6"
},
+50
View File
@@ -8,6 +8,9 @@ importers:
.:
devDependencies:
'@lucide/svelte':
specifier: ^0.523.0
version: 0.523.0(svelte@5.34.8)
'@sveltejs/adapter-auto':
specifier: ^6.0.0
version: 6.0.1(@sveltejs/kit@2.22.0(@sveltejs/vite-plugin-svelte@5.1.0(svelte@5.34.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.34.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)))
@@ -20,15 +23,27 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.1.10(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))
clsx:
specifier: ^2.1.1
version: 2.1.1
svelte:
specifier: ^5.0.0
version: 5.34.8
svelte-check:
specifier: ^4.0.0
version: 4.2.2(picomatch@4.0.2)(svelte@5.34.8)(typescript@5.8.3)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
tailwind-variants:
specifier: ^1.0.0
version: 1.0.0(tailwindcss@4.1.10)
tailwindcss:
specifier: ^4.0.0
version: 4.1.10
tw-animate-css:
specifier: ^1.3.4
version: 1.3.4
typescript:
specifier: ^5.0.0
version: 5.8.3
@@ -214,6 +229,11 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@lucide/svelte@0.523.0':
resolution: {integrity: sha512-t6CNxUAC7to9IPQv+yaJD8OlpXVeEL7wT1nw5+lx62HoBnkO6//xYZSTkV8Ia3bqtoF4deI3Pjff/8cPyQjiCw==}
peerDependencies:
svelte: ^5
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -684,6 +704,18 @@ packages:
resolution: {integrity: sha512-TF+8irl7rpj3+fpaLuPRX5BqReTAqckp0Fumxa/mCeK3fo0/MnBb9W/Z2bLwtqj3C3r5Lm6NKIAw7YrgIv1Fwg==}
engines: {node: '>=18'}
tailwind-merge@3.0.2:
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwind-variants@1.0.0:
resolution: {integrity: sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==}
engines: {node: '>=16.x', pnpm: '>=7.x'}
peerDependencies:
tailwindcss: '*'
tailwindcss@4.1.10:
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
@@ -703,6 +735,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tw-animate-css@1.3.4:
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
@@ -866,6 +901,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@lucide/svelte@0.523.0(svelte@5.34.8)':
dependencies:
svelte: 5.34.8
'@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.44.0':
@@ -1282,6 +1321,15 @@ snapshots:
magic-string: 0.30.17
zimmerframe: 1.1.2
tailwind-merge@3.0.2: {}
tailwind-merge@3.3.1: {}
tailwind-variants@1.0.0(tailwindcss@4.1.10):
dependencies:
tailwind-merge: 3.0.2
tailwindcss: 4.1.10
tailwindcss@4.1.10: {}
tapable@2.2.2: {}
@@ -1302,6 +1350,8 @@ snapshots:
totalist@3.0.1: {}
tw-animate-css@1.3.4: {}
typescript@5.8.3: {}
vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1):
+161 -1
View File
@@ -1 +1,161 @@
@import 'tailwindcss';
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground min-h-screen;
}
}
@layer utilities {
.container {
@apply max-w-7xl;
}
}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { MapPin } from "@lucide/svelte";
interface Props {
onclick: () => void;
loading?: boolean;
}
let { onclick, loading = false }: Props = $props();
</script>
<Button {onclick} disabled={loading} class="flex items-center gap-2">
<MapPin class="w-4 h-4" />
{loading ? "Getting location..." : "Use current location"}
</Button>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import WeatherIcon from './WeatherIcon.svelte';
import type { WeatherPeriod } from '$lib/types/weather.js';
interface Props {
period: WeatherPeriod;
}
let { period }: Props = $props();
function formatTime(timeString: string): string {
return new Date(timeString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function getTemperatureColor(temp: number): string {
if (temp >= 80) return 'bg-red-100 text-red-800';
if (temp >= 70) return 'bg-orange-100 text-orange-800';
if (temp >= 60) return 'bg-yellow-100 text-yellow-800';
if (temp >= 50) return 'bg-blue-100 text-blue-800';
return 'bg-slate-100 text-slate-800';
}
function getPrecipitationColor(chance: number): string {
if (chance >= 70) return 'bg-blue-100 text-blue-800';
if (chance >= 40) return 'bg-sky-100 text-sky-800';
if (chance >= 20) return 'bg-slate-100 text-slate-800';
return 'bg-gray-50 text-gray-600';
}
</script>
<Card class="w-full">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{period.name}</CardTitle>
<WeatherIcon
iconUrl={period.icon}
shortForecast={period.shortForecast}
isDaytime={period.isDaytime}
size={48}
class="text-muted-foreground"
/>
</div>
<CardDescription class="text-sm text-muted-foreground">
{formatTime(period.startTime)} - {formatTime(period.endTime)}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-2xl font-bold">
{period.temperature}°{period.temperatureUnit}
</span>
<Badge class={getTemperatureColor(period.temperature)}>
{period.isDaytime ? 'Day' : 'Night'}
</Badge>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Precipitation:</span>
<Badge class={getPrecipitationColor(period.probabilityOfPrecipitation.value)}>
{period.probabilityOfPrecipitation.value}%
</Badge>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Wind:</span>
<span class="font-medium">{period.windSpeed} {period.windDirection}</span>
</div>
</div>
<div class="space-y-2">
<p class="font-medium text-sm">{period.shortForecast}</p>
<p class="text-xs text-muted-foreground leading-relaxed">
{period.detailedForecast}
</p>
</div>
</CardContent>
</Card>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import WeatherCard from './WeatherCard.svelte';
import type { WeatherPeriod } from '$lib/types/weather.js';
interface Props {
periods: WeatherPeriod[];
}
let { periods }: Props = $props();
</script>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each periods as period (period.number)}
<WeatherCard {period} />
{/each}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { getWeatherIcon } from '$lib/utils/weatherIcons.js';
interface Props {
iconUrl: string;
shortForecast: string;
isDaytime: boolean;
size?: number;
class?: string;
}
let { iconUrl, shortForecast, isDaytime, size = 24, class: className = '' }: Props = $props();
const weatherIcon = $derived(getWeatherIcon(iconUrl, shortForecast, isDaytime));
</script>
<svelte:component
this={weatherIcon.icon}
{size}
class={className}
aria-label={weatherIcon.description}
title={weatherIcon.description}
/>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,80 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+52
View File
@@ -0,0 +1,52 @@
import type { WeatherForecast, LocationInfo } from '$lib/types/weather.js';
export class WeatherService {
private static readonly BASE_URL = 'https://api.weather.gov';
static async getLocationFromCoords(lat: number, lon: number): Promise<{ gridX: number; gridY: number; office: string }> {
const response = await fetch(`${this.BASE_URL}/points/${lat},${lon}`);
if (!response.ok) {
throw new Error(`Failed to get location data: ${response.statusText}`);
}
const data = await response.json();
return {
gridX: data.properties.gridX,
gridY: data.properties.gridY,
office: data.properties.gridId
};
}
static async getForecast(office: string, gridX: number, gridY: number): Promise<WeatherForecast> {
const response = await fetch(`${this.BASE_URL}/gridpoints/${office}/${gridX},${gridY}/forecast`);
if (!response.ok) {
throw new Error(`Failed to get forecast: ${response.statusText}`);
}
return await response.json();
}
static async getForecastByCoords(lat: number, lon: number): Promise<WeatherForecast> {
const location = await this.getLocationFromCoords(lat, lon);
return await this.getForecast(location.office, location.gridX, location.gridY);
}
static async getCurrentLocation(): Promise<LocationInfo> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
},
(error) => {
reject(new Error(`Geolocation error: ${error.message}`));
}
);
});
}
}
+46
View File
@@ -0,0 +1,46 @@
export interface WeatherPeriod {
number: number;
name: string;
startTime: string;
endTime: string;
isDaytime: boolean;
temperature: number;
temperatureUnit: string;
temperatureTrend: string;
probabilityOfPrecipitation: {
unitCode: string;
value: number;
};
windSpeed: string;
windDirection: string;
icon: string;
shortForecast: string;
detailedForecast: string;
}
export interface WeatherForecast {
type: string;
geometry: {
type: string;
coordinates: number[][][];
};
properties: {
units: string;
forecastGenerator: string;
generatedAt: string;
updateTime: string;
validTimes: string;
elevation: {
unitCode: string;
value: number;
};
periods: WeatherPeriod[];
};
}
export interface LocationInfo {
latitude: number;
longitude: number;
city?: string;
state?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+81
View File
@@ -0,0 +1,81 @@
import {
Sun,
Moon,
Cloud,
CloudRain,
CloudSnow,
CloudLightning,
CloudDrizzle,
Cloudy,
Wind,
Eye,
type Icon
} from '@lucide/svelte';
export interface WeatherIconMapping {
icon: typeof Icon;
description: string;
}
export function getWeatherIcon(iconUrl: string, shortForecast: string, isDaytime: boolean): WeatherIconMapping {
const forecast = shortForecast.toLowerCase();
const url = iconUrl.toLowerCase();
// Check for specific weather conditions in forecast text and icon URL
if (forecast.includes('thunderstorm') || forecast.includes('storm') || url.includes('tsra')) {
return { icon: CloudLightning, description: 'Thunderstorm' };
}
if (forecast.includes('snow') || url.includes('snow')) {
return { icon: CloudSnow, description: 'Snow' };
}
if (forecast.includes('rain') || forecast.includes('shower') || url.includes('rain') || url.includes('shra')) {
return { icon: CloudRain, description: 'Rain' };
}
if (forecast.includes('drizzle') || url.includes('drizzle')) {
return { icon: CloudDrizzle, description: 'Drizzle' };
}
if (forecast.includes('fog') || forecast.includes('mist') || url.includes('fog')) {
return { icon: Eye, description: 'Fog' };
}
if (forecast.includes('wind') || forecast.includes('breezy') || forecast.includes('gusty')) {
return { icon: Wind, description: 'Windy' };
}
// Cloud conditions
if (forecast.includes('overcast') || url.includes('ovc')) {
return { icon: Cloudy, description: 'Overcast' };
}
if (forecast.includes('mostly cloudy') || forecast.includes('considerable cloudiness') || url.includes('bkn')) {
return { icon: Cloud, description: 'Mostly Cloudy' };
}
if (forecast.includes('partly cloudy') || forecast.includes('partly sunny') || url.includes('sct')) {
return isDaytime
? { icon: Sun, description: 'Partly Cloudy' }
: { icon: Moon, description: 'Partly Cloudy' };
}
if (forecast.includes('mostly clear') || forecast.includes('mostly sunny') || url.includes('few')) {
return isDaytime
? { icon: Sun, description: 'Mostly Clear' }
: { icon: Moon, description: 'Mostly Clear' };
}
// Clear conditions (default)
if (forecast.includes('clear') || forecast.includes('sunny') || url.includes('skc') || url.includes('clr')) {
return isDaytime
? { icon: Sun, description: 'Clear' }
: { icon: Moon, description: 'Clear' };
}
// Default fallback based on time of day
return isDaytime
? { icon: Sun, description: 'Clear' }
: { icon: Moon, description: 'Clear' };
}
+89 -2
View File
@@ -1,2 +1,89 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { onMount } from 'svelte';
import WeatherGrid from '$lib/components/WeatherGrid.svelte';
import LocationButton from '$lib/components/LocationButton.svelte';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { WeatherService } from '$lib/services/weather.js';
import type { WeatherForecast, LocationInfo } from '$lib/types/weather.js';
let forecast: WeatherForecast | null = $state(null);
let loading = $state(false);
let error = $state<string | null>(null);
let location: LocationInfo | null = $state(null);
async function loadWeatherForLocation(lat: number, lon: number) {
try {
loading = true;
error = null;
forecast = await WeatherService.getForecastByCoords(lat, lon);
location = { latitude: lat, longitude: lon };
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load weather data';
forecast = null;
} finally {
loading = false;
}
}
async function handleLocationClick() {
try {
loading = true;
error = null;
const currentLocation = await WeatherService.getCurrentLocation();
await loadWeatherForLocation(currentLocation.latitude, currentLocation.longitude);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to get location';
loading = false;
}
}
onMount(() => {
loadWeatherForLocation(45.5152, -122.6784);
});
</script>
<div class="container mx-auto px-4 py-8 space-y-6">
<div class="text-center space-y-4">
<h1 class="text-4xl font-bold tracking-tight">Weather Forecast</h1>
<p class="text-muted-foreground">
Get current weather conditions and forecasts from the National Weather Service
</p>
<LocationButton onclick={handleLocationClick} {loading} />
</div>
{#if error}
<Card class="border-destructive">
<CardHeader>
<CardTitle class="text-destructive">Error</CardTitle>
</CardHeader>
<CardContent>
<p class="text-destructive">{error}</p>
</CardContent>
</Card>
{/if}
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{/if}
{#if forecast && !loading}
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Forecast Information</CardTitle>
<CardDescription>
Generated: {new Date(forecast.properties.generatedAt).toLocaleString()}
{#if location}
• Location: {location.latitude.toFixed(4)}, {location.longitude.toFixed(4)}
{/if}
</CardDescription>
</CardHeader>
</Card>
<WeatherGrid periods={forecast.properties.periods} />
</div>
{/if}
</div>