first commit
This commit is contained in:
72
calendar/src/App.vue
Normal file
72
calendar/src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import CalendarCard from '@/components/CalendarCard.vue';
|
||||
import QuoteCard from '@/components/QuoteCard.vue';
|
||||
import WeatherCard from '@/components/WeatherCard.vue';
|
||||
import { buildCalendarModel } from '@/lib/calendar';
|
||||
import { getQuoteForDate } from '@/lib/quotes';
|
||||
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
|
||||
|
||||
const now = ref(new Date());
|
||||
const location = ref<LocationCoordinates>({
|
||||
latitude: 31.2304,
|
||||
longitude: 121.4737,
|
||||
label: '上海',
|
||||
});
|
||||
const weather = ref<WeatherSnapshot | null>(null);
|
||||
const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
|
||||
let clockTimer = 0;
|
||||
let weatherTimer = 0;
|
||||
|
||||
const calendarModel = computed(() => buildCalendarModel(now.value));
|
||||
const quoteEntry = computed(() => getQuoteForDate(now.value));
|
||||
|
||||
async function refreshWeather() {
|
||||
weatherStatus.value = 'loading';
|
||||
|
||||
try {
|
||||
weather.value = await fetchWeather(location.value);
|
||||
weatherStatus.value = 'ready';
|
||||
} catch (error) {
|
||||
console.error('获取天气失败', error);
|
||||
weatherStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
location.value = await resolveLocation();
|
||||
await refreshWeather();
|
||||
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date();
|
||||
}, 30 * 1000);
|
||||
|
||||
// 天气不需要秒级刷新,30 分钟一次足够。
|
||||
weatherTimer = window.setInterval(() => {
|
||||
void refreshWeather();
|
||||
}, 30 * 60 * 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(clockTimer);
|
||||
window.clearInterval(weatherTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-shell">
|
||||
<div class="dashboard-frame">
|
||||
<div class="dashboard-grid">
|
||||
<CalendarCard :model="calendarModel" />
|
||||
<WeatherCard
|
||||
:weather="weather"
|
||||
:status="weatherStatus"
|
||||
:location-label="location.label"
|
||||
/>
|
||||
<QuoteCard :quote="quoteEntry.text" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
70
calendar/src/components/CalendarCard.vue
Normal file
70
calendar/src/components/CalendarCard.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { CalendarModel } from '@/lib/calendar';
|
||||
|
||||
const props = defineProps<{
|
||||
model: CalendarModel;
|
||||
}>();
|
||||
|
||||
const weeks = computed(() => {
|
||||
const chunks: CalendarModel['cells'][] = [];
|
||||
|
||||
for (let index = 0; index < props.model.cells.length; index += 7) {
|
||||
chunks.push(props.model.cells.slice(index, index + 7));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card calendar-card">
|
||||
<div class="calendar-card__header">
|
||||
<div class="calendar-card__day">{{ model.largeDay }}</div>
|
||||
<div class="calendar-card__meta">
|
||||
<p class="calendar-card__date-line">
|
||||
<span>{{ model.gregorianLabel }}</span>
|
||||
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
|
||||
</p>
|
||||
<p class="calendar-card__lunar">
|
||||
<span>{{ model.lunarYearLabel }}</span>
|
||||
<span>{{ model.lunarDayLabel }}</span>
|
||||
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
|
||||
<span
|
||||
v-for="badge in model.summaryBadges"
|
||||
:key="badge.label"
|
||||
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-card__grid">
|
||||
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<template v-for="week in weeks" :key="week[0].date.toISOString()">
|
||||
<div
|
||||
v-for="cell in week"
|
||||
:key="cell.date.toISOString()"
|
||||
:class="[
|
||||
'calendar-card__cell',
|
||||
{
|
||||
'calendar-card__cell--muted': !cell.currentMonth,
|
||||
'calendar-card__cell--today': cell.isToday,
|
||||
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<span class="calendar-card__solar">{{ cell.day }}</span>
|
||||
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
27
calendar/src/components/QuoteCard.vue
Normal file
27
calendar/src/components/QuoteCard.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
quote: string;
|
||||
}>();
|
||||
|
||||
const quoteFontSize = computed(() => {
|
||||
const length = props.quote.length;
|
||||
|
||||
if (length > 140) {
|
||||
return '1.75rem';
|
||||
}
|
||||
|
||||
if (length > 100) {
|
||||
return '1.95rem';
|
||||
}
|
||||
|
||||
return '2.2rem';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card quote-card">
|
||||
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
|
||||
</section>
|
||||
</template>
|
||||
158
calendar/src/components/WeatherCard.vue
Normal file
158
calendar/src/components/WeatherCard.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import WeatherGlyph from './WeatherGlyph.vue';
|
||||
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
|
||||
|
||||
const props = defineProps<{
|
||||
weather: WeatherSnapshot | null;
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
locationLabel: string;
|
||||
}>();
|
||||
|
||||
function weatherKind(code: number) {
|
||||
if ([0].includes(code)) {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
if ([1, 2].includes(code)) {
|
||||
return 'partly';
|
||||
}
|
||||
|
||||
if ([3].includes(code)) {
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
if ([45, 48].includes(code)) {
|
||||
return 'fog';
|
||||
}
|
||||
|
||||
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
|
||||
return 'rain';
|
||||
}
|
||||
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code)) {
|
||||
return 'snow';
|
||||
}
|
||||
|
||||
if ([95, 96, 99].includes(code)) {
|
||||
return 'storm';
|
||||
}
|
||||
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (!props.weather) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '日出',
|
||||
value: props.weather.sunrise,
|
||||
accent: 'metric--sunrise',
|
||||
icon: 'sunrise',
|
||||
},
|
||||
{
|
||||
label: '日落',
|
||||
value: props.weather.sunset,
|
||||
accent: 'metric--sunset',
|
||||
icon: 'sunset',
|
||||
},
|
||||
{
|
||||
label: '空气质量',
|
||||
value:
|
||||
props.weather.aqi === null
|
||||
? '暂无'
|
||||
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
||||
accent: 'metric--air',
|
||||
icon: 'air',
|
||||
},
|
||||
{
|
||||
label: '能见度',
|
||||
value: `${props.weather.visibilityKm} km`,
|
||||
accent: 'metric--visibility',
|
||||
icon: 'visibility',
|
||||
},
|
||||
] as const;
|
||||
});
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (props.status === 'error') {
|
||||
return '天气数据获取失败';
|
||||
}
|
||||
|
||||
if (props.status === 'loading') {
|
||||
return '天气数据加载中';
|
||||
}
|
||||
|
||||
return props.locationLabel;
|
||||
});
|
||||
|
||||
const currentWeatherKind = computed(() => weatherKind(props.weather?.weatherCode ?? 3));
|
||||
|
||||
function forecastKind(day: ForecastDay) {
|
||||
return weatherKind(day.weatherCode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card weather-card">
|
||||
<div class="weather-card__heading">
|
||||
<div>
|
||||
<p class="weather-card__title">天气预报</p>
|
||||
<p class="weather-card__subtitle">{{ stateLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="weather" class="weather-card__hero">
|
||||
<div class="weather-card__hero-main">
|
||||
<WeatherGlyph :kind="currentWeatherKind" large />
|
||||
<div>
|
||||
<div class="weather-card__temperature">{{ weather.temperature }}°C</div>
|
||||
<div class="weather-card__condition">{{ weather.condition }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-card__facts">
|
||||
<div class="weather-card__fact">
|
||||
<WeatherGlyph kind="humidity" />
|
||||
<span>湿度 {{ weather.humidity }}%</span>
|
||||
</div>
|
||||
<div class="weather-card__fact">
|
||||
<WeatherGlyph kind="wind" />
|
||||
<span>风速 {{ weather.windSpeed }} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="weather-card__hero weather-card__hero--placeholder">
|
||||
<p>{{ stateLabel }}</p>
|
||||
</div>
|
||||
|
||||
<div class="weather-card__forecast">
|
||||
<article v-for="day in forecast" :key="day.label" class="forecast-pill">
|
||||
<p class="forecast-pill__label">{{ day.label }}</p>
|
||||
<WeatherGlyph :kind="forecastKind(day)" large />
|
||||
<p class="forecast-pill__temp">{{ day.high }}°<span>{{ day.low }}°</span></p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="weather-card__metrics">
|
||||
<article
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
:class="['metric-pill', metric.accent]"
|
||||
>
|
||||
<div class="metric-pill__label">
|
||||
<WeatherGlyph :kind="metric.icon" />
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<p class="metric-pill__value">{{ metric.value }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
154
calendar/src/components/WeatherGlyph.vue
Normal file
154
calendar/src/components/WeatherGlyph.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type WeatherGlyphKind =
|
||||
| 'clear'
|
||||
| 'partly'
|
||||
| 'cloudy'
|
||||
| 'fog'
|
||||
| 'rain'
|
||||
| 'snow'
|
||||
| 'storm'
|
||||
| 'sunrise'
|
||||
| 'sunset'
|
||||
| 'air'
|
||||
| 'visibility'
|
||||
| 'humidity'
|
||||
| 'wind';
|
||||
|
||||
const props = defineProps<{
|
||||
kind: WeatherGlyphKind;
|
||||
large?: boolean;
|
||||
}>();
|
||||
|
||||
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
|
||||
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="['glyph', { 'glyph--large': props.large }]"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="strokeWidth"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g v-if="props.kind === 'clear'">
|
||||
<circle cx="24" cy="24" r="6.5" />
|
||||
<line x1="24" y1="8.5" x2="24" y2="12.5" />
|
||||
<line x1="24" y1="35.5" x2="24" y2="39.5" />
|
||||
<line x1="8.5" y1="24" x2="12.5" y2="24" />
|
||||
<line x1="35.5" y1="24" x2="39.5" y2="24" />
|
||||
<line x1="13.1" y1="13.1" x2="15.9" y2="15.9" />
|
||||
<line x1="32.1" y1="32.1" x2="34.9" y2="34.9" />
|
||||
<line x1="13.1" y1="34.9" x2="15.9" y2="32.1" />
|
||||
<line x1="32.1" y1="15.9" x2="34.9" y2="13.1" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'partly'">
|
||||
<circle cx="16.5" cy="16.5" r="5" />
|
||||
<line x1="16.5" y1="7.5" x2="16.5" y2="10.5" />
|
||||
<line x1="9.2" y1="16.5" x2="12.2" y2="16.5" />
|
||||
<line x1="11.4" y1="11.4" x2="13.6" y2="13.6" />
|
||||
<line x1="21.6" y1="11.4" x2="19.4" y2="13.6" />
|
||||
<path d="M15.5 31.5h15.8a5.7 5.7 0 1 0-.5-11.3 8.4 8.4 0 0 0-15.3-1.7 6.2 6.2 0 0 0 0 13Z" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'cloudy'">
|
||||
<path d="M13 31.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 31.5Z" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'fog'">
|
||||
<path d="M13 25.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 25.5Z" />
|
||||
<path d="M11 31c2 0 3-1 5-1s3 1 5 1 3-1 5-1 3 1 5 1 3-1 5-1" />
|
||||
<path d="M14 36c1.7 0 2.6-.8 4.3-.8s2.6.8 4.3.8 2.6-.8 4.3-.8 2.6.8 4.3.8" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'rain'">
|
||||
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
|
||||
<path d="M16 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9S16 29.4 16 31Z" />
|
||||
<path d="M25.4 34.2c0 1.8-1.1 3.2-2.4 3.2s-2.4-1.4-2.4-3.2c0-1.5 2.4-4.5 2.4-4.5s2.4 3 2.4 4.5Z" />
|
||||
<path d="M34.8 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9s2.7 3.3 2.7 4.9Z" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'snow'">
|
||||
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
|
||||
<g>
|
||||
<line x1="16.5" y1="30.5" x2="16.5" y2="38.5" />
|
||||
<line x1="13" y1="34.5" x2="20" y2="34.5" />
|
||||
<line x1="13.8" y1="31.8" x2="19.2" y2="37.2" />
|
||||
<line x1="19.2" y1="31.8" x2="13.8" y2="37.2" />
|
||||
</g>
|
||||
<g>
|
||||
<line x1="31.5" y1="30.5" x2="31.5" y2="38.5" />
|
||||
<line x1="28" y1="34.5" x2="35" y2="34.5" />
|
||||
<line x1="28.8" y1="31.8" x2="34.2" y2="37.2" />
|
||||
<line x1="34.2" y1="31.8" x2="28.8" y2="37.2" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'storm'">
|
||||
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
|
||||
<path d="M24.5 29.5l-4 7h4.4l-2 8 7.6-10h-4.8l2.3-5Z" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'sunrise'">
|
||||
<line x1="10" y1="33" x2="38" y2="33" />
|
||||
<path d="M16 33a8 8 0 0 1 16 0" />
|
||||
<line x1="24" y1="10" x2="24" y2="16" />
|
||||
<polyline points="21,13 24,10 27,13" />
|
||||
<line x1="15" y1="26" x2="17.5" y2="23.5" />
|
||||
<line x1="33" y1="26" x2="30.5" y2="23.5" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'sunset'">
|
||||
<line x1="10" y1="33" x2="38" y2="33" />
|
||||
<path d="M16 33a8 8 0 0 1 16 0" />
|
||||
<line x1="24" y1="10" x2="24" y2="16" />
|
||||
<polyline points="21,13 24,16 27,13" />
|
||||
<line x1="15" y1="26" x2="17.5" y2="28.5" />
|
||||
<line x1="33" y1="26" x2="30.5" y2="28.5" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'air'">
|
||||
<path d="M10 17c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
|
||||
<path d="M13 24c3.8 0 3.8-2.5 7.6-2.5s3.8 2.5 7.6 2.5 3.8-2.5 7.6-2.5" />
|
||||
<path d="M10 31c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'visibility'">
|
||||
<path d="M6 24c3.1-4.8 8.8-8 18-8s14.9 3.2 18 8c-3.1 4.8-8.8 8-18 8S9.1 28.8 6 24Z" />
|
||||
<circle cx="24" cy="24" r="4.8" />
|
||||
<circle cx="24" cy="24" r="1.5" fill="currentColor" stroke="none" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'humidity'">
|
||||
<path d="M24 10C20.5 15 16 20.4 16 27a8 8 0 0 0 16 0c0-6.6-4.5-12-8-17Z" />
|
||||
<path d="M24 30.5a4.6 4.6 0 0 0 4.4-3.1" />
|
||||
</g>
|
||||
|
||||
<g v-else-if="props.kind === 'wind'">
|
||||
<path d="M10 17h17c3.3 0 5.5-2 5.5-4.8 0-2.4-1.9-4.2-4.4-4.2-2.1 0-3.7 1.2-4.4 3" />
|
||||
<path d="M10 25h22c2.8 0 4.8 1.8 4.8 4.2 0 2.3-1.8 4.1-4.1 4.1-1.9 0-3.4-.9-4.2-2.4" />
|
||||
<path d="M10 33h11c2.5 0 4.3 1.6 4.3 3.8 0 2.1-1.7 3.7-3.9 3.7-1.6 0-2.9-.7-3.7-2" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glyph {
|
||||
width: 1.12rem;
|
||||
height: 1.12rem;
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.glyph--large {
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
}
|
||||
</style>
|
||||
11
calendar/src/env.d.ts
vendored
Normal file
11
calendar/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
132
calendar/src/lib/calendar.ts
Normal file
132
calendar/src/lib/calendar.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { HolidayUtil, Solar } from 'lunar-typescript';
|
||||
|
||||
export interface CalendarBadge {
|
||||
label: string;
|
||||
tone: 'holiday' | 'workday' | 'festival' | 'term';
|
||||
}
|
||||
|
||||
export interface CalendarCell {
|
||||
date: Date;
|
||||
day: number;
|
||||
subLabel: string;
|
||||
currentMonth: boolean;
|
||||
isToday: boolean;
|
||||
badges: CalendarBadge[];
|
||||
}
|
||||
|
||||
export interface CalendarModel {
|
||||
largeDay: string;
|
||||
gregorianLabel: string;
|
||||
weekdayLabel: string;
|
||||
lunarYearLabel: string;
|
||||
lunarDayLabel: string;
|
||||
summaryBadges: CalendarBadge[];
|
||||
weekLabels: string[];
|
||||
cells: CalendarCell[];
|
||||
}
|
||||
|
||||
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
function isSameDay(left: Date, right: Date) {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function badgeFromHoliday(dateKey: string): CalendarBadge[] {
|
||||
const holiday = HolidayUtil.getHoliday(dateKey);
|
||||
|
||||
if (!holiday) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: holiday.getName(),
|
||||
tone: holiday.isWork() ? 'workday' : 'holiday',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildSolar(date: Date) {
|
||||
return Solar.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
|
||||
function buildCellBadges(date: Date) {
|
||||
const solar = buildSolar(date);
|
||||
const lunar = solar.getLunar();
|
||||
const solarFestivals = solar.getFestivals();
|
||||
const lunarFestivals = lunar.getFestivals();
|
||||
const otherFestivals = solar.getOtherFestivals();
|
||||
const jieQi = lunar.getJieQi();
|
||||
|
||||
return [
|
||||
...badgeFromHoliday(solar.toYmd()),
|
||||
...solarFestivals.map((label) => ({ label, tone: 'festival' as const })),
|
||||
...lunarFestivals.map((label) => ({ label, tone: 'festival' as const })),
|
||||
...otherFestivals.map((label) => ({ label, tone: 'festival' as const })),
|
||||
...(jieQi ? [{ label: jieQi, tone: 'term' as const }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function buildCellSubLabel(date: Date) {
|
||||
const solar = buildSolar(date);
|
||||
const lunar = solar.getLunar();
|
||||
const badges = buildCellBadges(date);
|
||||
|
||||
if (badges.length > 0) {
|
||||
return badges[0].label;
|
||||
}
|
||||
|
||||
const lunarDay = lunar.getDayInChinese();
|
||||
|
||||
if (lunarDay === '初一') {
|
||||
return lunar.getMonthInChinese();
|
||||
}
|
||||
|
||||
return lunarDay;
|
||||
}
|
||||
|
||||
export function buildCalendarModel(currentDate: Date): CalendarModel {
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const monthStart = new Date(currentYear, currentMonth, 1);
|
||||
const monthEnd = new Date(currentYear, currentMonth + 1, 0);
|
||||
const gridStart = new Date(monthStart);
|
||||
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
|
||||
const gridEnd = new Date(monthEnd);
|
||||
gridEnd.setDate(monthEnd.getDate() + (6 - monthEnd.getDay()));
|
||||
|
||||
const cells: CalendarCell[] = [];
|
||||
const cursor = new Date(gridStart);
|
||||
|
||||
while (cursor <= gridEnd) {
|
||||
const cellDate = new Date(cursor);
|
||||
cells.push({
|
||||
date: cellDate,
|
||||
day: cellDate.getDate(),
|
||||
subLabel: buildCellSubLabel(cellDate),
|
||||
badges: buildCellBadges(cellDate),
|
||||
currentMonth: cellDate.getMonth() === currentMonth,
|
||||
isToday: isSameDay(cellDate, currentDate),
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
const solar = buildSolar(currentDate);
|
||||
const lunar = solar.getLunar();
|
||||
const summaryBadges = buildCellBadges(currentDate).slice(0, 3);
|
||||
|
||||
return {
|
||||
largeDay: String(currentDate.getDate()).padStart(2, '0'),
|
||||
gregorianLabel: `${currentYear}年${currentMonth + 1}月${currentDate.getDate()}日`,
|
||||
weekdayLabel: `星期${solar.getWeekInChinese()}`,
|
||||
lunarYearLabel: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}年`,
|
||||
lunarDayLabel: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
|
||||
summaryBadges,
|
||||
weekLabels: WEEK_LABELS,
|
||||
cells,
|
||||
};
|
||||
}
|
||||
49
calendar/src/lib/quotes.ts
Normal file
49
calendar/src/lib/quotes.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import quoteMarkdown from '../../../日知录.md?raw';
|
||||
|
||||
export interface QuoteEntry {
|
||||
date: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const QUOTE_PATTERN = /^>\s*(.+?)\s*--\s*(\d{4}-\d{2}-\d{2})$/gm;
|
||||
|
||||
const QUOTE_ENTRIES: QuoteEntry[] = Array.from(quoteMarkdown.matchAll(QUOTE_PATTERN)).map(
|
||||
(match) => ({
|
||||
text: match[1].trim(),
|
||||
date: match[2],
|
||||
}),
|
||||
);
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function toMonthDay(date: Date) {
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function toIsoDate(date: Date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function getQuoteForDate(date: Date) {
|
||||
const isoDate = toIsoDate(date);
|
||||
const monthDay = toMonthDay(date);
|
||||
const exactMatch = QUOTE_ENTRIES.find((entry) => entry.date === isoDate);
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const monthDayMatch = QUOTE_ENTRIES.find((entry) => entry.date.slice(5) === monthDay);
|
||||
|
||||
if (monthDayMatch) {
|
||||
return monthDayMatch;
|
||||
}
|
||||
|
||||
// 文件日期不覆盖全年时,回退到一个稳定的循环选择,保证每天都有内容可显示。
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const dayIndex = Math.floor((date.getTime() - startOfYear.getTime()) / 86_400_000);
|
||||
|
||||
return QUOTE_ENTRIES[(dayIndex % QUOTE_ENTRIES.length + QUOTE_ENTRIES.length) % QUOTE_ENTRIES.length];
|
||||
}
|
||||
203
calendar/src/lib/weather.ts
Normal file
203
calendar/src/lib/weather.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
export interface LocationCoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ForecastDay {
|
||||
label: string;
|
||||
weatherCode: number;
|
||||
condition: string;
|
||||
high: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface WeatherSnapshot {
|
||||
temperature: number;
|
||||
condition: string;
|
||||
weatherCode: number;
|
||||
humidity: number;
|
||||
windSpeed: number;
|
||||
visibilityKm: number;
|
||||
forecast: ForecastDay[];
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
aqi: number | null;
|
||||
aqiLabel: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCATION: LocationCoordinates = {
|
||||
latitude: 31.2304,
|
||||
longitude: 121.4737,
|
||||
label: '上海',
|
||||
};
|
||||
|
||||
const WEATHER_CODE_LABELS: Record<number, string> = {
|
||||
0: '晴朗',
|
||||
1: '晴间多云',
|
||||
2: '多云',
|
||||
3: '阴天',
|
||||
45: '有雾',
|
||||
48: '冻雾',
|
||||
51: '毛毛雨',
|
||||
53: '小雨',
|
||||
55: '中雨',
|
||||
56: '冻毛雨',
|
||||
57: '冻毛雨',
|
||||
61: '小雨',
|
||||
63: '中雨',
|
||||
65: '大雨',
|
||||
66: '冻雨',
|
||||
67: '冻雨',
|
||||
71: '小雪',
|
||||
73: '中雪',
|
||||
75: '大雪',
|
||||
77: '阵雪',
|
||||
80: '阵雨',
|
||||
81: '强阵雨',
|
||||
82: '暴雨',
|
||||
85: '阵雪',
|
||||
86: '大阵雪',
|
||||
95: '雷阵雨',
|
||||
96: '雷暴冰雹',
|
||||
99: '强雷暴',
|
||||
};
|
||||
|
||||
function formatClock(value: string) {
|
||||
const date = new Date(value);
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatForecastLabel(value: string, index: number) {
|
||||
if (index === 0) {
|
||||
return '今天';
|
||||
}
|
||||
|
||||
if (index === 1) {
|
||||
return '明天';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
weekday: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function aqiToLabel(aqi: number | null) {
|
||||
if (aqi === null) {
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
if (aqi <= 50) {
|
||||
return '优';
|
||||
}
|
||||
|
||||
if (aqi <= 100) {
|
||||
return '良';
|
||||
}
|
||||
|
||||
if (aqi <= 150) {
|
||||
return '轻度';
|
||||
}
|
||||
|
||||
if (aqi <= 200) {
|
||||
return '中度';
|
||||
}
|
||||
|
||||
if (aqi <= 300) {
|
||||
return '重度';
|
||||
}
|
||||
|
||||
return '严重';
|
||||
}
|
||||
|
||||
export function weatherCodeToLabel(code: number) {
|
||||
return WEATHER_CODE_LABELS[code] ?? '天气';
|
||||
}
|
||||
|
||||
export async function resolveLocation(): Promise<LocationCoordinates> {
|
||||
if (!('geolocation' in navigator)) {
|
||||
return DEFAULT_LOCATION;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
label: '当前位置',
|
||||
});
|
||||
},
|
||||
() => resolve(DEFAULT_LOCATION),
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 5000,
|
||||
maximumAge: 10 * 60 * 1000,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWeather(location: LocationCoordinates): Promise<WeatherSnapshot> {
|
||||
const weatherParams = new URLSearchParams({
|
||||
latitude: String(location.latitude),
|
||||
longitude: String(location.longitude),
|
||||
current: [
|
||||
'temperature_2m',
|
||||
'relative_humidity_2m',
|
||||
'weather_code',
|
||||
'wind_speed_10m',
|
||||
'visibility',
|
||||
].join(','),
|
||||
daily: ['weather_code', 'temperature_2m_max', 'temperature_2m_min', 'sunrise', 'sunset'].join(','),
|
||||
forecast_days: '5',
|
||||
timezone: 'auto',
|
||||
});
|
||||
|
||||
const airParams = new URLSearchParams({
|
||||
latitude: String(location.latitude),
|
||||
longitude: String(location.longitude),
|
||||
current: 'us_aqi',
|
||||
timezone: 'auto',
|
||||
});
|
||||
|
||||
const [weatherResponse, airResponse] = await Promise.all([
|
||||
fetch(`https://api.open-meteo.com/v1/forecast?${weatherParams.toString()}`),
|
||||
fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${airParams.toString()}`),
|
||||
]);
|
||||
|
||||
if (!weatherResponse.ok) {
|
||||
throw new Error(`天气接口返回异常:${weatherResponse.status}`);
|
||||
}
|
||||
|
||||
const weatherJson = await weatherResponse.json();
|
||||
const airJson = airResponse.ok ? await airResponse.json() : null;
|
||||
|
||||
const aqi = airJson?.current?.us_aqi ?? null;
|
||||
const daily = weatherJson.daily;
|
||||
|
||||
return {
|
||||
temperature: Math.round(weatherJson.current.temperature_2m),
|
||||
condition: weatherCodeToLabel(weatherJson.current.weather_code),
|
||||
weatherCode: weatherJson.current.weather_code,
|
||||
humidity: Math.round(weatherJson.current.relative_humidity_2m),
|
||||
windSpeed: Math.round(weatherJson.current.wind_speed_10m),
|
||||
visibilityKm: Number((weatherJson.current.visibility / 1000).toFixed(1)),
|
||||
forecast: daily.time.map((value: string, index: number) => ({
|
||||
label: formatForecastLabel(value, index),
|
||||
weatherCode: daily.weather_code[index],
|
||||
condition: weatherCodeToLabel(daily.weather_code[index]),
|
||||
high: Math.round(daily.temperature_2m_max[index]),
|
||||
low: Math.round(daily.temperature_2m_min[index]),
|
||||
})),
|
||||
sunrise: formatClock(daily.sunrise[0]),
|
||||
sunset: formatClock(daily.sunset[0]),
|
||||
aqi,
|
||||
aqiLabel: aqiToLabel(aqi),
|
||||
};
|
||||
}
|
||||
6
calendar/src/main.ts
Normal file
6
calendar/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
448
calendar/src/style.css
Normal file
448
calendar/src/style.css
Normal file
@@ -0,0 +1,448 @@
|
||||
:root {
|
||||
font-family:
|
||||
'Hiragino Sans GB',
|
||||
'PingFang SC',
|
||||
'Noto Sans SC',
|
||||
sans-serif;
|
||||
color: #0f172a;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
|
||||
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
|
||||
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-frame {
|
||||
width: min(100%, 1448px);
|
||||
aspect-ratio: 1448 / 1072;
|
||||
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
|
||||
border-radius: 2rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
|
||||
rgba(255, 255, 255, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
||||
0 38px 72px rgba(64, 52, 38, 0.14);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.08fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1.6rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow:
|
||||
0 20px 36px rgba(72, 58, 44, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(70, 58, 46, 0.08);
|
||||
}
|
||||
|
||||
.calendar-card,
|
||||
.weather-card {
|
||||
padding: clamp(1.4rem, 1.2vw + 1rem, 2rem);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.calendar-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
.calendar-card__day {
|
||||
font-family:
|
||||
'Iowan Old Style',
|
||||
'Baskerville',
|
||||
serif;
|
||||
font-size: clamp(4.4rem, 5vw, 7rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.08em;
|
||||
color: #101010;
|
||||
}
|
||||
|
||||
.calendar-card__meta {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.calendar-card__date-line,
|
||||
.calendar-card__lunar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem);
|
||||
}
|
||||
|
||||
.calendar-card__weekday {
|
||||
color: #556274;
|
||||
}
|
||||
|
||||
.calendar-card__lunar {
|
||||
color: #5f4b32;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-card__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.calendar-card__badges--inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.calendar-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.6rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-card__badge--holiday {
|
||||
color: #2f2b26;
|
||||
background: #ece5db;
|
||||
}
|
||||
|
||||
.calendar-card__badge--workday {
|
||||
color: #68593f;
|
||||
background: #f2eadb;
|
||||
}
|
||||
|
||||
.calendar-card__badge--festival {
|
||||
color: #4f4436;
|
||||
background: #f5f0e8;
|
||||
}
|
||||
|
||||
.calendar-card__badge--term {
|
||||
color: #3a4a4a;
|
||||
background: #e9efef;
|
||||
}
|
||||
|
||||
.calendar-card__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
gap: 0.35rem 0.3rem;
|
||||
align-content: start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-card__week-label {
|
||||
text-align: center;
|
||||
color: #798395;
|
||||
font-size: 0.92rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.calendar-card__cell {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
padding: 0.2rem 0;
|
||||
border-radius: 0.8rem;
|
||||
color: #304153;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.calendar-card__cell--muted {
|
||||
color: #bac4d2;
|
||||
}
|
||||
|
||||
.calendar-card__cell--today {
|
||||
background: linear-gradient(180deg, #303640 0%, #1f252d 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24);
|
||||
}
|
||||
|
||||
.calendar-card__cell--holiday .calendar-card__solar,
|
||||
.calendar-card__cell--holiday .calendar-card__lunar-day {
|
||||
color: #6a4a1c;
|
||||
}
|
||||
|
||||
.calendar-card__solar {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.calendar-card__lunar-day {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.2;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.weather-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.weather-card__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.weather-card__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 1vw + 1rem, 2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weather-card__subtitle {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #778396;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.weather-card__hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.05rem 1.2rem;
|
||||
border-radius: 1.2rem;
|
||||
background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%);
|
||||
}
|
||||
|
||||
.weather-card__hero--placeholder {
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.weather-card__hero-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.weather-card__temperature {
|
||||
font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem);
|
||||
line-height: 1;
|
||||
font-family:
|
||||
'Iowan Old Style',
|
||||
'Baskerville',
|
||||
serif;
|
||||
}
|
||||
|
||||
.weather-card__condition {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 1.15rem;
|
||||
color: #243041;
|
||||
}
|
||||
|
||||
.weather-card__facts {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.weather-card__fact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.weather-card__forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.forecast-pill {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.8rem 0.55rem;
|
||||
border-radius: 1rem;
|
||||
background: #f7f4ef;
|
||||
border: 1px solid rgba(82, 70, 56, 0.08);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.forecast-pill__label {
|
||||
margin: 0;
|
||||
color: #556274;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.forecast-pill__temp {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.forecast-pill__temp span {
|
||||
margin-left: 0.15rem;
|
||||
color: #7d8798;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.weather-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.85rem 0.9rem 0.9rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.metric-pill__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.metric-pill__value {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric--sunrise {
|
||||
background: linear-gradient(150deg, #f3eee5, #ebe3d6);
|
||||
color: #5c4631;
|
||||
}
|
||||
|
||||
.metric--sunset {
|
||||
background: linear-gradient(150deg, #efebe6, #e5dfd6);
|
||||
color: #4f463d;
|
||||
}
|
||||
|
||||
.metric--air {
|
||||
background: linear-gradient(150deg, #eef0eb, #e3e7de);
|
||||
color: #354338;
|
||||
}
|
||||
|
||||
.metric--visibility {
|
||||
background: linear-gradient(150deg, #eef1f0, #e2e5e4);
|
||||
color: #3b4a4d;
|
||||
}
|
||||
|
||||
.quote-card {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
padding: clamp(1.5rem, 1.2vw + 1rem, 2rem);
|
||||
background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94));
|
||||
}
|
||||
|
||||
.quote-card__content {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
line-height: 1.65;
|
||||
font-family:
|
||||
'Iowan Old Style',
|
||||
'Baskerville',
|
||||
'Noto Serif SC',
|
||||
serif;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.page-shell {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-frame {
|
||||
aspect-ratio: auto;
|
||||
width: min(100%, 1448px);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.quote-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.weather-card__forecast,
|
||||
.weather-card__metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.calendar-card__header,
|
||||
.weather-card__heading,
|
||||
.weather-card__hero {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.calendar-card__header {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.weather-card__forecast,
|
||||
.weather-card__metrics {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.quote-card__content {
|
||||
font-size: 1.45rem !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user