version 1.0
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+50
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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}`));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user