update at 2026-03-15 15:58:14
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs",
|
||||
"export:background": "sh scripts/export-kindle-background.sh",
|
||||
"manifest": "node scripts/generate-dashboard-manifest.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
23
calendar/scripts/export-kindle-background.sh
Normal file
23
calendar/scripts/export-kindle-background.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
CALENDAR_DIR="$ROOT_DIR/calendar"
|
||||
DIST_DIR="$CALENDAR_DIR/dist"
|
||||
PORT=${PORT:-4173}
|
||||
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
|
||||
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
|
||||
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}
|
||||
|
||||
cd "$CALENDAR_DIR"
|
||||
npm run build >/dev/null
|
||||
|
||||
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
sleep 1
|
||||
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
|
||||
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
|
||||
|
||||
cat "$OUT_REGION"
|
||||
186
calendar/scripts/export-kindle-background.swift
Normal file
186
calendar/scripts/export-kindle-background.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
enum ExportError: Error, CustomStringConvertible {
|
||||
case invalidArguments
|
||||
case snapshotFailed
|
||||
case pngEncodingFailed(String)
|
||||
case clockRegionMissing
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidArguments:
|
||||
return "用法:export-kindle-background.swift <url> <png-output> <region-output>"
|
||||
case .snapshotFailed:
|
||||
return "网页截图失败"
|
||||
case let .pngEncodingFailed(path):
|
||||
return "PNG 编码失败:\(path)"
|
||||
case .clockRegionMissing:
|
||||
return "页面里没有找到 data-clock-region 标记"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
private let url: URL
|
||||
private let pngOutputURL: URL
|
||||
private let regionOutputURL: URL
|
||||
private let completion: (Result<Void, Error>) -> Void
|
||||
private let targetSize = CGSize(width: 1024, height: 600)
|
||||
|
||||
private lazy var window: NSWindow = {
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(origin: .zero, size: targetSize),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.backgroundColor = .white
|
||||
return window
|
||||
}()
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
let config = WKWebViewConfiguration()
|
||||
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
|
||||
view.navigationDelegate = self
|
||||
return view
|
||||
}()
|
||||
|
||||
init(url: URL, pngOutputURL: URL, regionOutputURL: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
self.url = url
|
||||
self.pngOutputURL = pngOutputURL
|
||||
self.regionOutputURL = regionOutputURL
|
||||
self.completion = completion
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start() {
|
||||
window.contentView = webView
|
||||
window.orderBack(nil)
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||||
// 等页面数据与图片稳定下来,避免只截到骨架。
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
|
||||
self.capture()
|
||||
}
|
||||
}
|
||||
|
||||
private func capture() {
|
||||
let regionScript = """
|
||||
(() => {
|
||||
const node = document.querySelector('[data-clock-region="true"]');
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
webView.evaluateJavaScript(regionScript) { value, error in
|
||||
if let error {
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let region = value as? [String: NSNumber] else {
|
||||
self.finish(.failure(ExportError.clockRegionMissing))
|
||||
return
|
||||
}
|
||||
|
||||
let snapshotConfig = WKSnapshotConfiguration()
|
||||
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
|
||||
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
|
||||
|
||||
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
|
||||
if let snapshotError {
|
||||
self.finish(.failure(snapshotError))
|
||||
return
|
||||
}
|
||||
|
||||
guard let image else {
|
||||
self.finish(.failure(ExportError.snapshotFailed))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try self.savePNG(image: image, to: self.pngOutputURL)
|
||||
try self.saveRegion(region: region)
|
||||
self.finish(.success(()))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func savePNG(image: NSImage, to url: URL) throws {
|
||||
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
|
||||
normalizedImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
||||
normalizedImage.unlockFocus()
|
||||
|
||||
guard
|
||||
let tiffRepresentation = normalizedImage.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw ExportError.pngEncodingFailed(url.path)
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try pngData.write(to: url)
|
||||
}
|
||||
|
||||
private func saveRegion(region: [String: NSNumber]) throws {
|
||||
let jsonObject: [String: Int] = [
|
||||
"x": region["x"]?.intValue ?? 0,
|
||||
"y": region["y"]?.intValue ?? 0,
|
||||
"width": region["width"]?.intValue ?? 0,
|
||||
"height": region["height"]?.intValue ?? 0,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: regionOutputURL)
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<Void, Error>) {
|
||||
completion(result)
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
guard CommandLine.arguments.count >= 4 else {
|
||||
fputs("\(ExportError.invalidArguments)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let url = URL(string: CommandLine.arguments[1])!
|
||||
let pngOutputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||
let regionOutputURL = URL(fileURLWithPath: CommandLine.arguments[3])
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.prohibited)
|
||||
|
||||
let exporter = SnapshotExporter(url: url, pngOutputURL: pngOutputURL, regionOutputURL: regionOutputURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
print("Exported \(pngOutputURL.path)")
|
||||
print("Region saved to \(regionOutputURL.path)")
|
||||
case let .failure(error):
|
||||
fputs("\(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
exporter.start()
|
||||
app.run()
|
||||
50
calendar/scripts/generate-dashboard-manifest.mjs
Normal file
50
calendar/scripts/generate-dashboard-manifest.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(currentDir, '../dist');
|
||||
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
|
||||
const clockRegionPath = path.join(distDir, 'clock-region.json');
|
||||
|
||||
const defaultClockRegion = {
|
||||
x: 313,
|
||||
y: 0,
|
||||
width: 220,
|
||||
height: 220,
|
||||
};
|
||||
|
||||
const clockRegion = fs.existsSync(clockRegionPath)
|
||||
? {
|
||||
...defaultClockRegion,
|
||||
...JSON.parse(fs.readFileSync(clockRegionPath, 'utf8')),
|
||||
}
|
||||
: defaultClockRegion;
|
||||
|
||||
const manifest = {
|
||||
background: {
|
||||
path: 'kindlebg.png',
|
||||
url: 'https://shell.biboer.cn:20001/kindlebg.png',
|
||||
updatedAt: new Date().toISOString(),
|
||||
refreshIntervalMinutes: 120,
|
||||
},
|
||||
clockRegion,
|
||||
clockFace: {
|
||||
path: 'assets/clock-face.png',
|
||||
managedOnKindle: true,
|
||||
designWidth: 220,
|
||||
designHeight: 220,
|
||||
},
|
||||
clockHands: {
|
||||
hourPattern: 'assets/hour-hand/%03d.png',
|
||||
minutePattern: 'assets/minute-hand/%02d.png',
|
||||
refreshIntervalMinutes: 1,
|
||||
networkRequired: false,
|
||||
anchorMode: 'baked-into-patch',
|
||||
scaleWithClockFace: false,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
console.log(`Wrote ${manifestPath}`);
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import AnalogClock from '@/components/AnalogClock.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 { resolveDashboardMode } from '@/lib/dashboard-mode';
|
||||
import { getQuoteForDate } from '@/lib/quotes';
|
||||
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
|
||||
|
||||
const now = ref(new Date());
|
||||
const mode = ref(resolveDashboardMode(window.location.search));
|
||||
const location = ref<LocationCoordinates>({
|
||||
latitude: 31.2304,
|
||||
longitude: 121.4737,
|
||||
@@ -22,6 +25,7 @@ let weatherTimer = 0;
|
||||
|
||||
const calendarModel = computed(() => buildCalendarModel(now.value));
|
||||
const quoteEntry = computed(() => getQuoteForDate(now.value));
|
||||
const isClockFaceMode = computed(() => mode.value === 'clock-face');
|
||||
|
||||
async function refreshWeather() {
|
||||
weatherStatus.value = 'loading';
|
||||
@@ -35,31 +39,46 @@ async function refreshWeather() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncMode() {
|
||||
mode.value = resolveDashboardMode(window.location.search);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
location.value = await resolveLocation();
|
||||
await refreshWeather();
|
||||
window.addEventListener('popstate', syncMode);
|
||||
|
||||
if (!isClockFaceMode.value) {
|
||||
location.value = await resolveLocation();
|
||||
await refreshWeather();
|
||||
}
|
||||
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date();
|
||||
}, 30 * 1000);
|
||||
|
||||
// 天气不需要秒级刷新,30 分钟一次足够。
|
||||
weatherTimer = window.setInterval(() => {
|
||||
void refreshWeather();
|
||||
}, 30 * 60 * 1000);
|
||||
if (!isClockFaceMode.value) {
|
||||
// 天气数据属于低频区块,只需要半小时刷新一次。
|
||||
weatherTimer = window.setInterval(() => {
|
||||
void refreshWeather();
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(clockTimer);
|
||||
window.clearInterval(weatherTimer);
|
||||
window.removeEventListener('popstate', syncMode);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-shell">
|
||||
<div class="dashboard-frame">
|
||||
<main :class="['page-shell', `page-shell--${mode}`]">
|
||||
<section v-if="isClockFaceMode" class="clock-face-stage">
|
||||
<AnalogClock :date="now" mode="clock-face" :size="220" />
|
||||
</section>
|
||||
|
||||
<div v-else class="dashboard-frame">
|
||||
<div class="dashboard-grid">
|
||||
<CalendarCard :model="calendarModel" />
|
||||
<CalendarCard :model="calendarModel" :date="now" :mode="mode" />
|
||||
<WeatherCard
|
||||
:weather="weather"
|
||||
:status="weatherStatus"
|
||||
|
||||
126
calendar/src/components/AnalogClock.vue
Normal file
126
calendar/src/components/AnalogClock.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { DashboardMode } from '@/lib/dashboard-mode';
|
||||
import {
|
||||
buildClockState,
|
||||
CLOCK_FACE_ASSET,
|
||||
CLOCK_FACE_SOURCE_SIZE,
|
||||
CLOCK_RENDER_SIZE,
|
||||
HOUR_HAND_SPEC,
|
||||
MINUTE_HAND_SPEC,
|
||||
type ClockHandSpec,
|
||||
} from '@/lib/clock';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
date: Date;
|
||||
mode: DashboardMode;
|
||||
size?: number;
|
||||
}>(),
|
||||
{
|
||||
size: CLOCK_RENDER_SIZE,
|
||||
},
|
||||
);
|
||||
|
||||
const clockState = computed(() => buildClockState(props.date));
|
||||
const scale = computed(() => props.size / CLOCK_FACE_SOURCE_SIZE);
|
||||
|
||||
function buildHandStyle(spec: ClockHandSpec, angle: number) {
|
||||
const scaledWidth = spec.sourceWidth * scale.value;
|
||||
const scaledHeight = spec.sourceHeight * scale.value;
|
||||
const left = props.size / 2 - spec.pivotX * scale.value;
|
||||
const top = props.size / 2 - spec.pivotY * scale.value;
|
||||
|
||||
return {
|
||||
width: `${scaledWidth}px`,
|
||||
height: `${scaledHeight}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
transformOrigin: `${spec.pivotX * scale.value}px ${spec.pivotY * scale.value}px`,
|
||||
transform: `rotate(${angle}deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
const hourHandStyle = computed(() => buildHandStyle(HOUR_HAND_SPEC, clockState.value.hourAngle));
|
||||
const minuteHandStyle = computed(() => buildHandStyle(MINUTE_HAND_SPEC, clockState.value.minuteAngle));
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="analog-clock"
|
||||
:class="`analog-clock--${mode}`"
|
||||
:style="stageStyle"
|
||||
data-clock-region="true"
|
||||
>
|
||||
<div v-if="mode === 'background'" class="analog-clock__placeholder" />
|
||||
<template v-else>
|
||||
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
|
||||
<img
|
||||
v-if="mode === 'full'"
|
||||
class="analog-clock__hand analog-clock__hand--hour"
|
||||
:src="HOUR_HAND_SPEC.src"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
:style="hourHandStyle"
|
||||
/>
|
||||
<img
|
||||
v-if="mode === 'full'"
|
||||
class="analog-clock__hand analog-clock__hand--minute"
|
||||
:src="MINUTE_HAND_SPEC.src"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
:style="minuteHandStyle"
|
||||
/>
|
||||
<span v-if="mode === 'full'" class="analog-clock__sr">{{ clockState.digitalLabel }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analog-clock {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.analog-clock__face,
|
||||
.analog-clock__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.analog-clock__face {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.analog-clock__placeholder {
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.94), rgba(255, 250, 246, 0.78) 68%, rgba(239, 226, 221, 0.52));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(214, 196, 187, 0.35),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.analog-clock__hand {
|
||||
position: absolute;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.analog-clock__sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import AnalogClock from '@/components/AnalogClock.vue';
|
||||
import type { CalendarModel } from '@/lib/calendar';
|
||||
import type { DashboardMode } from '@/lib/dashboard-mode';
|
||||
|
||||
const props = defineProps<{
|
||||
model: CalendarModel;
|
||||
date: Date;
|
||||
mode: DashboardMode;
|
||||
}>();
|
||||
|
||||
const weeks = computed(() => {
|
||||
@@ -16,55 +20,285 @@ const weeks = computed(() => {
|
||||
|
||||
return chunks;
|
||||
});
|
||||
|
||||
function subLabelTone(cell: CalendarModel['cells'][number]) {
|
||||
return cell.badges[0]?.tone ?? 'lunar';
|
||||
}
|
||||
</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 class="calendar-card__hero">
|
||||
<div class="calendar-card__headline">
|
||||
<div class="calendar-card__day">{{ model.largeDay }}</div>
|
||||
<div class="calendar-card__headline-copy">
|
||||
<p class="calendar-card__lunar-day">{{ model.lunarDayLabel }}</p>
|
||||
<p class="calendar-card__weekday">{{ model.weekdayLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnalogClock :date="date" :mode="mode" :size="220" />
|
||||
</div>
|
||||
|
||||
<div class="calendar-card__grid">
|
||||
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
|
||||
{{ label }}
|
||||
<div class="calendar-card__panel">
|
||||
<div class="calendar-card__panel-header">
|
||||
<div>
|
||||
<p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p>
|
||||
<p class="calendar-card__panel-subtitle">{{ model.lunarYearLabel }}</p>
|
||||
</div>
|
||||
<div v-if="model.summaryBadges.length" class="calendar-card__badges">
|
||||
<span
|
||||
v-for="badge in model.summaryBadges"
|
||||
:key="badge.label"
|
||||
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</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 class="calendar-card__grid">
|
||||
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="calendar-card__cell-copy">
|
||||
<span class="calendar-card__solar">{{ cell.day }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'calendar-card__sub',
|
||||
`calendar-card__sub--${subLabelTone(cell)}`,
|
||||
]"
|
||||
>
|
||||
{{ cell.subLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
gap: 0.95rem;
|
||||
padding: 1.28rem 1.32rem 1.12rem;
|
||||
}
|
||||
|
||||
.calendar-card__hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.calendar-card__headline {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.calendar-card__headline-copy {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.calendar-card__day {
|
||||
font-family:
|
||||
'Iowan Old Style',
|
||||
'Baskerville',
|
||||
serif;
|
||||
font-size: 5.7rem;
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.08em;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.calendar-card__lunar-day,
|
||||
.calendar-card__weekday {
|
||||
margin: 0;
|
||||
font-size: 1.48rem;
|
||||
line-height: 1.02;
|
||||
color: #232323;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-card__weekday {
|
||||
font-size: 1.54rem;
|
||||
}
|
||||
|
||||
.calendar-card__panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0.45rem;
|
||||
min-height: 0;
|
||||
padding: 0.74rem 0.9rem 0.84rem;
|
||||
border-radius: 1.1rem;
|
||||
background: linear-gradient(180deg, rgba(250, 237, 238, 0.58), rgba(247, 240, 239, 0.86));
|
||||
}
|
||||
|
||||
.calendar-card__panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-card__panel-title,
|
||||
.calendar-card__panel-subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-card__panel-title {
|
||||
font-size: 0.9rem;
|
||||
color: #4c4c4c;
|
||||
}
|
||||
|
||||
.calendar-card__panel-subtitle {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.76rem;
|
||||
color: #7a6a61;
|
||||
}
|
||||
|
||||
.calendar-card__badges {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.calendar-card__badge {
|
||||
padding: 0.12rem 0.42rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.2;
|
||||
border: 1px solid currentColor;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.calendar-card__badge--holiday {
|
||||
color: #8b4a20;
|
||||
}
|
||||
|
||||
.calendar-card__badge--workday {
|
||||
color: #7c5d2f;
|
||||
}
|
||||
|
||||
.calendar-card__badge--festival {
|
||||
color: #67503d;
|
||||
}
|
||||
|
||||
.calendar-card__badge--term {
|
||||
color: #4a6b6f;
|
||||
}
|
||||
|
||||
.calendar-card__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
column-gap: 0.16rem;
|
||||
row-gap: 0.04rem;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.calendar-card__week-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-bottom: 0.08rem;
|
||||
font-size: 0.7rem;
|
||||
color: #7b7b7b;
|
||||
}
|
||||
|
||||
.calendar-card__cell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
padding: 0.08rem 0 0.1rem;
|
||||
border-radius: 0.78rem;
|
||||
color: #3f454e;
|
||||
}
|
||||
|
||||
.calendar-card__cell-copy {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.04rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-card__cell--muted {
|
||||
color: #b7b0b3;
|
||||
}
|
||||
|
||||
.calendar-card__cell--today {
|
||||
background: #171717;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-card__cell--holiday .calendar-card__solar {
|
||||
color: #b76114;
|
||||
}
|
||||
|
||||
.calendar-card__solar {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.calendar-card__sub {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.42rem;
|
||||
line-height: 1;
|
||||
color: #8b8b8b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-card__sub--holiday {
|
||||
color: #b76114;
|
||||
}
|
||||
|
||||
.calendar-card__sub--workday {
|
||||
color: #7c5d2f;
|
||||
}
|
||||
|
||||
.calendar-card__sub--festival {
|
||||
color: #67503d;
|
||||
}
|
||||
|
||||
.calendar-card__sub--term {
|
||||
color: #4a6b6f;
|
||||
}
|
||||
|
||||
.calendar-card__sub--lunar {
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
.calendar-card__cell--muted .calendar-card__sub {
|
||||
color: #c3bbbe;
|
||||
}
|
||||
|
||||
.calendar-card__cell--today .calendar-card__sub {
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { QUOTE_ICON_ASSET } from '@/lib/icon-assets';
|
||||
|
||||
const props = defineProps<{
|
||||
quote: string;
|
||||
}>();
|
||||
@@ -8,20 +10,62 @@ const props = defineProps<{
|
||||
const quoteFontSize = computed(() => {
|
||||
const length = props.quote.length;
|
||||
|
||||
if (length > 140) {
|
||||
return '1.75rem';
|
||||
if (length > 120) {
|
||||
return '1.05rem';
|
||||
}
|
||||
|
||||
if (length > 100) {
|
||||
return '1.95rem';
|
||||
if (length > 80) {
|
||||
return '1.22rem';
|
||||
}
|
||||
|
||||
return '2.2rem';
|
||||
if (length > 48) {
|
||||
return '1.4rem';
|
||||
}
|
||||
|
||||
return '1.6rem';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card quote-card">
|
||||
<div class="quote-card__header">
|
||||
<img class="quote-card__icon" :src="QUOTE_ICON_ASSET" alt="" aria-hidden="true" />
|
||||
<span class="quote-card__title">每日鸡汤</span>
|
||||
</div>
|
||||
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quote-card {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
padding: 0.72rem 1.02rem;
|
||||
background: linear-gradient(180deg, rgba(255, 248, 230, 0.96), rgba(255, 252, 243, 0.94));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quote-card__header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: #c75d00;
|
||||
}
|
||||
|
||||
.quote-card__icon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
.quote-card__title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quote-card__content {
|
||||
margin: 0;
|
||||
line-height: 1.34;
|
||||
color: #292929;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,7 @@ function weatherKind(code: number) {
|
||||
}
|
||||
|
||||
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
|
||||
return 'rain';
|
||||
return [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
|
||||
}
|
||||
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code)) {
|
||||
@@ -42,7 +42,7 @@ function weatherKind(code: number) {
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
|
||||
const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []);
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (!props.weather) {
|
||||
@@ -53,14 +53,12 @@ const metrics = computed(() => {
|
||||
{
|
||||
label: '日出',
|
||||
value: props.weather.sunrise,
|
||||
accent: 'metric--sunrise',
|
||||
icon: 'sunrise',
|
||||
accent: 'metric-pill--sunrise',
|
||||
},
|
||||
{
|
||||
label: '日落',
|
||||
value: props.weather.sunset,
|
||||
accent: 'metric--sunset',
|
||||
icon: 'sunset',
|
||||
accent: 'metric-pill--sunset',
|
||||
},
|
||||
{
|
||||
label: '空气质量',
|
||||
@@ -68,14 +66,12 @@ const metrics = computed(() => {
|
||||
props.weather.aqi === null
|
||||
? '暂无'
|
||||
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
||||
accent: 'metric--air',
|
||||
icon: 'air',
|
||||
accent: 'metric-pill--air',
|
||||
},
|
||||
{
|
||||
label: '能见度',
|
||||
value: `${props.weather.visibilityKm} km`,
|
||||
accent: 'metric--visibility',
|
||||
icon: 'visibility',
|
||||
accent: 'metric-pill--visibility',
|
||||
},
|
||||
] as const;
|
||||
});
|
||||
@@ -119,11 +115,11 @@ function forecastKind(day: ForecastDay) {
|
||||
|
||||
<div class="weather-card__facts">
|
||||
<div class="weather-card__fact">
|
||||
<WeatherGlyph kind="humidity" />
|
||||
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
|
||||
<span>湿度 {{ weather.humidity }}%</span>
|
||||
</div>
|
||||
<div class="weather-card__fact">
|
||||
<WeatherGlyph kind="wind" />
|
||||
<span class="weather-card__fact-dot weather-card__fact-dot--wind" />
|
||||
<span>风速 {{ weather.windSpeed }} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +144,7 @@ function forecastKind(day: ForecastDay) {
|
||||
:class="['metric-pill', metric.accent]"
|
||||
>
|
||||
<div class="metric-pill__label">
|
||||
<WeatherGlyph :kind="metric.icon" />
|
||||
<span class="metric-pill__dot" />
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<p class="metric-pill__value">{{ metric.value }}</p>
|
||||
@@ -156,3 +152,213 @@ function forecastKind(day: ForecastDay) {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weather-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
height: 100%;
|
||||
gap: 0.65rem;
|
||||
padding: 1.05rem 1.1rem 0.92rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weather-card__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.weather-card__title,
|
||||
.weather-card__subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.weather-card__title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.weather-card__subtitle {
|
||||
margin-top: 0.14rem;
|
||||
font-size: 1.18rem;
|
||||
line-height: 1.08;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.weather-card__hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 0;
|
||||
padding: 0.88rem 0.94rem;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #dfeaf8, #d7e4f6);
|
||||
}
|
||||
|
||||
.weather-card__hero--placeholder {
|
||||
justify-content: center;
|
||||
min-height: 5.75rem;
|
||||
color: #617288;
|
||||
}
|
||||
|
||||
.weather-card__hero-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.92rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weather-card__temperature {
|
||||
font-size: 2.8rem;
|
||||
line-height: 0.94;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.weather-card__condition {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 1.36rem;
|
||||
line-height: 1.05;
|
||||
color: #2c3641;
|
||||
}
|
||||
|
||||
.weather-card__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.28rem;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weather-card__fact {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
color: #617288;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.06;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weather-card__fact-dot,
|
||||
.metric-pill__dot {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.weather-card__fact-dot--humidity {
|
||||
color: #5c84be;
|
||||
}
|
||||
|
||||
.weather-card__fact-dot--wind {
|
||||
color: #6c7e95;
|
||||
}
|
||||
|
||||
.weather-card__forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.46rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.forecast-pill {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 0.34rem;
|
||||
min-height: 0;
|
||||
padding: 0.68rem 0.28rem;
|
||||
border-radius: 1rem;
|
||||
background: #f8f7f6;
|
||||
}
|
||||
|
||||
.forecast-pill__label,
|
||||
.forecast-pill__temp {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forecast-pill__label {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: #5a5a5a;
|
||||
}
|
||||
|
||||
.forecast-pill__temp {
|
||||
font-size: 1.24rem;
|
||||
line-height: 1;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.forecast-pill__temp span {
|
||||
margin-left: 0.12rem;
|
||||
font-size: 1rem;
|
||||
color: #7a7a7a;
|
||||
}
|
||||
|
||||
.weather-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 0.46rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 0.3rem;
|
||||
min-height: 0;
|
||||
padding: 0.68rem 0.74rem;
|
||||
border-radius: 0.95rem;
|
||||
}
|
||||
|
||||
.metric-pill__label,
|
||||
.metric-pill__value {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metric-pill__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.metric-pill__value {
|
||||
font-size: 1.34rem;
|
||||
line-height: 1.02;
|
||||
color: #1c1c1c;
|
||||
}
|
||||
|
||||
.metric-pill--sunrise {
|
||||
background: #fff1dd;
|
||||
color: #cb6800;
|
||||
}
|
||||
|
||||
.metric-pill--sunset {
|
||||
background: #efe4ff;
|
||||
color: #8e42e8;
|
||||
}
|
||||
|
||||
.metric-pill--air {
|
||||
background: #dbf5df;
|
||||
color: #1f9451;
|
||||
}
|
||||
|
||||
.metric-pill--visibility {
|
||||
background: #d8f4fa;
|
||||
color: #17779d;
|
||||
}
|
||||
|
||||
.weather-card :deep(.glyph--large) {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,154 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type WeatherGlyphKind =
|
||||
| 'clear'
|
||||
| 'partly'
|
||||
| 'cloudy'
|
||||
| 'fog'
|
||||
| 'rain'
|
||||
| 'snow'
|
||||
| 'storm'
|
||||
| 'sunrise'
|
||||
| 'sunset'
|
||||
| 'air'
|
||||
| 'visibility'
|
||||
| 'humidity'
|
||||
| 'wind';
|
||||
import { weatherIconForKind, type WeatherIconKind } from '@/lib/icon-assets';
|
||||
|
||||
const props = defineProps<{
|
||||
kind: WeatherGlyphKind;
|
||||
kind: WeatherIconKind;
|
||||
large?: boolean;
|
||||
}>();
|
||||
|
||||
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
|
||||
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
|
||||
const source = computed(() => weatherIconForKind(props.kind));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
<img
|
||||
:class="['glyph', { 'glyph--large': props.large }]"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="strokeWidth"
|
||||
:src="source"
|
||||
alt=""
|
||||
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;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
overflow: visible;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.glyph--large {
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
10
calendar/src/env.d.ts
vendored
10
calendar/src/env.d.ts
vendored
@@ -9,3 +9,13 @@ declare module '*.md?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
51
calendar/src/lib/clock.ts
Normal file
51
calendar/src/lib/clock.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import clockFaceAsset from '../../../assets/clock-face.png';
|
||||
import hourHandAsset from '../../../assets/hour-hand.png';
|
||||
import minuteHandAsset from '../../../assets/minite-hand.png';
|
||||
|
||||
export interface ClockHandSpec {
|
||||
src: string;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
pivotX: number;
|
||||
pivotY: number;
|
||||
}
|
||||
|
||||
export interface ClockState {
|
||||
hourAngle: number;
|
||||
minuteAngle: number;
|
||||
digitalLabel: string;
|
||||
}
|
||||
|
||||
// 这组尺寸直接来自仓库里的 PNG 素材,Kindle 侧也复用同一套基准。
|
||||
export const CLOCK_FACE_SOURCE_SIZE = 431;
|
||||
export const CLOCK_RENDER_SIZE = 220;
|
||||
|
||||
// 锚点按 Figma annotation 的中心点拟合到现有 PNG,保证网页预览与 Kindle 贴图一致。
|
||||
export const HOUR_HAND_SPEC: ClockHandSpec = {
|
||||
src: hourHandAsset,
|
||||
sourceWidth: 32,
|
||||
sourceHeight: 205,
|
||||
pivotX: 13,
|
||||
pivotY: 138,
|
||||
};
|
||||
|
||||
export const MINUTE_HAND_SPEC: ClockHandSpec = {
|
||||
src: minuteHandAsset,
|
||||
sourceWidth: 32,
|
||||
sourceHeight: 288,
|
||||
pivotX: 15,
|
||||
pivotY: 203,
|
||||
};
|
||||
|
||||
export const CLOCK_FACE_ASSET = clockFaceAsset;
|
||||
|
||||
export function buildClockState(date: Date): ClockState {
|
||||
const hour = date.getHours() % 12;
|
||||
const minute = date.getMinutes();
|
||||
|
||||
return {
|
||||
hourAngle: hour * 30 + minute * 0.5,
|
||||
minuteAngle: minute * 6,
|
||||
digitalLabel: `${String(date.getHours()).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
|
||||
};
|
||||
}
|
||||
14
calendar/src/lib/dashboard-mode.ts
Normal file
14
calendar/src/lib/dashboard-mode.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type DashboardMode = 'full' | 'background' | 'clock-face';
|
||||
|
||||
const VALID_MODES = new Set<DashboardMode>(['full', 'background', 'clock-face']);
|
||||
|
||||
export function resolveDashboardMode(search: string): DashboardMode {
|
||||
const params = new URLSearchParams(search);
|
||||
const mode = params.get('mode');
|
||||
|
||||
if (mode && VALID_MODES.has(mode as DashboardMode)) {
|
||||
return mode as DashboardMode;
|
||||
}
|
||||
|
||||
return 'full';
|
||||
}
|
||||
39
calendar/src/lib/icon-assets.ts
Normal file
39
calendar/src/lib/icon-assets.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import bookIcon from '../../../assets/书摘.svg';
|
||||
import cloudyIcon from '../../../assets/多云.svg';
|
||||
import heavyRainIcon from '../../../assets/大雨.svg';
|
||||
import snowIcon from '../../../assets/大雪.svg';
|
||||
import lightRainIcon from '../../../assets/小雨.svg';
|
||||
import nightIcon from '../../../assets/晚上.svg';
|
||||
import clearIcon from '../../../assets/晴天.svg';
|
||||
import sleetIcon from '../../../assets/雨夹雪.svg';
|
||||
|
||||
export type WeatherIconKind =
|
||||
| 'clear'
|
||||
| 'partly'
|
||||
| 'cloudy'
|
||||
| 'fog'
|
||||
| 'rain'
|
||||
| 'heavy-rain'
|
||||
| 'snow'
|
||||
| 'storm'
|
||||
| 'sleet'
|
||||
| 'night';
|
||||
|
||||
const WEATHER_ICON_MAP: Record<WeatherIconKind, string> = {
|
||||
clear: clearIcon,
|
||||
partly: cloudyIcon,
|
||||
cloudy: cloudyIcon,
|
||||
fog: cloudyIcon,
|
||||
rain: lightRainIcon,
|
||||
'heavy-rain': heavyRainIcon,
|
||||
snow: snowIcon,
|
||||
storm: heavyRainIcon,
|
||||
sleet: sleetIcon,
|
||||
night: nightIcon,
|
||||
};
|
||||
|
||||
export function weatherIconForKind(kind: WeatherIconKind) {
|
||||
return WEATHER_ICON_MAP[kind];
|
||||
}
|
||||
|
||||
export const QUOTE_ICON_ASSET = bookIcon;
|
||||
@@ -4,11 +4,11 @@
|
||||
'PingFang SC',
|
||||
'Noto Sans SC',
|
||||
sans-serif;
|
||||
color: #0f172a;
|
||||
color: #111111;
|
||||
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%);
|
||||
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
|
||||
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
|
||||
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -30,419 +30,74 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.page-shell--clock-face {
|
||||
background: #f5f2ef;
|
||||
}
|
||||
|
||||
.dashboard-frame {
|
||||
width: min(100%, 1448px);
|
||||
aspect-ratio: 1448 / 1072;
|
||||
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
|
||||
border-radius: 2rem;
|
||||
width: 100%;
|
||||
aspect-ratio: 1024 / 600;
|
||||
padding: 1rem;
|
||||
border-radius: 1.9rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
|
||||
rgba(255, 255, 255, 0.14);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
|
||||
rgba(255, 255, 255, 0.16);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
||||
0 38px 72px rgba(64, 52, 38, 0.14);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.62),
|
||||
0 24px 46px rgba(99, 64, 66, 0.18);
|
||||
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);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
|
||||
grid-template-rows: minmax(0, 1fr) 72px;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1.6rem;
|
||||
min-height: 0;
|
||||
border-radius: 1.75rem;
|
||||
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);
|
||||
0 16px 34px rgba(92, 67, 60, 0.18),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(117, 80, 76, 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;
|
||||
.clock-face-stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
padding: 0.2rem 0;
|
||||
border-radius: 0.8rem;
|
||||
color: #304153;
|
||||
transition: background-color 150ms ease;
|
||||
width: min(100%, 320px);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.dashboard-frame {
|
||||
aspect-ratio: auto;
|
||||
width: min(100%, 1448px);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
grid-template-rows: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
.page-shell {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user