update at 2026-03-15 15:58:14
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"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">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import AnalogClock from '@/components/AnalogClock.vue';
|
||||||
import CalendarCard from '@/components/CalendarCard.vue';
|
import CalendarCard from '@/components/CalendarCard.vue';
|
||||||
import QuoteCard from '@/components/QuoteCard.vue';
|
import QuoteCard from '@/components/QuoteCard.vue';
|
||||||
import WeatherCard from '@/components/WeatherCard.vue';
|
import WeatherCard from '@/components/WeatherCard.vue';
|
||||||
import { buildCalendarModel } from '@/lib/calendar';
|
import { buildCalendarModel } from '@/lib/calendar';
|
||||||
|
import { resolveDashboardMode } from '@/lib/dashboard-mode';
|
||||||
import { getQuoteForDate } from '@/lib/quotes';
|
import { getQuoteForDate } from '@/lib/quotes';
|
||||||
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
|
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
|
||||||
|
|
||||||
const now = ref(new Date());
|
const now = ref(new Date());
|
||||||
|
const mode = ref(resolveDashboardMode(window.location.search));
|
||||||
const location = ref<LocationCoordinates>({
|
const location = ref<LocationCoordinates>({
|
||||||
latitude: 31.2304,
|
latitude: 31.2304,
|
||||||
longitude: 121.4737,
|
longitude: 121.4737,
|
||||||
@@ -22,6 +25,7 @@ let weatherTimer = 0;
|
|||||||
|
|
||||||
const calendarModel = computed(() => buildCalendarModel(now.value));
|
const calendarModel = computed(() => buildCalendarModel(now.value));
|
||||||
const quoteEntry = computed(() => getQuoteForDate(now.value));
|
const quoteEntry = computed(() => getQuoteForDate(now.value));
|
||||||
|
const isClockFaceMode = computed(() => mode.value === 'clock-face');
|
||||||
|
|
||||||
async function refreshWeather() {
|
async function refreshWeather() {
|
||||||
weatherStatus.value = 'loading';
|
weatherStatus.value = 'loading';
|
||||||
@@ -35,31 +39,46 @@ async function refreshWeather() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncMode() {
|
||||||
|
mode.value = resolveDashboardMode(window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('popstate', syncMode);
|
||||||
|
|
||||||
|
if (!isClockFaceMode.value) {
|
||||||
location.value = await resolveLocation();
|
location.value = await resolveLocation();
|
||||||
await refreshWeather();
|
await refreshWeather();
|
||||||
|
}
|
||||||
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = new Date();
|
now.value = new Date();
|
||||||
}, 30 * 1000);
|
}, 30 * 1000);
|
||||||
|
|
||||||
// 天气不需要秒级刷新,30 分钟一次足够。
|
if (!isClockFaceMode.value) {
|
||||||
|
// 天气数据属于低频区块,只需要半小时刷新一次。
|
||||||
weatherTimer = window.setInterval(() => {
|
weatherTimer = window.setInterval(() => {
|
||||||
void refreshWeather();
|
void refreshWeather();
|
||||||
}, 30 * 60 * 1000);
|
}, 30 * 60 * 1000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.clearInterval(clockTimer);
|
window.clearInterval(clockTimer);
|
||||||
window.clearInterval(weatherTimer);
|
window.clearInterval(weatherTimer);
|
||||||
|
window.removeEventListener('popstate', syncMode);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="page-shell">
|
<main :class="['page-shell', `page-shell--${mode}`]">
|
||||||
<div class="dashboard-frame">
|
<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">
|
<div class="dashboard-grid">
|
||||||
<CalendarCard :model="calendarModel" />
|
<CalendarCard :model="calendarModel" :date="now" :mode="mode" />
|
||||||
<WeatherCard
|
<WeatherCard
|
||||||
:weather="weather"
|
:weather="weather"
|
||||||
:status="weatherStatus"
|
: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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import AnalogClock from '@/components/AnalogClock.vue';
|
||||||
import type { CalendarModel } from '@/lib/calendar';
|
import type { CalendarModel } from '@/lib/calendar';
|
||||||
|
import type { DashboardMode } from '@/lib/dashboard-mode';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
model: CalendarModel;
|
model: CalendarModel;
|
||||||
|
date: Date;
|
||||||
|
mode: DashboardMode;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const weeks = computed(() => {
|
const weeks = computed(() => {
|
||||||
@@ -16,21 +20,32 @@ const weeks = computed(() => {
|
|||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function subLabelTone(cell: CalendarModel['cells'][number]) {
|
||||||
|
return cell.badges[0]?.tone ?? 'lunar';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="card calendar-card">
|
<section class="card calendar-card">
|
||||||
<div class="calendar-card__header">
|
<div class="calendar-card__hero">
|
||||||
|
<div class="calendar-card__headline">
|
||||||
<div class="calendar-card__day">{{ model.largeDay }}</div>
|
<div class="calendar-card__day">{{ model.largeDay }}</div>
|
||||||
<div class="calendar-card__meta">
|
<div class="calendar-card__headline-copy">
|
||||||
<p class="calendar-card__date-line">
|
<p class="calendar-card__lunar-day">{{ model.lunarDayLabel }}</p>
|
||||||
<span>{{ model.gregorianLabel }}</span>
|
<p class="calendar-card__weekday">{{ model.weekdayLabel }}</p>
|
||||||
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<p class="calendar-card__lunar">
|
<AnalogClock :date="date" :mode="mode" :size="220" />
|
||||||
<span>{{ model.lunarYearLabel }}</span>
|
</div>
|
||||||
<span>{{ model.lunarDayLabel }}</span>
|
|
||||||
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
|
<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
|
<span
|
||||||
v-for="badge in model.summaryBadges"
|
v-for="badge in model.summaryBadges"
|
||||||
:key="badge.label"
|
:key="badge.label"
|
||||||
@@ -38,8 +53,6 @@ const weeks = computed(() => {
|
|||||||
>
|
>
|
||||||
{{ badge.label }}
|
{{ badge.label }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,10 +74,231 @@ const weeks = computed(() => {
|
|||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
<div class="calendar-card__cell-copy">
|
||||||
<span class="calendar-card__solar">{{ cell.day }}</span>
|
<span class="calendar-card__solar">{{ cell.day }}</span>
|
||||||
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
|
<span
|
||||||
|
:class="[
|
||||||
|
'calendar-card__sub',
|
||||||
|
`calendar-card__sub--${subLabelTone(cell)}`,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ cell.subLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { QUOTE_ICON_ASSET } from '@/lib/icon-assets';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
quote: string;
|
quote: string;
|
||||||
}>();
|
}>();
|
||||||
@@ -8,20 +10,62 @@ const props = defineProps<{
|
|||||||
const quoteFontSize = computed(() => {
|
const quoteFontSize = computed(() => {
|
||||||
const length = props.quote.length;
|
const length = props.quote.length;
|
||||||
|
|
||||||
if (length > 140) {
|
if (length > 120) {
|
||||||
return '1.75rem';
|
return '1.05rem';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (length > 100) {
|
if (length > 80) {
|
||||||
return '1.95rem';
|
return '1.22rem';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '2.2rem';
|
if (length > 48) {
|
||||||
|
return '1.4rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '1.6rem';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="card quote-card">
|
<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>
|
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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)) {
|
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)) {
|
if ([71, 73, 75, 77, 85, 86].includes(code)) {
|
||||||
@@ -42,7 +42,7 @@ function weatherKind(code: number) {
|
|||||||
return 'cloudy';
|
return 'cloudy';
|
||||||
}
|
}
|
||||||
|
|
||||||
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
|
const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []);
|
||||||
|
|
||||||
const metrics = computed(() => {
|
const metrics = computed(() => {
|
||||||
if (!props.weather) {
|
if (!props.weather) {
|
||||||
@@ -53,14 +53,12 @@ const metrics = computed(() => {
|
|||||||
{
|
{
|
||||||
label: '日出',
|
label: '日出',
|
||||||
value: props.weather.sunrise,
|
value: props.weather.sunrise,
|
||||||
accent: 'metric--sunrise',
|
accent: 'metric-pill--sunrise',
|
||||||
icon: 'sunrise',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '日落',
|
label: '日落',
|
||||||
value: props.weather.sunset,
|
value: props.weather.sunset,
|
||||||
accent: 'metric--sunset',
|
accent: 'metric-pill--sunset',
|
||||||
icon: 'sunset',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '空气质量',
|
label: '空气质量',
|
||||||
@@ -68,14 +66,12 @@ const metrics = computed(() => {
|
|||||||
props.weather.aqi === null
|
props.weather.aqi === null
|
||||||
? '暂无'
|
? '暂无'
|
||||||
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
||||||
accent: 'metric--air',
|
accent: 'metric-pill--air',
|
||||||
icon: 'air',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '能见度',
|
label: '能见度',
|
||||||
value: `${props.weather.visibilityKm} km`,
|
value: `${props.weather.visibilityKm} km`,
|
||||||
accent: 'metric--visibility',
|
accent: 'metric-pill--visibility',
|
||||||
icon: 'visibility',
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
});
|
});
|
||||||
@@ -119,11 +115,11 @@ function forecastKind(day: ForecastDay) {
|
|||||||
|
|
||||||
<div class="weather-card__facts">
|
<div class="weather-card__facts">
|
||||||
<div class="weather-card__fact">
|
<div class="weather-card__fact">
|
||||||
<WeatherGlyph kind="humidity" />
|
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
|
||||||
<span>湿度 {{ weather.humidity }}%</span>
|
<span>湿度 {{ weather.humidity }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="weather-card__fact">
|
<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>
|
<span>风速 {{ weather.windSpeed }} km/h</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +144,7 @@ function forecastKind(day: ForecastDay) {
|
|||||||
:class="['metric-pill', metric.accent]"
|
:class="['metric-pill', metric.accent]"
|
||||||
>
|
>
|
||||||
<div class="metric-pill__label">
|
<div class="metric-pill__label">
|
||||||
<WeatherGlyph :kind="metric.icon" />
|
<span class="metric-pill__dot" />
|
||||||
<span>{{ metric.label }}</span>
|
<span>{{ metric.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="metric-pill__value">{{ metric.value }}</p>
|
<p class="metric-pill__value">{{ metric.value }}</p>
|
||||||
@@ -156,3 +152,213 @@ function forecastKind(day: ForecastDay) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
type WeatherGlyphKind =
|
import { weatherIconForKind, type WeatherIconKind } from '@/lib/icon-assets';
|
||||||
| 'clear'
|
|
||||||
| 'partly'
|
|
||||||
| 'cloudy'
|
|
||||||
| 'fog'
|
|
||||||
| 'rain'
|
|
||||||
| 'snow'
|
|
||||||
| 'storm'
|
|
||||||
| 'sunrise'
|
|
||||||
| 'sunset'
|
|
||||||
| 'air'
|
|
||||||
| 'visibility'
|
|
||||||
| 'humidity'
|
|
||||||
| 'wind';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
kind: WeatherGlyphKind;
|
kind: WeatherIconKind;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
|
const source = computed(() => weatherIconForKind(props.kind));
|
||||||
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg
|
<img
|
||||||
:class="['glyph', { 'glyph--large': props.large }]"
|
:class="['glyph', { 'glyph--large': props.large }]"
|
||||||
viewBox="0 0 48 48"
|
:src="source"
|
||||||
fill="none"
|
alt=""
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
:stroke-width="strokeWidth"
|
|
||||||
aria-hidden="true"
|
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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.glyph {
|
.glyph {
|
||||||
width: 1.12rem;
|
width: 1.25rem;
|
||||||
height: 1.12rem;
|
height: 1.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: currentColor;
|
object-fit: contain;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glyph--large {
|
.glyph--large {
|
||||||
width: 2.7rem;
|
width: 2.5rem;
|
||||||
height: 2.7rem;
|
height: 2.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</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;
|
const content: string;
|
||||||
export default content;
|
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',
|
'PingFang SC',
|
||||||
'Noto Sans SC',
|
'Noto Sans SC',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
color: #0f172a;
|
color: #111111;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
|
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
|
||||||
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
|
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
|
||||||
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
|
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -30,419 +30,74 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1.5rem;
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell--clock-face {
|
||||||
|
background: #f5f2ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-frame {
|
.dashboard-frame {
|
||||||
width: min(100%, 1448px);
|
width: 100%;
|
||||||
aspect-ratio: 1448 / 1072;
|
aspect-ratio: 1024 / 600;
|
||||||
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
|
padding: 1rem;
|
||||||
border-radius: 2rem;
|
border-radius: 1.9rem;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
|
||||||
rgba(255, 255, 255, 0.14);
|
rgba(255, 255, 255, 0.16);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
inset 0 1px 0 rgba(255, 255, 255, 0.62),
|
||||||
0 38px 72px rgba(64, 52, 38, 0.14);
|
0 24px 46px rgba(99, 64, 66, 0.18);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.08fr 1fr;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: minmax(0, 1fr) 72px;
|
||||||
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
|
gap: 1rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: 1.6rem;
|
min-height: 0;
|
||||||
|
border-radius: 1.75rem;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 255, 255, 0.94);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 36px rgba(72, 58, 44, 0.12),
|
0 16px 34px rgba(92, 67, 60, 0.18),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
border: 1px solid rgba(70, 58, 46, 0.08);
|
border: 1px solid rgba(117, 80, 76, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card,
|
.clock-face-stage {
|
||||||
.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;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
width: min(100%, 320px);
|
||||||
padding: 0.2rem 0;
|
aspect-ratio: 1;
|
||||||
border-radius: 0.8rem;
|
|
||||||
color: #304153;
|
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__cell--muted {
|
@media (max-width: 860px) {
|
||||||
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 {
|
.dashboard-frame {
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
width: min(100%, 1448px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-card {
|
.page-shell {
|
||||||
grid-column: auto;
|
align-items: stretch;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ Turns out old Kindle devices make great, energy efficient dashboards :-)
|
|||||||
|
|
||||||
This repo only contains the code that runs on the Kindle. It periodically fetches an image to be displayed on the screen and suspends the device to RAM (which is very power efficient) until the next screen update.
|
This repo only contains the code that runs on the Kindle. It periodically fetches an image to be displayed on the screen and suspends the device to RAM (which is very power efficient) until the next screen update.
|
||||||
|
|
||||||
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetchd via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
|
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetched via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
|
||||||
|
|
||||||
|
In the current Voyage layered-clock setup, the Kindle only fetches a low-frequency `kindlebg.png` background. The clock face and hand patches are synced as local assets and re-drawn on-device once per minute without network access.
|
||||||
|
|
||||||
In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code.
|
In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code.
|
||||||
|
|
||||||
@@ -55,8 +57,9 @@ If you're connected over SSH and only want a one-off foreground session, you can
|
|||||||
|
|
||||||
## How this works
|
## How this works
|
||||||
|
|
||||||
* This code periodically downloads a dashboard image from an HTTP(s) endpoint.
|
* This code periodically downloads a dashboard background image from an HTTP(s) endpoint.
|
||||||
* The interval can be configured in `dist/local/env.sh` using a cron expression.
|
* The interval can be configured in `dist/local/env.sh` using a cron expression.
|
||||||
|
* When layered clock assets are present, the Kindle re-renders the clock patch locally every minute.
|
||||||
* During the update intervals the device is suspended to RAM to save power.
|
* During the update intervals the device is suspended to RAM to save power.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -64,6 +67,7 @@ If you're connected over SSH and only want a one-off foreground session, you can
|
|||||||
* The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library).
|
* The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library).
|
||||||
* For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md).
|
* For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md).
|
||||||
* For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md).
|
* For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md).
|
||||||
|
* For the layered clock split and runtime model, see [docs/layered-clock-plan.zh.md](./docs/layered-clock-plan.zh.md).
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ DEBUG=${DEBUG:-false}
|
|||||||
[ "$DEBUG" = true ] && set -x
|
[ "$DEBUG" = true ] && set -x
|
||||||
|
|
||||||
DIR="$(dirname "$0")"
|
DIR="$(dirname "$0")"
|
||||||
DASH_PNG="$DIR/dash.png"
|
BACKGROUND_PNG="$DIR/kindlebg.png"
|
||||||
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
|
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
|
||||||
LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
|
LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
|
||||||
|
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
|
||||||
|
STATE_DIR="$DIR/local/state"
|
||||||
|
BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at"
|
||||||
|
|
||||||
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
|
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
|
||||||
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
|
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
|
||||||
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
|
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
|
||||||
DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
||||||
|
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
||||||
|
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
||||||
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
|
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
|
||||||
|
|
||||||
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
|
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
|
||||||
@@ -27,6 +32,7 @@ init() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
|
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
if [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
|
if [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
|
||||||
echo "System suspend disabled, using normal sleep between refreshes."
|
echo "System suspend disabled, using normal sleep between refreshes."
|
||||||
@@ -50,27 +56,72 @@ prepare_sleep() {
|
|||||||
num_refresh=$FULL_DISPLAY_REFRESH_RATE
|
num_refresh=$FULL_DISPLAY_REFRESH_RATE
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh_dashboard() {
|
now_epoch() {
|
||||||
echo "Refreshing dashboard"
|
date '+%s'
|
||||||
|
}
|
||||||
|
|
||||||
|
background_refresh_due() {
|
||||||
|
if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_epoch=$(now_epoch)
|
||||||
|
last_background_epoch=$(cat "$BACKGROUND_TIMESTAMP_FILE")
|
||||||
|
refresh_interval_seconds=$((BACKGROUND_REFRESH_INTERVAL_MINUTES * 60))
|
||||||
|
|
||||||
|
[ $((current_epoch - last_background_epoch)) -ge "$refresh_interval_seconds" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
store_background_timestamp() {
|
||||||
|
now_epoch >"$BACKGROUND_TIMESTAMP_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_background() {
|
||||||
|
echo "Refreshing background"
|
||||||
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
|
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
|
||||||
|
|
||||||
"$FETCH_DASHBOARD_CMD" "$DASH_PNG"
|
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
|
||||||
fetch_status=$?
|
fetch_status=$?
|
||||||
|
|
||||||
if [ "$fetch_status" -ne 0 ]; then
|
if [ "$fetch_status" -ne 0 ]; then
|
||||||
echo "Not updating screen, fetch-dashboard returned $fetch_status"
|
echo "Background fetch failed with $fetch_status"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then
|
store_background_timestamp
|
||||||
num_refresh=0
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# trigger a full refresh once in every 4 refreshes, to keep the screen clean
|
clock_force_full_refresh() {
|
||||||
|
eval "$("$DIR/local/clock-index.sh")"
|
||||||
|
[ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_dashboard() {
|
||||||
|
background_refreshed=false
|
||||||
|
|
||||||
|
if background_refresh_due; then
|
||||||
|
if fetch_background; then
|
||||||
|
background_refreshed=true
|
||||||
echo "Full screen refresh"
|
echo "Full screen refresh"
|
||||||
/usr/sbin/eips -f -g "$DASH_PNG"
|
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
|
||||||
|
elif [ ! -f "$BACKGROUND_PNG" ]; then
|
||||||
|
echo "No cached background available."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$background_refreshed" = false ] && [ ! -f "$BACKGROUND_PNG" ]; then
|
||||||
|
echo "No cached background available."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$background_refreshed" = true ] || clock_force_full_refresh; then
|
||||||
|
echo "Clock patch full refresh"
|
||||||
|
"$CLOCK_RENDER_CMD" true
|
||||||
else
|
else
|
||||||
echo "Partial screen refresh"
|
echo "Clock patch partial refresh"
|
||||||
/usr/sbin/eips -g "$DASH_PNG"
|
"$CLOCK_RENDER_CMD" false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
num_refresh=$((num_refresh + 1))
|
num_refresh=$((num_refresh + 1))
|
||||||
|
|||||||
24
dash/src/local/clock-index.sh
Normal file
24
dash/src/local/clock-index.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
to_decimal() {
|
||||||
|
printf '%s' "$1" | awk '{
|
||||||
|
sub(/^0+/, "", $0)
|
||||||
|
if ($0 == "") {
|
||||||
|
$0 = 0
|
||||||
|
}
|
||||||
|
print $0
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
hour_value=${1:-$(date '+%H')}
|
||||||
|
minute_value=${2:-$(date '+%M')}
|
||||||
|
|
||||||
|
hour_decimal=$(to_decimal "$hour_value")
|
||||||
|
minute_decimal=$(to_decimal "$minute_value")
|
||||||
|
hour_index=$(( (hour_decimal % 12) * 60 + minute_decimal ))
|
||||||
|
|
||||||
|
printf 'hour=%s\n' "$hour_decimal"
|
||||||
|
printf 'minute=%s\n' "$minute_decimal"
|
||||||
|
printf 'hour_index=%03d\n' "$hour_index"
|
||||||
|
printf 'minute_index=%02d\n' "$minute_decimal"
|
||||||
@@ -5,6 +5,13 @@ export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
|
|||||||
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
|
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
|
||||||
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
|
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
|
||||||
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
|
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
|
||||||
|
export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
|
||||||
|
export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
||||||
|
export CLOCK_REGION_X=${CLOCK_REGION_X:-262}
|
||||||
|
export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55}
|
||||||
|
export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220}
|
||||||
|
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
|
||||||
|
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
||||||
|
|
||||||
# By default, partial screen updates are used to update the screen,
|
# By default, partial screen updates are used to update the screen,
|
||||||
# to prevent the screen from flashing. After a few partial updates,
|
# to prevent the screen from flashing. After a few partial updates,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
# Fetch a new dashboard image, make sure to output it to "$1".
|
set -eu
|
||||||
# For example:
|
|
||||||
"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png
|
# 拉取低频背景图,调用方负责传入输出路径。
|
||||||
|
output_path=${1:?"missing output path"}
|
||||||
|
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
|
||||||
|
|
||||||
|
"$(dirname "$0")/../xh" -d -q -o "$output_path" get "$background_url"
|
||||||
|
|||||||
32
dash/src/local/render-clock.sh
Normal file
32
dash/src/local/render-clock.sh
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||||
|
clock_face_path=${CLOCK_FACE_PATH:-"$DIR/../assets/clock-face.png"}
|
||||||
|
hour_assets_dir=${CLOCK_HOUR_ASSETS_DIR:-"$DIR/../assets/hour-hand"}
|
||||||
|
minute_assets_dir=${CLOCK_MINUTE_ASSETS_DIR:-"$DIR/../assets/minute-hand"}
|
||||||
|
clock_region_x=${CLOCK_REGION_X:-313}
|
||||||
|
clock_region_y=${CLOCK_REGION_Y:-0}
|
||||||
|
force_full_refresh=${1:-false}
|
||||||
|
|
||||||
|
eval "$("$DIR/clock-index.sh")"
|
||||||
|
|
||||||
|
hour_patch="$hour_assets_dir/$hour_index.png"
|
||||||
|
minute_patch="$minute_assets_dir/$minute_index.png"
|
||||||
|
|
||||||
|
if [ ! -f "$clock_face_path" ] || [ ! -f "$hour_patch" ] || [ ! -f "$minute_patch" ]; then
|
||||||
|
echo "Clock assets missing."
|
||||||
|
echo "Face: $clock_face_path"
|
||||||
|
echo "Hour: $hour_patch"
|
||||||
|
echo "Minute: $minute_patch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$force_full_refresh" = true ]; then
|
||||||
|
/usr/sbin/eips -f -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
|
||||||
|
else
|
||||||
|
/usr/sbin/eips -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/sbin/eips -g "$hour_patch" -x "$clock_region_x" -y "$clock_region_y"
|
||||||
|
/usr/sbin/eips -g "$minute_patch" -x "$clock_region_x" -y "$clock_region_y"
|
||||||
166
scripts/generate-kindle-clock-assets.swift
Normal file
166
scripts/generate-kindle-clock-assets.swift
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HandConfig {
|
||||||
|
let sourcePath: String
|
||||||
|
let outputDirectoryName: String
|
||||||
|
let frameCount: Int
|
||||||
|
let sourceWidth: CGFloat
|
||||||
|
let sourceHeight: CGFloat
|
||||||
|
let pivotX: CGFloat
|
||||||
|
let pivotY: CGFloat
|
||||||
|
let digits: Int
|
||||||
|
let angleStep: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetError: Error, CustomStringConvertible {
|
||||||
|
case invalidImage(String)
|
||||||
|
case pngEncodingFailed(String)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case let .invalidImage(path):
|
||||||
|
return "无法读取图片:\(path)"
|
||||||
|
case let .pngEncodingFailed(path):
|
||||||
|
return "无法编码 PNG:\(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
|
||||||
|
let repoRoot = CommandLine.arguments.count > 1
|
||||||
|
? URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||||
|
: workingDirectory
|
||||||
|
let outputRoot = CommandLine.arguments.count > 2
|
||||||
|
? URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true)
|
||||||
|
: repoRoot.appendingPathComponent("assets/kindle-clock", isDirectory: true)
|
||||||
|
|
||||||
|
let faceSourceURL = repoRoot.appendingPathComponent("assets/clock-face.png")
|
||||||
|
let targetSize = NSSize(width: 220, height: 220)
|
||||||
|
let faceSourceSize = NSSize(width: 431, height: 431)
|
||||||
|
let scale = targetSize.width / faceSourceSize.width
|
||||||
|
|
||||||
|
let handConfigs = [
|
||||||
|
HandConfig(
|
||||||
|
sourcePath: "assets/hour-hand.png",
|
||||||
|
outputDirectoryName: "hour-hand",
|
||||||
|
frameCount: 720,
|
||||||
|
sourceWidth: 32,
|
||||||
|
sourceHeight: 205,
|
||||||
|
pivotX: 13,
|
||||||
|
pivotY: 138,
|
||||||
|
digits: 3,
|
||||||
|
angleStep: 0.5
|
||||||
|
),
|
||||||
|
HandConfig(
|
||||||
|
sourcePath: "assets/minite-hand.png",
|
||||||
|
outputDirectoryName: "minute-hand",
|
||||||
|
frameCount: 60,
|
||||||
|
sourceWidth: 32,
|
||||||
|
sourceHeight: 288,
|
||||||
|
pivotX: 15,
|
||||||
|
pivotY: 203,
|
||||||
|
digits: 2,
|
||||||
|
angleStep: 6
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
func loadImage(at url: URL) throws -> NSImage {
|
||||||
|
guard let image = NSImage(contentsOf: url) else {
|
||||||
|
throw AssetError.invalidImage(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePNG(_ image: NSImage, to url: URL) throws {
|
||||||
|
guard
|
||||||
|
let tiffRepresentation = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
|
||||||
|
let pngData = bitmap.representation(using: .png, properties: [:])
|
||||||
|
else {
|
||||||
|
throw AssetError.pngEncodingFailed(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try pngData.write(to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderImage(size: NSSize, draw: () -> Void) -> NSImage {
|
||||||
|
let image = NSImage(size: size)
|
||||||
|
image.lockFocusFlipped(true)
|
||||||
|
NSColor.clear.setFill()
|
||||||
|
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
|
||||||
|
draw()
|
||||||
|
image.unlockFocus()
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFace() throws {
|
||||||
|
let faceImage = try loadImage(at: faceSourceURL)
|
||||||
|
let renderedFace = renderImage(size: targetSize) {
|
||||||
|
faceImage.draw(
|
||||||
|
in: NSRect(origin: .zero, size: targetSize),
|
||||||
|
from: NSRect(origin: .zero, size: faceImage.size),
|
||||||
|
operation: .sourceOver,
|
||||||
|
fraction: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try savePNG(renderedFace, to: outputRoot.appendingPathComponent("clock-face.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHandFrames(config: HandConfig) throws {
|
||||||
|
let sourceURL = repoRoot.appendingPathComponent(config.sourcePath)
|
||||||
|
let handImage = try loadImage(at: sourceURL)
|
||||||
|
let outputDirectory = outputRoot.appendingPathComponent(config.outputDirectoryName, isDirectory: true)
|
||||||
|
let scaledWidth = config.sourceWidth * scale
|
||||||
|
let scaledHeight = config.sourceHeight * scale
|
||||||
|
let scaledPivotX = config.pivotX * scale
|
||||||
|
let scaledPivotY = config.pivotY * scale
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
for frameIndex in 0..<config.frameCount {
|
||||||
|
let angle = CGFloat(frameIndex) * config.angleStep
|
||||||
|
let renderedFrame = renderImage(size: targetSize) {
|
||||||
|
guard let context = NSGraphicsContext.current?.cgContext else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.translateBy(x: targetSize.width / 2, y: targetSize.height / 2)
|
||||||
|
context.rotate(by: angle * .pi / 180)
|
||||||
|
context.translateBy(x: -scaledPivotX, y: -scaledPivotY)
|
||||||
|
|
||||||
|
handImage.draw(
|
||||||
|
in: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
|
||||||
|
from: NSRect(origin: .zero, size: handImage.size),
|
||||||
|
operation: .sourceOver,
|
||||||
|
fraction: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = String(format: "%0\(config.digits)d.png", frameIndex)
|
||||||
|
try savePNG(renderedFrame, to: outputDirectory.appendingPathComponent(filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(at: outputRoot, withIntermediateDirectories: true)
|
||||||
|
try renderFace()
|
||||||
|
|
||||||
|
for config in handConfigs {
|
||||||
|
try renderHandFrames(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Generated Kindle clock assets at \(outputRoot.path)")
|
||||||
|
} catch {
|
||||||
|
fputs("\(error)\n", stderr)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
14
scripts/sync-layered-clock-assets.sh
Normal file
14
scripts/sync-layered-clock-assets.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
TMP_DIR="${TMPDIR:-/tmp}/kindle-clock-assets"
|
||||||
|
KINDLE_TARGET=${1:-kindle}
|
||||||
|
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
/usr/bin/swift "$ROOT_DIR/scripts/generate-kindle-clock-assets.swift" "$ROOT_DIR" "$TMP_DIR"
|
||||||
|
|
||||||
|
ssh "$KINDLE_TARGET" 'mkdir -p /mnt/us/dashboard/assets/hour-hand /mnt/us/dashboard/assets/minute-hand'
|
||||||
|
rsync -av --no-o --no-g --delete "$TMP_DIR"/ "$KINDLE_TARGET":/mnt/us/dashboard/assets/
|
||||||
|
|
||||||
|
echo "Clock assets synced to $KINDLE_TARGET:/mnt/us/dashboard/assets"
|
||||||
71
scripts/sync-layered-clock-to-kindle.sh
Normal file
71
scripts/sync-layered-clock-to-kindle.sh
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
KINDLE_TARGET=${1:-kindle}
|
||||||
|
CLOCK_REGION_JSON="$ROOT_DIR/calendar/dist/clock-region.json"
|
||||||
|
|
||||||
|
clock_region_value() {
|
||||||
|
key=$1
|
||||||
|
python3 - "$CLOCK_REGION_JSON" "$key" <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
key = sys.argv[2]
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
defaults = {"x": 262, "y": 55, "width": 220, "height": 220}
|
||||||
|
print(defaults[key])
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
print(data[key])
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
clock_x=$(clock_region_value x)
|
||||||
|
clock_y=$(clock_region_value y)
|
||||||
|
clock_width=$(clock_region_value width)
|
||||||
|
clock_height=$(clock_region_value height)
|
||||||
|
|
||||||
|
sh "$ROOT_DIR/scripts/sync-layered-clock-assets.sh" "$KINDLE_TARGET"
|
||||||
|
|
||||||
|
rsync -av --no-o --no-g \
|
||||||
|
"$ROOT_DIR/dash/src/dash.sh" \
|
||||||
|
"$KINDLE_TARGET":/mnt/us/dashboard/
|
||||||
|
|
||||||
|
rsync -av --no-o --no-g \
|
||||||
|
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
|
||||||
|
"$ROOT_DIR/dash/src/local/clock-index.sh" \
|
||||||
|
"$ROOT_DIR/dash/src/local/render-clock.sh" \
|
||||||
|
"$KINDLE_TARGET":/mnt/us/dashboard/local/
|
||||||
|
|
||||||
|
rsync -av --no-o --no-g \
|
||||||
|
"$ROOT_DIR/calendar/dist/kindlebg.png" \
|
||||||
|
"$KINDLE_TARGET":/mnt/us/dashboard/
|
||||||
|
|
||||||
|
ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/dash.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh"
|
||||||
|
|
||||||
|
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state"
|
||||||
|
ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at"
|
||||||
|
ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \
|
||||||
|
-v x='$clock_x' \
|
||||||
|
-v y='$clock_y' \
|
||||||
|
-v w='$clock_width' \
|
||||||
|
-v h='$clock_height' \
|
||||||
|
'BEGIN { seen_x=0; seen_y=0; seen_w=0; seen_h=0 } \
|
||||||
|
/^export CLOCK_REGION_X=/ { print \"export CLOCK_REGION_X=\" x; seen_x=1; next } \
|
||||||
|
/^export CLOCK_REGION_Y=/ { print \"export CLOCK_REGION_Y=\" y; seen_y=1; next } \
|
||||||
|
/^export CLOCK_REGION_WIDTH=/ { print \"export CLOCK_REGION_WIDTH=\" w; seen_w=1; next } \
|
||||||
|
/^export CLOCK_REGION_HEIGHT=/ { print \"export CLOCK_REGION_HEIGHT=\" h; seen_h=1; next } \
|
||||||
|
{ print } \
|
||||||
|
END { \
|
||||||
|
if (!seen_x) print \"export CLOCK_REGION_X=\" x; \
|
||||||
|
if (!seen_y) print \"export CLOCK_REGION_Y=\" y; \
|
||||||
|
if (!seen_w) print \"export CLOCK_REGION_WIDTH=\" w; \
|
||||||
|
if (!seen_h) print \"export CLOCK_REGION_HEIGHT=\" h; \
|
||||||
|
}' /mnt/us/dashboard/local/env.sh >\"\$tmp\" && cat \"\$tmp\" >/mnt/us/dashboard/local/env.sh && rm -f \"\$tmp\""
|
||||||
|
|
||||||
|
echo "Layered clock runtime synced to $KINDLE_TARGET"
|
||||||
Reference in New Issue
Block a user