Initial scaffold: NixOS + SvelteKit dashboard + Python renderer
This commit is contained in:
Generated
+1965
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "eink-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tsdav": "^2.1.0",
|
||||
"ical.js": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* E-paper-friendly base styles.
|
||||
Constraints:
|
||||
- Hard 1-bit palette: pure black on pure white. No grays (they dither badly).
|
||||
- No thin lines below 2px — Floyd-Steinberg dither breaks them up.
|
||||
- No anti-aliased small fonts. Stick to bold weights at >= 18px.
|
||||
- No soft shadows; use crisp 2-4px solid borders for separation. */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 1304px;
|
||||
height: 984px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: unset;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.border-thick {
|
||||
border: 3px solid #000000;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=1304, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createDAVClient } from 'tsdav';
|
||||
import ICAL from 'ical.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const CALDAV_URL = process.env.CALDAV_URL ?? 'https://mail.rubenhensen.nl/dav/';
|
||||
const CALDAV_USER = process.env.CALDAV_USER ?? 'ruben@rubenhensen.nl';
|
||||
|
||||
// systemd LoadCredential drops the password at $CREDENTIALS_DIRECTORY/caldav-password.
|
||||
// Falls back to env for dev.
|
||||
function readCalDavPassword(): string {
|
||||
const dir = process.env.CREDENTIALS_DIRECTORY;
|
||||
if (dir) {
|
||||
try {
|
||||
return readFileSync(`${dir}/caldav-password`, 'utf-8').trim();
|
||||
} catch {}
|
||||
}
|
||||
return process.env.CALDAV_PASSWORD ?? '';
|
||||
}
|
||||
|
||||
interface Event {
|
||||
start: Date;
|
||||
end: Date;
|
||||
summary: string;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
async function fetchTodayEvents(): Promise<Event[]> {
|
||||
const password = readCalDavPassword();
|
||||
if (!password) return [];
|
||||
|
||||
const client = await createDAVClient({
|
||||
serverUrl: CALDAV_URL,
|
||||
credentials: { username: CALDAV_USER, password },
|
||||
authMethod: 'Basic',
|
||||
defaultAccountType: 'caldav'
|
||||
});
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
|
||||
// Stalwart returns "207 Multi-Status / 404 No resources found" for empty
|
||||
// time-range queries, which tsdav rejects as an error. Fetch everything
|
||||
// and filter client-side — the calendar is small enough that this is fine.
|
||||
const windowStart = ICAL.Time.fromJSDate(start, true);
|
||||
const windowEnd = ICAL.Time.fromJSDate(end, true);
|
||||
|
||||
const events: Event[] = [];
|
||||
for (const cal of calendars) {
|
||||
const objects = await client.fetchCalendarObjects({ calendar: cal });
|
||||
for (const obj of objects) {
|
||||
if (!obj.data) continue;
|
||||
const jcal = ICAL.parse(obj.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
|
||||
for (const vevent of comp.getAllSubcomponents('vevent')) {
|
||||
const ev = new ICAL.Event(vevent);
|
||||
if (ev.isRecurrenceException()) continue;
|
||||
|
||||
const push = (s: ICAL.Time, e: ICAL.Time) => {
|
||||
events.push({
|
||||
start: s.toJSDate(),
|
||||
end: e.toJSDate(),
|
||||
summary: ev.summary,
|
||||
allDay: s.isDate
|
||||
});
|
||||
};
|
||||
|
||||
if (!ev.isRecurring()) {
|
||||
if (ev.endDate.compare(windowStart) <= 0) continue;
|
||||
if (ev.startDate.compare(windowEnd) >= 0) continue;
|
||||
push(ev.startDate, ev.endDate);
|
||||
continue;
|
||||
}
|
||||
|
||||
const iter = ev.iterator();
|
||||
let next: ICAL.Time | null;
|
||||
while ((next = iter.next())) {
|
||||
if (next.compare(windowEnd) >= 0) break;
|
||||
const occ = ev.getOccurrenceDetails(next);
|
||||
if (occ.endDate.compare(windowStart) <= 0) continue;
|
||||
push(occ.startDate, occ.endDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
let events: Event[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
events = await fetchTodayEvents();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else if (typeof e === 'object' && e !== null) {
|
||||
try { error = JSON.stringify(e); } catch { error = String(e); }
|
||||
} else {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
events,
|
||||
error,
|
||||
renderedAt: new Date().toISOString()
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
function fmtTime(d: Date | string): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDate(d: Date | string): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
</script>
|
||||
|
||||
<main class="dashboard">
|
||||
<header class="header">
|
||||
<h1>{fmtDate(today)}</h1>
|
||||
<span class="rendered">refreshed {fmtTime(data.renderedAt)}</span>
|
||||
</header>
|
||||
|
||||
<section class="events">
|
||||
<h2>Today</h2>
|
||||
{#if data.error}
|
||||
<p class="error">CalDAV error: {data.error}</p>
|
||||
{:else if data.events.length === 0}
|
||||
<p class="empty">Nothing scheduled.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each data.events as ev (`${ev.start}-${ev.summary}`)}
|
||||
<li class="event">
|
||||
<span class="time">
|
||||
{#if ev.allDay}all day{:else}{fmtTime(ev.start)}–{fmtTime(ev.end)}{/if}
|
||||
</span>
|
||||
<span class="summary">{ev.summary}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
width: 1304px;
|
||||
height: 984px;
|
||||
padding: 40px 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
border-bottom: 4px solid #000;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.rendered {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events h2 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.events ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 24px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 2px solid #000;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty, .error {
|
||||
font-size: 28px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user