|
1 | 1 | <script setup lang="ts"> |
2 | | -import ClearDay from '../assets/clear-day.svg' |
| 2 | +import { computed } from 'vue' |
| 3 | +
|
3 | 4 | import Skeleton from './Skeleton.vue' |
4 | 5 |
|
5 | | -const props = defineProps<{ |
6 | | - propsLoading: boolean |
| 6 | +type WeatherEffect = 'rain' | 'snow' | 'thunder' | 'fog' | 'cloudy' | 'none' |
| 7 | +type SizePreset = 's' | 'm' | 'l' | { cols?: number, rows?: number } |
| 8 | +
|
| 9 | +const props = withDefaults(defineProps<{ |
| 10 | + propsLoading?: boolean |
7 | 11 |
|
8 | 12 | city?: string |
9 | 13 | temperature?: string |
10 | 14 | condition?: string |
11 | | -}>() |
| 15 | + icon?: string |
| 16 | + conditionCode?: string |
| 17 | + isNight?: boolean |
| 18 | + effect?: WeatherEffect |
| 19 | + size?: SizePreset |
| 20 | + high?: string |
| 21 | + low?: string |
| 22 | + feelsLike?: string |
| 23 | + humidity?: string |
| 24 | + wind?: string |
| 25 | + precipitation?: string |
| 26 | +}>(), { |
| 27 | + propsLoading: false, |
| 28 | + isNight: false, |
| 29 | +}) |
| 30 | +
|
| 31 | +const weatherIconMap = { |
| 32 | + 'clear-day': { icon: 'i-iconify-meteocons:clear-day-fill', effect: 'none', nightKey: 'clear-night' }, |
| 33 | + 'clear-night': { icon: 'i-iconify-meteocons:clear-night-fill', effect: 'none' }, |
| 34 | + 'partly-cloudy-day': { icon: 'i-iconify-meteocons:partly-cloudy-day-fill', effect: 'cloudy', nightKey: 'partly-cloudy-night' }, |
| 35 | + 'partly-cloudy-night': { icon: 'i-iconify-meteocons:partly-cloudy-night-fill', effect: 'cloudy' }, |
| 36 | + 'cloudy': { icon: 'i-iconify-meteocons:cloudy-fill', effect: 'cloudy' }, |
| 37 | + 'overcast': { icon: 'i-iconify-meteocons:overcast-fill', effect: 'cloudy' }, |
| 38 | + 'rain': { icon: 'i-iconify-meteocons:rain-fill', effect: 'rain' }, |
| 39 | + 'drizzle': { icon: 'i-iconify-meteocons:drizzle-fill', effect: 'rain' }, |
| 40 | + 'extreme-rain': { icon: 'i-iconify-meteocons:extreme-rain-fill', effect: 'rain' }, |
| 41 | + 'thunderstorm': { icon: 'i-iconify-meteocons:thunderstorms-fill', effect: 'thunder' }, |
| 42 | + 'snow': { icon: 'i-iconify-meteocons:snow-fill', effect: 'snow' }, |
| 43 | + 'extreme-snow': { icon: 'i-iconify-meteocons:extreme-snow-fill', effect: 'snow' }, |
| 44 | + 'sleet': { icon: 'i-iconify-meteocons:sleet-fill', effect: 'snow' }, |
| 45 | + 'hail': { icon: 'i-iconify-meteocons:hail-fill', effect: 'snow' }, |
| 46 | + 'fog': { icon: 'i-iconify-meteocons:fog-fill', effect: 'fog' }, |
| 47 | + 'mist': { icon: 'i-iconify-meteocons:mist-fill', effect: 'fog' }, |
| 48 | + 'haze': { icon: 'i-iconify-meteocons:haze-fill', effect: 'fog' }, |
| 49 | + 'dust': { icon: 'i-iconify-meteocons:dust-fill', effect: 'fog' }, |
| 50 | + 'smoke': { icon: 'i-iconify-meteocons:smoke-fill', effect: 'fog' }, |
| 51 | + 'wind': { icon: 'i-iconify-meteocons:wind-fill', effect: 'cloudy' }, |
| 52 | + 'tornado': { icon: 'i-iconify-meteocons:tornado-fill', effect: 'thunder' }, |
| 53 | + 'hurricane': { icon: 'i-iconify-meteocons:hurricane-fill', effect: 'thunder' }, |
| 54 | +} as Record<string, { icon: string, effect?: WeatherEffect, nightKey?: string }> |
| 55 | +
|
| 56 | +const normalizedCondition = computed(() => (props.condition ?? '').toLowerCase().trim()) |
| 57 | +const normalizedCode = computed(() => (props.conditionCode ?? '').toLowerCase().trim()) |
| 58 | +const isLarge = computed(() => props.size === 'l') |
| 59 | +
|
| 60 | +const conditionMappings: Array<{ keywords: string[], key: string }> = [ |
| 61 | + { keywords: ['thunder', 'storm', 'lightning'], key: 'thunderstorm' }, |
| 62 | + { keywords: ['hurricane', 'typhoon', 'cyclone'], key: 'hurricane' }, |
| 63 | + { keywords: ['tornado'], key: 'tornado' }, |
| 64 | + { keywords: ['hail'], key: 'hail' }, |
| 65 | + { keywords: ['sleet', 'ice'], key: 'sleet' }, |
| 66 | + { keywords: ['snow', 'blizzard', 'flurries'], key: 'snow' }, |
| 67 | + { keywords: ['extreme rain', 'heavy rain', 'downpour'], key: 'extreme-rain' }, |
| 68 | + { keywords: ['rain', 'shower'], key: 'rain' }, |
| 69 | + { keywords: ['drizzle', 'sprinkle'], key: 'drizzle' }, |
| 70 | + { keywords: ['fog'], key: 'fog' }, |
| 71 | + { keywords: ['mist'], key: 'mist' }, |
| 72 | + { keywords: ['haze'], key: 'haze' }, |
| 73 | + { keywords: ['dust', 'sand'], key: 'dust' }, |
| 74 | + { keywords: ['smoke'], key: 'smoke' }, |
| 75 | + { keywords: ['overcast'], key: 'overcast' }, |
| 76 | + { keywords: ['cloud'], key: 'cloudy' }, |
| 77 | + { keywords: ['partly', 'mostly', 'scattered'], key: 'partly-cloudy-day' }, |
| 78 | + { keywords: ['wind', 'breezy', 'gust'], key: 'wind' }, |
| 79 | + { keywords: ['clear', 'sun', 'fair'], key: 'clear-day' }, |
| 80 | +] |
| 81 | +
|
| 82 | +function isWeatherIconKey(input: string): input is string { |
| 83 | + return input in weatherIconMap |
| 84 | +} |
| 85 | +
|
| 86 | +const resolvedIconKey = computed<string>(() => { |
| 87 | + if (props.icon) |
| 88 | + return props.icon |
| 89 | +
|
| 90 | + const direct = normalizedCode.value |
| 91 | + if (direct && isWeatherIconKey(direct)) |
| 92 | + return direct |
| 93 | +
|
| 94 | + const match = conditionMappings.find(({ keywords }) => keywords.some(keyword => normalizedCondition.value.includes(keyword))) |
| 95 | + const fallback: string = props.isNight ? 'clear-night' : 'clear-day' |
| 96 | + const baseKey = match?.key ?? fallback |
| 97 | + const config = weatherIconMap[baseKey] |
| 98 | +
|
| 99 | + if (props.isNight && config.nightKey) |
| 100 | + return config.nightKey |
| 101 | +
|
| 102 | + return baseKey |
| 103 | +}) |
| 104 | +
|
| 105 | +const resolvedIconClass = computed(() => weatherIconMap[resolvedIconKey.value].icon) |
12 | 106 | </script> |
13 | 107 |
|
14 | 108 | <template> |
15 | | - <div h-full w-full> |
16 | | - <Skeleton v-if="props.propsLoading" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 max-h-35 gap-2"> |
17 | | - <Skeleton animation="wave" class="grid-col-span-3 h-[1lh] w-20% rounded-2xl" /> |
18 | | - <div class="col-span-1 row-span-2 h-20 w-20 justify-self-end" /> |
19 | | - <Skeleton animation="wave" class="col-span-2 row-span-2 h-full w-20% inline-flex items-end rounded-2xl text-gray-600 font-thin dark:text-gray-300" /> |
20 | | - <Skeleton animation="wave" class="col-span-2 row-span-1 h-full w-20% inline-flex items-end justify-self-end rounded-2xl pr-4 text-gray-500 dark:text-gray-400" /> |
| 109 | + <div :class="['relative', 'h-full', 'w-full', 'font-sans']"> |
| 110 | + <Skeleton |
| 111 | + v-if="props.propsLoading" |
| 112 | + :class="['grid', 'grid-cols-4', 'grid-rows-3', 'max-h-35', 'gap-2', 'rounded-2xl', 'py-2', 'pl-3', 'pr-1']" |
| 113 | + > |
| 114 | + <Skeleton |
| 115 | + animation="wave" |
| 116 | + :class="['col-span-3', 'h-[1lh]', 'w-20%', 'rounded-2xl']" |
| 117 | + /> |
| 118 | + <div |
| 119 | + :class="['col-span-1', 'row-span-2', 'h-20', 'w-20', 'justify-self-end']" |
| 120 | + /> |
| 121 | + <Skeleton |
| 122 | + animation="wave" |
| 123 | + :class="['col-span-2', 'row-span-2', 'h-full', 'w-20%', 'inline-flex', 'items-end', 'rounded-2xl', 'text-gray-600', 'font-thin', 'dark:text-gray-300']" |
| 124 | + /> |
| 125 | + <Skeleton |
| 126 | + animation="wave" |
| 127 | + :class="['col-span-2', 'row-span-1', 'h-full', 'w-20%', 'inline-flex', 'items-end', 'justify-self-end', 'rounded-2xl', 'pr-4', 'text-gray-500', 'dark:text-gray-400']" |
| 128 | + /> |
21 | 129 | </Skeleton> |
22 | | - <div v-else bg="blue-100 dark:blue-900" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 h-full gap-2"> |
23 | | - <div class="grid-col-span-2 text-lg font-semibold"> |
24 | | - {{ props.city }} |
| 130 | + <div |
| 131 | + v-else |
| 132 | + :class="[ |
| 133 | + 'relative', |
| 134 | + 'flex', |
| 135 | + 'h-full', |
| 136 | + 'flex-col', |
| 137 | + 'justify-between', |
| 138 | + 'gap-3', |
| 139 | + 'overflow-x-hidden overflow-y-scroll', |
| 140 | + 'rounded-2xl', |
| 141 | + 'p-3', |
| 142 | + 'bg-gradient-to-br', |
| 143 | + 'from-neutral-950', |
| 144 | + 'via-neutral-900', |
| 145 | + 'to-neutral-800', |
| 146 | + 'text-white', |
| 147 | + ]" |
| 148 | + > |
| 149 | + <div |
| 150 | + aria-hidden="true" |
| 151 | + :class="[ |
| 152 | + 'pointer-events-none', |
| 153 | + 'absolute', |
| 154 | + '-right-8', |
| 155 | + '-top-10', |
| 156 | + 'size-32', |
| 157 | + 'rounded-full', |
| 158 | + 'bg-gradient-to-br', |
| 159 | + 'from-white/30', |
| 160 | + 'via-white/5', |
| 161 | + 'to-transparent', |
| 162 | + 'blur-xl', |
| 163 | + ]" |
| 164 | + /> |
| 165 | + <div v-if="isLarge" :class="['relative', 'z-1', 'flex', 'h-full', 'flex-col', 'justify-between', 'gap-4']"> |
| 166 | + <div :class="['flex', 'items-center', 'justify-center']"> |
| 167 | + <div |
| 168 | + aria-hidden="true" |
| 169 | + :class="[ |
| 170 | + 'text-[6rem]', |
| 171 | + 'leading-none', |
| 172 | + 'text-white/95', |
| 173 | + 'drop-shadow-[0_12px_30px_rgba(0,0,0,0.55)]', |
| 174 | + resolvedIconClass, |
| 175 | + ]" |
| 176 | + /> |
| 177 | + </div> |
| 178 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-2']"> |
| 179 | + <div :class="['text-[4rem]', 'font-semibold', 'leading-none']"> |
| 180 | + {{ props.temperature ?? '--' }} |
| 181 | + </div> |
| 182 | + <div :class="['text-lg', 'text-white/85']"> |
| 183 | + {{ props.condition ?? '—' }} |
| 184 | + </div> |
| 185 | + <div :class="['text-sm', 'text-white/55']"> |
| 186 | + {{ props.city ?? 'Unknown' }} |
| 187 | + </div> |
| 188 | + </div> |
| 189 | + <div :class="['h-px', 'w-full', 'bg-white/10']" /> |
| 190 | + <div :class="['grid', 'grid-cols-3', 'gap-3', 'text-white/80']"> |
| 191 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 192 | + <div :class="['text-4xl', 'leading-none', 'i-iconify-meteocons:wind-fill-static']" /> |
| 193 | + <div :class="['text-sm', 'font-semibold']"> |
| 194 | + {{ props.wind ?? '--' }} |
| 195 | + </div> |
| 196 | + <div :class="['text-xs', 'text-white/60']"> |
| 197 | + Wind |
| 198 | + </div> |
| 199 | + </div> |
| 200 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 201 | + <div :class="['text-4xl', 'leading-none', 'i-iconify-meteocons:raindrops-fill-static']" /> |
| 202 | + <div :class="['text-sm', 'font-semibold']"> |
| 203 | + {{ props.precipitation ?? '--' }} |
| 204 | + </div> |
| 205 | + <div :class="['text-xs', 'text-white/60']"> |
| 206 | + Chance of rain |
| 207 | + </div> |
| 208 | + </div> |
| 209 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 210 | + <div :class="['text-4xl', 'leading-none', 'i-iconify-meteocons:humidity-fill-static']" /> |
| 211 | + <div :class="['text-sm', 'font-semibold']"> |
| 212 | + {{ props.humidity ?? '--' }} |
| 213 | + </div> |
| 214 | + <div :class="['text-xs', 'text-white/60']"> |
| 215 | + Humidity |
| 216 | + </div> |
| 217 | + </div> |
| 218 | + </div> |
25 | 219 | </div> |
26 | | - <img :src="ClearDay" alt="Weather Icon" class="col-span-2 row-span-2 h-full w-auto justify-self-end"> |
27 | | - <div class="col-span-2 row-span-2 h-full inline-flex items-end text-gray-600 font-thin dark:text-gray-300"> |
28 | | - <span class="text-[3.5rem] font-thin leading-[1]">{{ props.temperature }}</span> |
29 | | - </div> |
30 | | - <div class="col-span-2 row-span-2 h-full w-full inline-flex items-end justify-end pr-4 text-gray-500 dark:text-gray-400"> |
31 | | - <span>{{ props.condition }}</span> |
| 220 | + <div v-else :class="['relative', 'z-1', 'flex', 'flex-1', 'flex-col', 'gap-3']"> |
| 221 | + <div :class="['flex', 'items-start', 'justify-between', 'gap-4', 'flex-1', 'p-2']"> |
| 222 | + <div |
| 223 | + aria-hidden="true" |
| 224 | + :class="[ |
| 225 | + 'size-40 scale-120', |
| 226 | + 'leading-none', |
| 227 | + 'text-white/90', |
| 228 | + 'drop-shadow-[0_10px_22px_rgba(0,0,0,0.5)]', |
| 229 | + resolvedIconClass, |
| 230 | + ]" |
| 231 | + /> |
| 232 | + <div :class="['flex', 'flex-col', 'items-end', 'gap-2']"> |
| 233 | + <div :class="['text-sm', 'tracking-wide', 'text-white/80']"> |
| 234 | + {{ props.condition ?? '—' }} |
| 235 | + </div> |
| 236 | + <div :class="['text-[3.5rem]', 'font-semibold', 'leading-none']"> |
| 237 | + {{ props.temperature ?? '--' }} |
| 238 | + </div> |
| 239 | + <div :class="['text-xs', 'text-white/60']"> |
| 240 | + {{ props.city ?? 'Unknown' }} |
| 241 | + </div> |
| 242 | + </div> |
| 243 | + </div> |
| 244 | + <div :class="['h-px', 'w-full', 'bg-white/10']" /> |
| 245 | + <div :class="['grid', 'grid-cols-3', 'gap-2', 'text-white/80', 'flex-shrink-0', 'pb-4']"> |
| 246 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 247 | + <div :class="['text-4xl scale-120', 'leading-none', 'i-iconify-meteocons:wind-fill-static']" /> |
| 248 | + <div :class="['text-sm', 'font-semibold']"> |
| 249 | + {{ props.wind ?? '--' }} |
| 250 | + </div> |
| 251 | + <div :class="['text-xs', 'text-white/60']"> |
| 252 | + Wind |
| 253 | + </div> |
| 254 | + </div> |
| 255 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 256 | + <div :class="['text-4xl scale-120', 'leading-none', 'i-iconify-meteocons:raindrops-fill-static']" /> |
| 257 | + <div :class="['text-sm', 'font-semibold']"> |
| 258 | + {{ props.precipitation ?? '--' }} |
| 259 | + </div> |
| 260 | + <div :class="['text-xs', 'text-white/60']"> |
| 261 | + Chance of rain |
| 262 | + </div> |
| 263 | + </div> |
| 264 | + <div :class="['flex', 'flex-col', 'items-center', 'gap-1']"> |
| 265 | + <div :class="['text-4xl scale-120', 'leading-none', 'i-iconify-meteocons:humidity-fill-static']" /> |
| 266 | + <div :class="['text-sm', 'font-semibold']"> |
| 267 | + {{ props.humidity ?? '--' }} |
| 268 | + </div> |
| 269 | + <div :class="['text-xs', 'text-white/60']"> |
| 270 | + Humidity |
| 271 | + </div> |
| 272 | + </div> |
| 273 | + </div> |
32 | 274 | </div> |
33 | 275 | </div> |
34 | 276 | </div> |
35 | 277 | </template> |
| 278 | + |
| 279 | +<style scoped> |
| 280 | +</style> |
| 281 | + |
| 282 | +<route lang="yaml"> |
| 283 | +meta: |
| 284 | + layout: plain |
| 285 | +</route> |
0 commit comments