Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e94a0c998 | |||
| 2d16ded0c1 | |||
| 00e0cc42cf | |||
| 5cfd2de432 | |||
| f4f64eb5d4 | |||
| 0a3ec27774 | |||
| c2e5c1d9dc | |||
| 8715589cbb | |||
| e9c7e9df1a |
@@ -68,6 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
|
|||||||
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
|
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
|
||||||
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
|
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
|
||||||
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
|
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
|
||||||
|
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.4.0` since 2026-11-04 late night; `1.0.0`–`1.3.0` kept for rollback; Watchtower disabled). **v1.4.0 adds the admin TVs tab**: per-TV morning routine (wake time + days, YouTube Music playlist, Shield volume) pushed to TVs by check-in (`GET /api/tv/config` every 5 min, `POST /api/tv/report`, both LAN-only; status in `data/tv-status.json`). TVs are identified by the photo app's per-app Android ID (.179 = `2f49d9d58ea552b4`, .186 "Jack's tv" = `cc516afd2195c2cc`). **v1.3.0 adds `/photo/gallery`** (remote-driven grid: arrows move, OK opens full screen, ◀ ▶ browse, Back returns; Slideshow button; idle 10 min -> slideshow) and a 400px tile per photo (`<key>.m.jpg`). Slideshow interval set to **15s** (was 20s) per user. **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.2.1** (2026-11-04; runs the morning routine: alarm at wake−30s -> MorningActivity turns screen on, sets volume, starts the playlist in YouTube Music (`music.youtube.com/watch?list=`); second alarm at the wake time opens `com.dayline.tv` on top. **Needs two ADB grants per TV, already done on both:** `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow` (start other apps from background) and `dumpsys deviceidle whitelist +com.dayline.photos` (network during Doze -- without it a sleeping TV never checks in). The old `com.dayline.tv` 6:30 alarm flag is still set on both as a backup -- **remove it once the new routine is proven**, or admin time changes will still get a 6:30 calendar launch; separate APK: launcher tile opens `/photo/gallery` and takes D-pad input, Back steps back through WebView history; screensaver/DreamService loads `/photo`, non-interactive; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components <that value>`). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it, and both have the daily 6:30 AM wake-and-launch enabled (flag `files/daily_wake_0630_enabled`; .186 added 2026-11-04). It wakes a sleeping Shield; the TV powers on only via HDMI-CEC. See memory `hobbs-family-organizer`.
|
||||||
├── [COMPOSE] Farm Agent (:8889)
|
├── [COMPOSE] Farm Agent (:8889)
|
||||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
||||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
||||||
@@ -99,6 +100,8 @@ All service data lives on the **Synology NAS** via NFS, mounted at `/mnt/docker-
|
|||||||
└── bart/{media,openvas,watchtower}/
|
└── bart/{media,openvas,watchtower}/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**hf-pdocker-02 fixed 2026-11-04 after the 11-03 power event** (dockerd was dead 27h: stale Swarm membership made it fatal-exit when .192:2377 was unreachable, and `/mnt/docker-data` had not mounted): `docker swarm leave --force` done (**side effect: it wiped Docker's embedded-DNS container-name records, so `wikijs-db`/`gitea-db`/… stopped resolving even for new containers -- external DNS was fine, existing DB pools kept working, new connections failed. Fixed by restarting all 18 containers DBs-first. When doing bart: restart its containers or dockerd right after `swarm leave`**), and the `/mnt/docker-data` fstab line now has `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` (backup `/etc/fstab.bak-20261104-nfs-nofail`). Its other NAS lines (`/mnt/infrastructure`, `/mnt/NAS` cifs, `/data/media`, `/mnt/nas-media`) still lack it. **bart fixed the same way 2026-11-04** (user-approved): `docker swarm leave`, then `systemctl restart docker` straight away (every bart container is `unless-stopped`, so this rebuilds embedded DNS in one step -- verified all names resolve on `media_default`), and its `/mnt/docker-data` fstab line got the same `nofail,...` options (backup `/etc/fstab.bak-20261104-nfs-nofail`). bart's other NAS lines (`/data/media`, `/mnt/infrastructure`, `/mnt/nas-media`, the `/mnt/frigate-storage` cifs line -- which has the node password in plaintext) were left alone: `nofail` on the media mounts would let Jellyfin/Sonarr/Radarr start against empty dirs. The stale bart/.100 entries were `docker node rm`'d on .192, which is now a **single-node swarm of itself** (harmless: it is the manager, so it never blocks at boot; `docker swarm leave --force` there is optional cleanup).
|
||||||
|
|
||||||
**NFS mount (all nodes):** `192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3,nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s 0 0`
|
**NFS mount (all nodes):** `192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3,nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s 0 0`
|
||||||
**Note (hf-pdocker-01):** Both NFS mounts hardened with `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` on 2026-05-20 to prevent boot-time race (network not up when NFS tried to mount, cascading ~10 container failures).
|
**Note (hf-pdocker-01):** Both NFS mounts hardened with `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` on 2026-05-20 to prevent boot-time race (network not up when NFS tried to mount, cascading ~10 container failures).
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
|
|||||||
- MCPO: :8001
|
- MCPO: :8001
|
||||||
- Trivy: :4954
|
- Trivy: :4954
|
||||||
- CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box.
|
- CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box.
|
||||||
|
- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`, `adminConfigured`). Dashboard open on LAN (trusted-LAN mode); `/photo` = photo frame; `/api/health/photos` (503 = album sync stale >48h, **probe this**); `/admin` is password-protected and LAN-only (direct 192.168.86.0/24 only -- anything arriving via a proxy gets 404, so do NOT add a Traefik route expecting admin to work through it). No Traefik route.
|
||||||
|
|
||||||
**bart (192.168.86.167):**
|
**bart (192.168.86.167):**
|
||||||
- Farm Agent: :8889
|
- Farm Agent: :8889
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Hobbs' Family Organizer - nonsecret runtime settings
|
||||||
|
# Copy this file to .env. Keep private calendar URLs and the access token in
|
||||||
|
# ./secrets; never put either value in this file.
|
||||||
|
|
||||||
|
# Docker publishes the app on this server address and port. Set the bind
|
||||||
|
# address to a specific LAN interface if you do not want to listen everywhere.
|
||||||
|
HOBBS_BIND_ADDRESS=0.0.0.0
|
||||||
|
HOBBS_HTTP_PORT=8340
|
||||||
|
|
||||||
|
# Display and calendar defaults.
|
||||||
|
ORGANIZER_TIMEZONE=America/Chicago
|
||||||
|
|
||||||
|
# Seven-day forecast defaults for Sapulpa, Oklahoma.
|
||||||
|
WEATHER_LOCATION="Sapulpa, OK"
|
||||||
|
WEATHER_LATITUDE=35.9987
|
||||||
|
WEATHER_LONGITUDE=-96.11417
|
||||||
|
NWS_FORECAST_URL=https://api.weather.gov/gridpoints/TSA/37,98/forecast
|
||||||
|
|
||||||
|
# The National Weather Service asks clients to identify themselves. Replace
|
||||||
|
# the example contact with an address monitored by the server operator.
|
||||||
|
WEATHER_USER_AGENT="Hobbs-Family-Organizer/1.0 (self-hosted)"
|
||||||
|
|
||||||
|
# Admin (v1.1.0)
|
||||||
|
HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data
|
||||||
|
ADMIN_ALLOWED_CIDRS=192.168.86.0/24,127.0.0.1/32
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Hobbs Family Organizer (Shield TV dashboard) — hf-pdocker-02
|
||||||
|
|
||||||
|
**Shield photo app (2026-11-04): `com.dayline.photos` v1.0.** A launcher tile plus a screensaver, both showing `/photo`.
|
||||||
|
- Build: `sudo ./build-photos-apk.sh`. It writes `~/hobbs-shield/Hobbs-Family-Photos-<ver>.apk` on .100.
|
||||||
|
- Install: `adb install -r <apk>`.
|
||||||
|
- Set the screensaver: `adb shell settings put secure screensaver_components com.dayline.photos/.PhotoDreamService`.
|
||||||
|
- Signing key: `/var/lib/hobbs-organizer/apk-signing/` plus a NAS copy. Keep it; every update must use it.
|
||||||
|
|
||||||
|
**v1.2.0 (2026-11-04): photo frame at http://192.168.86.100:8340/photo** — Google Photos shared albums
|
||||||
|
(admin → Photos tab: add share link, Check, Sync now; display interval/fit/shuffle/date) + uploads (resized in the browser).
|
||||||
|
Cache: `/var/lib/hobbs-organizer/data/photos/<album-id>/` (+ `uploads/`), re-synced every 6h. Stale check: `/api/health/photos` (503 after 48h).
|
||||||
|
Scraping is unofficial (share-page `ds:1` JSON); if Google changes the page, sync errors show in admin and cached photos keep playing.
|
||||||
|
|
||||||
|
**v1.1.0 (2026-11-04): admin screen at http://192.168.86.100:8340/admin** — password (set on first visit, LAN only),
|
||||||
|
calendar links (add/remove/rename/color/hide, masked after save, per-calendar Test, history + Restore).
|
||||||
|
Settings live in `/var/lib/hobbs-organizer/data/` (local disk, owner 10001, mode 700):
|
||||||
|
`settings.json` (calendars, incl. URLs — treat as secret), `auth.json` (scrypt hash + session key), `history/` (last 20).
|
||||||
|
Forgot password: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`.
|
||||||
|
The secrets-file calendar list is now only a seed used when `settings.json` does not exist yet.
|
||||||
|
Rollback: set `image: hobbs-family-organizer:1.0.0` (still on .100) — it ignores /data and reads the secrets file.
|
||||||
|
|
||||||
|
Deployed 2026-11-04. URL: http://192.168.86.100:8340/ (LAN only, trusted-LAN mode = no token).
|
||||||
|
|
||||||
|
- App source / release bundle: `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/` (repo root).
|
||||||
|
- Deploy dir on NAS: `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer/` = the release bundle
|
||||||
|
+ `compose.override.yaml` + `.env` from this folder. Image is built locally (`hobbs-family-organizer:1.0.0`).
|
||||||
|
- Secrets on LOCAL disk of .100 (root:root 600): `/var/lib/hobbs-organizer/secrets/`
|
||||||
|
- `calendar_ical_urls.json` — JSON array of private Google Calendar basic.ics URLs (treat as passwords; never commit)
|
||||||
|
- `organizer_access_token.txt` — empty = trusted-LAN mode. Put a 32+ char token here for bearer mode.
|
||||||
|
- Redeploy: `cd /mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer && docker compose up -d --build --wait`
|
||||||
|
- Health: `curl http://192.168.86.100:8340/api/health`
|
||||||
|
- Shields (package `com.dayline.tv`, APK v1.4) read `files/base_url` + `files/allow_insecure_http`
|
||||||
|
provisioned over ADB `run-as`. adb client lives on .100 (`apt install adb`).
|
||||||
|
- Shield provisioning (bash port of configure-shield.ps1, run on .100): `provision-shield.sh <ip> <base_url>`
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the Hobbs Family Photos Shield APK (com.dayline.photos) on hf-pdocker-02.
|
||||||
|
# Usage: sudo ./build-photos-apk.sh -> ~/hobbs-shield/Hobbs-Family-Photos-<ver>.apk
|
||||||
|
# The signing key is created on first run in /var/lib/hobbs-organizer/apk-signing (root 600)
|
||||||
|
# and copied to the NAS. NEVER delete it: updates must be signed with the same key.
|
||||||
|
set -euo pipefail
|
||||||
|
DEPLOY=/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer
|
||||||
|
SRC="$DEPLOY/shield/photos-source"
|
||||||
|
SIGN=/var/lib/hobbs-organizer/apk-signing
|
||||||
|
NAS_SIGN=/mnt/docker-data/configs/hobbs-organizer/apk-signing
|
||||||
|
WORK=/var/tmp/hfo-photos-build
|
||||||
|
OUT=/home/glyptic/hobbs-shield
|
||||||
|
IMAGE=eclipse-temurin:17-jdk-jammy
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || { echo "run with sudo"; exit 1; }
|
||||||
|
install -d -m 700 "$SIGN"
|
||||||
|
|
||||||
|
if [ ! -f "$SIGN/hobbs-photos.jks" ]; then
|
||||||
|
echo "== creating signing key"
|
||||||
|
PASS=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-32)
|
||||||
|
docker run --rm -v "$SIGN:/signing" "$IMAGE" keytool -genkeypair -keystore /signing/hobbs-photos.jks \
|
||||||
|
-storetype PKCS12 -alias hobbs-photos -keyalg RSA -keysize 4096 -validity 20000 \
|
||||||
|
-dname "CN=Hobbs Family Photos, O=Hobbs Farm" -storepass "$PASS" -keypass "$PASS" >/dev/null
|
||||||
|
printf 'storeFile=/signing/hobbs-photos.jks\nstorePassword=%s\nkeyAlias=hobbs-photos\nkeyPassword=%s\n' "$PASS" "$PASS" > "$SIGN/keystore.properties"
|
||||||
|
chmod 600 "$SIGN"/*
|
||||||
|
fi
|
||||||
|
# Off-box copy (NAS root_squash: write as glyptic, keep it private).
|
||||||
|
sudo -u glyptic install -d -m 700 "$NAS_SIGN"
|
||||||
|
for f in hobbs-photos.jks keystore.properties; do
|
||||||
|
cat "$SIGN/$f" | sudo -u glyptic sh -c "umask 077; cat > '$NAS_SIGN/$f'"
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf "$WORK"; mkdir -p "$WORK"; cp -a "$SRC/." "$WORK/"
|
||||||
|
cp "$SIGN/keystore.properties" "$WORK/keystore.properties"
|
||||||
|
|
||||||
|
docker volume create hobbs-android-sdk >/dev/null
|
||||||
|
docker run --rm -v "$WORK:/src" -v hobbs-android-sdk:/opt/cache -v "$SIGN:/signing:ro" -w /src "$IMAGE" bash -euo pipefail -c '
|
||||||
|
export ANDROID_HOME=/opt/cache/sdk GRADLE_USER_HOME=/opt/cache/gradle-home
|
||||||
|
command -v unzip >/dev/null || { apt-get update -qq && apt-get install -y -qq unzip curl >/dev/null; }
|
||||||
|
if [ ! -x $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager ]; then
|
||||||
|
echo "== downloading Android command-line tools"
|
||||||
|
curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
|
mkdir -p $ANDROID_HOME/cmdline-tools && unzip -q /tmp/clt.zip -d /tmp/clt && mv /tmp/clt/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
|
||||||
|
fi
|
||||||
|
if [ ! -d $ANDROID_HOME/platforms/android-35 ] || [ ! -d $ANDROID_HOME/build-tools/35.0.0 ]; then
|
||||||
|
echo "== installing SDK platform 35 + build-tools 35.0.0"
|
||||||
|
(yes 2>/dev/null || true) | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platforms;android-35" "build-tools;35.0.0" >/dev/null
|
||||||
|
fi
|
||||||
|
if [ ! -x /opt/cache/gradle-8.9/bin/gradle ]; then
|
||||||
|
echo "== downloading Gradle 8.9"
|
||||||
|
curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
unzip -q /tmp/gradle.zip -d /opt/cache
|
||||||
|
fi
|
||||||
|
echo "sdk.dir=$ANDROID_HOME" > local.properties
|
||||||
|
/opt/cache/gradle-8.9/bin/gradle --no-daemon -q assembleRelease
|
||||||
|
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk | grep -E "Signer #1 certificate (DN|SHA-256)"
|
||||||
|
$ANDROID_HOME/build-tools/35.0.0/aapt2 dump badging app/build/outputs/apk/release/app-release.apk | grep -E "^package:|application-debuggable|leanback"
|
||||||
|
'
|
||||||
|
VER=$(grep -oP 'versionName "\K[^"]+' "$SRC/app/build.gradle")
|
||||||
|
install -d -o glyptic -g glyptic "$OUT"
|
||||||
|
install -o glyptic -g glyptic -m 644 "$WORK/app/build/outputs/apk/release/app-release.apk" "$OUT/Hobbs-Family-Photos-$VER.apk"
|
||||||
|
sha256sum "$OUT/Hobbs-Family-Photos-$VER.apk"
|
||||||
|
rm -rf "$WORK"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Homelab overrides (hf-pdocker-02). Secrets live on LOCAL disk, not NFS:
|
||||||
|
# start.mjs reads them as root, and NFS root_squash would deny that.
|
||||||
|
# Mode: trusted-LAN (empty token file) per user 2026-11-04.
|
||||||
|
services:
|
||||||
|
organizer:
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "false"
|
||||||
|
secrets:
|
||||||
|
calendar_ical_urls:
|
||||||
|
file: /var/lib/hobbs-organizer/secrets/calendar_ical_urls.json
|
||||||
|
organizer_access_token:
|
||||||
|
file: /var/lib/hobbs-organizer/secrets/organizer_access_token.txt
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: provision-shield.sh <ip> <base_url> (bash port of configure-shield.ps1, trusted-LAN http)
|
||||||
|
set -euo pipefail
|
||||||
|
D="$1:5555"; URL="$2"; PKG=com.dayline.tv; APK=/home/glyptic/hobbs-shield/Hobbs-Family-Organizer-Local-v1.4.apk
|
||||||
|
adb connect "$D" >/dev/null; [ "$(adb -s "$D" get-state)" = device ]
|
||||||
|
echo "before: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)"
|
||||||
|
adb -s "$D" shell run-as $PKG ls files
|
||||||
|
adb -s "$D" install -r "$APK"
|
||||||
|
echo "after: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)"
|
||||||
|
put() { printf '%s' "$2" | adb -s "$D" shell "run-as $PKG sh -c 'umask 077; cat > files/.$1.new && chmod 600 files/.$1.new && mv files/.$1.new files/$1'"; }
|
||||||
|
put base_url "$URL"
|
||||||
|
adb -s "$D" shell "run-as $PKG rm -f files/access_token"
|
||||||
|
put allow_insecure_http enabled
|
||||||
|
adb -s "$D" shell run-as $PKG ls -l files
|
||||||
|
FG=$(adb -s "$D" shell dumpsys activity activities | grep -m1 mResumedActivity); echo "foreground: $FG"; echo "$FG" | grep -q -E "com.dayline.tv|launcher|tvlauncher|projengmenu" || { echo SKIP-LAUNCH: TV in use; exit 0; }
|
||||||
|
adb -s "$D" shell am force-stop $PKG
|
||||||
|
adb -s "$D" shell am start -W -n $PKG/.MainActivity | grep -E 'Status|Activity'
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.openai
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.next
|
||||||
|
.next-build
|
||||||
|
.sites-runtime
|
||||||
|
.vinext
|
||||||
|
.wrangler
|
||||||
|
cloudflare-env.d.ts
|
||||||
|
backups
|
||||||
|
components
|
||||||
|
coverage
|
||||||
|
db
|
||||||
|
dist
|
||||||
|
drizzle
|
||||||
|
examples
|
||||||
|
hooks
|
||||||
|
lib
|
||||||
|
node_modules
|
||||||
|
outputs
|
||||||
|
shield
|
||||||
|
scripts
|
||||||
|
secrets/*.json
|
||||||
|
secrets/*.txt
|
||||||
|
!secrets/*.example.json
|
||||||
|
!secrets/*.example.txt
|
||||||
|
vite.config.ts
|
||||||
|
worker
|
||||||
|
work
|
||||||
|
MIGRATION.md
|
||||||
|
README.md
|
||||||
|
RELEASE-NOTES.md
|
||||||
|
VERSION
|
||||||
|
*.log
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
# Keep authored Yarn tooling if the project adopts Yarn.
|
||||||
|
/.yarn/*
|
||||||
|
!/.yarn/patches/
|
||||||
|
!/.yarn/plugins/
|
||||||
|
!/.yarn/releases/
|
||||||
|
!/.yarn/versions/
|
||||||
|
|
||||||
|
# Generated builds and framework state
|
||||||
|
/dist/
|
||||||
|
/.next/
|
||||||
|
/.next-build/
|
||||||
|
/.vinext/
|
||||||
|
/out/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Test output
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Package-manager logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Local environment and keys
|
||||||
|
.env*
|
||||||
|
.dev.vars*
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Checkout-local tool state
|
||||||
|
/.wrangler/
|
||||||
|
/.sites-runtime/
|
||||||
|
|
||||||
|
# Workspace scaffolding and publication archives
|
||||||
|
/.agents/
|
||||||
|
/.codex/
|
||||||
|
/outputs/
|
||||||
|
/work/
|
||||||
|
|
||||||
|
# OS metadata
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
audit=false
|
||||||
|
fund=false
|
||||||
|
update-notifier=false
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM node:22-bookworm-slim AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build && test -f .next-build/BUILD_ID
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS production-dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
RUN groupadd --system --gid 10001 organizer \
|
||||||
|
&& useradd --system --uid 10001 --gid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin organizer
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
PORT=3000
|
||||||
|
COPY --from=production-dependencies --chown=10001:10001 /app/node_modules/ ./node_modules/
|
||||||
|
COPY --from=build --chown=10001:10001 /app/.next-build/ ./.next-build/
|
||||||
|
COPY --from=build --chown=10001:10001 /app/public/ ./public/
|
||||||
|
COPY --from=build --chown=10001:10001 /app/container/start.mjs /app/container/reset-admin.mjs ./container/
|
||||||
|
COPY --from=build --chown=10001:10001 /app/package.json /app/next.config.mjs ./
|
||||||
|
RUN mkdir -p /app/.next-build/cache /data && chown -R 10001:10001 /app/.next-build /data && chmod 700 /data
|
||||||
|
# The tiny supervisor starts as root only long enough to read Docker secrets,
|
||||||
|
# then drops to uid/gid 10001 (Compose grants only SETUID/SETGID), prepares
|
||||||
|
# /data, runs the LAN-aware front proxy on :3000 and Next.js on 127.0.0.1:3001.
|
||||||
|
USER 0:0
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
CMD ["node", "container/start.mjs"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Migration and rollback
|
||||||
|
|
||||||
|
This procedure keeps the hosted dashboard and both Shields working until the local service has passed its checks.
|
||||||
|
|
||||||
|
## 1. Prepare the server
|
||||||
|
|
||||||
|
1. Extract the release bundle on the internal container host.
|
||||||
|
2. Regenerate the private Google Calendar ICS URLs that were used during development.
|
||||||
|
3. Run `scripts/configure.sh` or `scripts/configure.ps1`.
|
||||||
|
4. Review `.env`, especially the bind address, port, timezone, and NWS grid.
|
||||||
|
5. Start the container with `docker compose up -d --build --wait`.
|
||||||
|
|
||||||
|
Do not expose port 8080 to the public Internet.
|
||||||
|
|
||||||
|
## 2. Validate before touching a Shield
|
||||||
|
|
||||||
|
Check liveness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://SERVER-IP:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
In trusted-LAN mode, open `http://SERVER-IP:8080/` and verify:
|
||||||
|
|
||||||
|
- calendar events appear in agenda, week, and month views;
|
||||||
|
- the seven-day forecast and sunrise/sunset values appear;
|
||||||
|
- the FRED ticker reports live or partial data;
|
||||||
|
- AI/tech headlines appear and their QR dialogs scan correctly.
|
||||||
|
|
||||||
|
In bearer mode, validate with a client capable of adding the `Authorization` header or proceed with one Shield as the test client.
|
||||||
|
|
||||||
|
## 3. Upgrade one Shield first
|
||||||
|
|
||||||
|
Use the Shield with the least operational impact as the canary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& $adb -s DEVICE:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-r` upgrade is important. It preserves app-private files, including the legacy hosted token and the `daily_wake_0630_enabled` schedule flag.
|
||||||
|
|
||||||
|
Configure the local server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\shield\configure-shield.ps1 `
|
||||||
|
-Device DEVICE:5555 `
|
||||||
|
-BaseUrl http://SERVER-IP:8080/ `
|
||||||
|
-AllowInsecureHttp `
|
||||||
|
-AdbPath $adb
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-TokenFile .\secrets\organizer_access_token.txt` if bearer mode is enabled. Omit `-AllowInsecureHttp` for HTTPS.
|
||||||
|
|
||||||
|
Verify remote navigation, automatic refresh, QR dialogs, reboot behavior, and the 6:30 AM alarm on any Shield where that schedule was already enabled.
|
||||||
|
|
||||||
|
## 4. Upgrade the second Shield
|
||||||
|
|
||||||
|
After the canary has run successfully, repeat the APK upgrade and configuration with the other device address.
|
||||||
|
|
||||||
|
## 5. Rollback
|
||||||
|
|
||||||
|
The local cutover does not delete the old hosted access token. To switch a Shield back:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\shield\restore-hosted-site.ps1 -Device DEVICE:5555 -AdbPath $adb
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes `base_url`, `access_token`, and `allow_insecure_http`, then relaunches the same app against the hosted Site.
|
||||||
|
|
||||||
|
To roll back the server release, restore the previous release directory and its protected configuration backup, then run `docker compose up -d --build --wait`.
|
||||||
|
|
||||||
|
## 6. After the observation period
|
||||||
|
|
||||||
|
Once the local deployment is stable:
|
||||||
|
|
||||||
|
- keep the hosted Site only as long as you want the rollback option;
|
||||||
|
- retain an encrypted configuration backup;
|
||||||
|
- keep the prebuilt signed APK with the release archive;
|
||||||
|
- document the server address/DNS record and certificate renewal process;
|
||||||
|
- periodically review outbound-feed failures in `docker compose logs organizer`.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Hobbs' Family Organizer App — self-hosted package
|
||||||
|
|
||||||
|
This release moves the organizer off ChatGPT Sites and onto your own container host. The dashboard, API routes, calendar parser, weather, FRED market ticker, RSS headlines, QR handoff, and Shield wrapper are included.
|
||||||
|
|
||||||
|
The local deployment does **not** require ChatGPT, an OpenAI account, or an OpenAI API key. It is stateless and has no database or persistent application volume.
|
||||||
|
|
||||||
|
## What still uses the Internet
|
||||||
|
|
||||||
|
The container needs outbound DNS and HTTPS for:
|
||||||
|
|
||||||
|
- `calendar.google.com` — private Google Calendar ICS feeds
|
||||||
|
- `api.weather.gov` — seven-day National Weather Service forecast
|
||||||
|
- `fred.stlouisfed.org` — public FRED series pages used by the ticker
|
||||||
|
- the configured OpenAI, Google AI, DeepMind, NVIDIA, Ars Technica, and Wired RSS feeds
|
||||||
|
|
||||||
|
No inbound Internet access is required. Keep the service on your LAN, VPN, or behind your existing reverse proxy.
|
||||||
|
|
||||||
|
## Package contents
|
||||||
|
|
||||||
|
- `Dockerfile` — production Node 22 image
|
||||||
|
- `compose.yaml` — hardened Compose deployment on port 8080 by default
|
||||||
|
- `app/` — complete Next.js dashboard and API source
|
||||||
|
- `container/start.mjs` — validates secrets, then drops the app to uid/gid 10001
|
||||||
|
- `scripts/configure.*` — creates runtime secrets without printing them
|
||||||
|
- `scripts/backup.*` and `scripts/restore.*` — configuration backup and restore
|
||||||
|
- `secrets/` — ignored runtime secret location and a calendar template
|
||||||
|
- `shield/` — signed v1.4 APK, provisioning scripts, and Android source
|
||||||
|
- `MIGRATION.md` — ordered cutover and rollback procedure
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A Linux, Windows, or macOS host with Docker Engine/Desktop and Docker Compose v2
|
||||||
|
- Outbound HTTPS from the container host
|
||||||
|
- One to ten private Google Calendar `basic.ics` URLs
|
||||||
|
- For Shield deployment: Android Platform Tools (`adb`) and network debugging enabled on the Shield
|
||||||
|
|
||||||
|
The image builds for both `linux/amd64` and `linux/arm64` when the selected Node base image supports the host architecture.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Linux or macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/*.sh
|
||||||
|
./scripts/configure.sh --mode trusted-lan
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
curl -fsS http://127.0.0.1:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows PowerShell
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
|
.\scripts\configure.ps1 -Mode trusted-lan
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
Invoke-RestMethod http://127.0.0.1:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
The configuration script:
|
||||||
|
|
||||||
|
1. copies `.env.example` to `.env` if needed;
|
||||||
|
2. prompts privately for each Google Calendar ICS URL;
|
||||||
|
3. writes `secrets/calendar_ical_urls.json` with restrictive permissions;
|
||||||
|
4. creates `secrets/organizer_access_token.txt`, empty for trusted-LAN mode or populated with a random token for bearer mode.
|
||||||
|
|
||||||
|
After startup, open `http://SERVER-IP:8080/` or the HTTPS name provided by your reverse proxy.
|
||||||
|
|
||||||
|
## Access modes
|
||||||
|
|
||||||
|
### Trusted LAN
|
||||||
|
|
||||||
|
Use this when firewall/VLAN/VPN rules already restrict the dashboard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/configure.sh --mode trusted-lan
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\configure.ps1 -Mode trusted-lan
|
||||||
|
```
|
||||||
|
|
||||||
|
The token file exists but is empty. Anyone who can reach the published port can view calendar details, so bind to the intended interface and enforce access in your network or reverse proxy.
|
||||||
|
|
||||||
|
### Bearer token
|
||||||
|
|
||||||
|
Use this for the Shield or a reverse proxy that can inject an authorization header:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/configure.sh --mode bearer --rotate-token
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\configure.ps1 -Mode bearer -RotateToken
|
||||||
|
```
|
||||||
|
|
||||||
|
Every route except `/api/health` then requires:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
The Shield wrapper injects this header into every same-origin request. An ordinary browser address bar cannot add it, so browser access in bearer mode should go through an authenticated reverse proxy that injects the header. The configuration scripts intentionally do not print the token.
|
||||||
|
|
||||||
|
## Admin screen (v1.1.0)
|
||||||
|
|
||||||
|
Open `http://SERVER-IP:8080/admin` from a phone or laptop on the home network.
|
||||||
|
|
||||||
|
- **First visit:** choose the admin password. This works only while no password exists, and only from an allowed network.
|
||||||
|
- **Calendars tab:** add, remove, reorder, rename, recolor or hide calendars.
|
||||||
|
- Saved addresses are masked and never shown again.
|
||||||
|
- **Test** fetches a feed and reports its name and how many events are upcoming.
|
||||||
|
- Every save keeps the previous version. **History → Restore** brings any of the last 20 back.
|
||||||
|
- **Account tab:** change the password (this signs out other devices) or sign out.
|
||||||
|
- **Forgot the password:** `docker exec -u 10001 <container> node container/reset-admin.mjs`, then visit `/admin` again.
|
||||||
|
|
||||||
|
Settings live in the `/data` volume (`HOBBS_DATA_DIR`, default `./data`). It must be writable by uid 10001 and should be on local disk.
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `settings.json` | Calendars, including their private URLs. Treat it like a secret. |
|
||||||
|
| `auth.json` | The scrypt password hash and the session key. |
|
||||||
|
| `history/` | The last 20 saved versions. |
|
||||||
|
|
||||||
|
The `calendar_ical_urls` secret is now optional. It is used only to seed `settings.json` on first start.
|
||||||
|
|
||||||
|
Admin is refused unless the TCP client address is inside `ADMIN_ALLOWED_CIDRS`. It is also refused, with a 404, for any request carrying `X-Forwarded-For`, `Forwarded` or `X-Real-IP`, so it cannot be reached through a reverse proxy. The container's own front proxy (port 3000, with Next.js on 127.0.0.1:3001) records the real client address, and forged copies of that header are discarded.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `.env` for nonsecret settings:
|
||||||
|
|
||||||
|
| Setting | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `HOBBS_BIND_ADDRESS` | `0.0.0.0` | Host interface used by Docker |
|
||||||
|
| `HOBBS_HTTP_PORT` | `8080` | Published HTTP port |
|
||||||
|
| `ORGANIZER_TIMEZONE` | `America/Chicago` | Clock and calendar timezone |
|
||||||
|
| `WEATHER_LOCATION` | `Sapulpa, OK` | Display label |
|
||||||
|
| `WEATHER_LATITUDE` | `35.9987` | Sunrise/sunset latitude |
|
||||||
|
| `WEATHER_LONGITUDE` | `-96.11417` | Sunrise/sunset longitude |
|
||||||
|
| `NWS_FORECAST_URL` | `https://api.weather.gov/gridpoints/TSA/37,98/forecast` | NWS grid forecast |
|
||||||
|
| `WEATHER_USER_AGENT` | example value | NWS client identifier; add a monitored contact |
|
||||||
|
|
||||||
|
Keep these secrets out of `.env`, Git, image layers, and tickets:
|
||||||
|
|
||||||
|
- `secrets/calendar_ical_urls.json`
|
||||||
|
- `secrets/organizer_access_token.txt`
|
||||||
|
|
||||||
|
Private Google Calendar URLs act like passwords. Because the current URLs were previously shared during development, reset/regenerate each private ICS URL in Google Calendar before the final production cutover, then run the configure script again.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
Status and logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=200 organizer
|
||||||
|
curl -fsS http://127.0.0.1:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
The health endpoint never calls Google, FRED, NWS, or RSS providers. It reports liveness plus booleans for calendar configuration and authentication, but no secret values.
|
||||||
|
|
||||||
|
Stop and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop
|
||||||
|
docker compose start
|
||||||
|
```
|
||||||
|
|
||||||
|
Update after replacing the release files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --pull
|
||||||
|
docker compose up -d --wait
|
||||||
|
docker image prune
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker image prune` is optional and removes only unused images after confirmation.
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
The app has no database. Back up only `.env` and the two runtime secret files.
|
||||||
|
|
||||||
|
Linux/macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/restore.sh --force backups/hobbs-organizer-config-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\backup.ps1
|
||||||
|
.\scripts\restore.ps1 -Archive .\backups\hobbs-organizer-config-YYYYMMDD-HHMMSS-XXXXXXXX.zip -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup archives contain private calendar URLs and possibly the bearer token. Store them in an encrypted or tightly access-controlled backup system.
|
||||||
|
|
||||||
|
## Reverse proxy and HTTPS
|
||||||
|
|
||||||
|
HTTPS with a stable internal DNS name is recommended, for example `organizer.home.arpa`. Sample Caddy and Nginx configurations are in `examples/`.
|
||||||
|
|
||||||
|
If you use an internal certificate authority, install its root certificate in the Shield's user certificate store. The wrapper trusts system and user-installed CAs but always rejects certificate errors; it has no trust-all bypass.
|
||||||
|
|
||||||
|
For direct LAN HTTP, the Shield configuration script requires both an explicit `-AllowInsecureHttp` switch and a private IP, `.local`, `.lan`, or `.home.arpa` host.
|
||||||
|
|
||||||
|
## NVIDIA Shield deployment
|
||||||
|
|
||||||
|
Install Android Platform Tools, enable network debugging, and authorize the computer on the Shield. Upgrading the existing package preserves the hosted rollback token and the per-device 6:30 AM schedule flag.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$adb = "C:\path\to\platform-tools\adb.exe"
|
||||||
|
& $adb connect 192.168.86.179:5555
|
||||||
|
& $adb -s 192.168.86.179:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
For HTTPS with bearer authentication:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\shield\configure-shield.ps1 `
|
||||||
|
-Device 192.168.86.179:5555 `
|
||||||
|
-BaseUrl https://organizer.home.arpa/ `
|
||||||
|
-TokenFile .\secrets\organizer_access_token.txt `
|
||||||
|
-AdbPath $adb
|
||||||
|
```
|
||||||
|
|
||||||
|
For trusted-LAN HTTP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\shield\configure-shield.ps1 `
|
||||||
|
-Device 192.168.86.179:5555 `
|
||||||
|
-BaseUrl http://192.168.86.50:8080/ `
|
||||||
|
-AllowInsecureHttp `
|
||||||
|
-AdbPath $adb
|
||||||
|
```
|
||||||
|
|
||||||
|
The script streams the token through ADB standard input and never places it in the process arguments. Repeat for the second Shield using its address. Do not change the package ID: `com.dayline.tv` is intentionally preserved so existing app-private data survives `adb install -r`.
|
||||||
|
|
||||||
|
The wrapper is debuggable so the scripts can use Android `run-as` to provision private files. After the migration, disable network debugging on each Shield and enable it again only during controlled maintenance.
|
||||||
|
|
||||||
|
To return a Shield to the existing hosted Site:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\shield\restore-hosted-site.ps1 -Device 192.168.86.179:5555 -AdbPath $adb
|
||||||
|
```
|
||||||
|
|
||||||
|
That removes only the local URL/token flags and keeps the old hosted `service_token` intact.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Container application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
For local `npm start`, set `CALENDAR_ICAL_URLS` to a JSON array and optionally set `ORGANIZER_ACCESS_TOKEN`. The Compose path is preferred because it loads secrets from files and applies the security profile.
|
||||||
|
|
||||||
|
Android source is under `shield/source/`. It targets Android SDK 35, Java 17, and Gradle 8.9/Android Gradle Plugin 8.7.3. The included APK is already signed with the same certificate as the installed v1.3 app. The private signing key is deliberately not included in this package.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `calendarConfigurationValid: false`: rerun `scripts/configure.*` and confirm the secret contains a JSON array of Google `basic.ics` URLs.
|
||||||
|
- Dashboard returns `401`: token mode is enabled; provision the same token to the Shield or reverse proxy.
|
||||||
|
- Weather is empty: verify the NWS grid URL, outbound HTTPS, DNS, and `WEATHER_USER_AGENT`.
|
||||||
|
- FRED shows demo data: allow outbound HTTPS to `fred.stlouisfed.org`; the feed uses public pages and no API key.
|
||||||
|
- Shield shows setup required: confirm the APK is installed and rerun `shield/configure-shield.ps1`.
|
||||||
|
- Shield rejects HTTPS: install the internal CA certificate or use a certificate trusted by Android; never bypass validation.
|
||||||
|
- Container does not become healthy: run `docker compose logs organizer` and validate that both secret files exist.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 1.4.0 (2026-11-04)
|
||||||
|
|
||||||
|
- Admin **TVs** tab: a morning routine for each TV.
|
||||||
|
- Wake time and days of the week.
|
||||||
|
- A YouTube Music playlist (paste any playlist link; the `si=` tracking part is dropped).
|
||||||
|
- The Shield's volume (0–100%).
|
||||||
|
- Settings are pushed to each TV on its next check-in (every 5 minutes). Each TV card shows when the TV has applied them and how its last morning routine went.
|
||||||
|
- TV endpoints, direct LAN only and refused through a proxy:
|
||||||
|
- `GET /api/tv/config?device=…` returns that TV's settings and records the check-in.
|
||||||
|
- `POST /api/tv/report` records the outcome of the morning routine.
|
||||||
|
- Check-in status lives in `/data/tv-status.json`, kept separate from the settings history.
|
||||||
|
- TVs that check in without being configured show up under "TVs found on the network" with an **Add** button.
|
||||||
|
- Settings history Restore leaves TV settings unchanged.
|
||||||
|
- Shield photo app `com.dayline.photos` 1.2.1 (versionCode 4) runs the routine:
|
||||||
|
- At the wake time minus 30 s, it turns the screen on (the TV follows over HDMI-CEC). After 5 s it sets the volume and starts the playlist in YouTube Music.
|
||||||
|
- At the wake time, a second alarm opens the calendar app (`com.dayline.tv`) on top while the music keeps playing (needs YouTube Music Premium).
|
||||||
|
- With music off, it opens the calendar straight away.
|
||||||
|
- It needs two one-time grants over ADB: `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow`, so it can open other apps from the background, and `dumpsys deviceidle whitelist +com.dayline.photos`, so check-ins keep network access while the Shield is in Doze.
|
||||||
|
|
||||||
|
# 1.3.0 (2026-11-04)
|
||||||
|
|
||||||
|
- `/photo/gallery`: a remote-driven photo grid for the Shield photo app's launcher tile.
|
||||||
|
- Arrows move between tiles, OK opens a photo full screen with a zoom-in, and ◀ ▶ browse while it is open.
|
||||||
|
- Back (or OK) returns to the grid, and a **Slideshow** button at the top starts `/photo`.
|
||||||
|
- Photos are shown newest first.
|
||||||
|
- After 10 minutes without a key press, the slideshow starts on its own. The app keeps the screen on, so the TV screensaver never would.
|
||||||
|
- Sync now also caches a 400px gallery tile (`<key>.m.jpg`) per photo. Existing albums backfill on their next sync.
|
||||||
|
- Admin uploads send a tile too. Older uploads without one use the full photo in the grid.
|
||||||
|
- `/api/photos` entries gain a `tile` field.
|
||||||
|
- Shield photo app `com.dayline.photos` 1.1 (versionCode 2):
|
||||||
|
- The launcher tile opens the gallery and takes D-pad input.
|
||||||
|
- Back steps back through the gallery's history (closing a full-screen photo) before exiting.
|
||||||
|
- The screensaver is unchanged (non-interactive slideshow).
|
||||||
|
|
||||||
|
# 1.2.0 (2026-11-04)
|
||||||
|
|
||||||
|
- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken.
|
||||||
|
- It picks up new settings and photos within about 30 seconds and reloads itself every 12 hours.
|
||||||
|
- Admin **Photos** tab:
|
||||||
|
- Google Photos shared albums: paste the share link, use **Check**, and **Sync now**. The tab shows sync status, stale warnings and errors.
|
||||||
|
- Slideshow options.
|
||||||
|
- Photo uploads. Each upload is resized in the browser to 1920px plus a 64px backdrop thumbnail.
|
||||||
|
- Albums sync every 6 hours (and 15 seconds after start).
|
||||||
|
- Each sync caches up to 500 photos per album at 1920px under `/data/photos`, skips videos, and removes photos deleted from the album.
|
||||||
|
- `/api/health/photos` returns 503 when an enabled album has not synced for 48 hours.
|
||||||
|
- History and Restore now cover photo settings too. A restore re-syncs any album it brings back.
|
||||||
|
- The minimum admin password length is now 6.
|
||||||
|
|
||||||
|
# 1.1.0 (2026-11-04)
|
||||||
|
|
||||||
|
- Password-protected, LAN-only `/admin` screen for calendar links (masked, test, history/restore).
|
||||||
|
- Calendars are read from `/data/settings.json` (seeded once from the calendar secret).
|
||||||
|
- The supervisor drops privileges right after reading secrets. A front proxy on :3000 records the real client IP.
|
||||||
|
- `/photo-admin` redirects to `/admin#photos`, a placeholder for the upcoming photo frame.
|
||||||
|
|
||||||
|
# Release 1.0.0
|
||||||
|
|
||||||
|
Built and validated on 2026-11-04.
|
||||||
|
|
||||||
|
- Replaced the hosted Cloudflare/Vinext runtime with a standard production Next.js container.
|
||||||
|
- Added runtime-only calendar secrets and optional bearer authentication.
|
||||||
|
- Added configurable timezone and weather settings, bounded feed responses, redirect validation, health checks, and hardened Compose settings.
|
||||||
|
- Included cross-platform configuration, backup, restore, and migration documentation.
|
||||||
|
- Included NVIDIA Shield wrapper v1.4 (`com.dayline.tv`, version code 5) with local URL support, exact-origin enforcement, optional bearer injection, controlled private-LAN HTTP, internal-CA support, and hosted rollback.
|
||||||
|
- Preserved the existing app identity, signing certificate, private app data, and per-device schedule flag during in-place upgrades.
|
||||||
|
|
||||||
|
The current hosted dashboard and installed Shield applications are not changed merely by extracting this package. Follow `MIGRATION.md` when the internal server URL is ready.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
0800fd869da63e37610ecf417fefa701f70d06abe559909145fe0ceaee95415e app/api/config/route.ts
|
||||||
|
0faf67bca2879084e9259dca3e1ddb911a4714669f81b4b23684f07f275f4012 .dockerignore
|
||||||
|
13c79859132210fd64a0bc38f8a6528a33c9b4d74ee84f41596e2907a1cee533 scripts/backup.ps1
|
||||||
|
150dc144c5ff007803cf7660e174a0122200bfa5a4caea0bc32a56affa74065d RELEASE-NOTES.md
|
||||||
|
1834aab6e34acc20ae57b1a28d327c2d6fd1206a158f170dc5438251ed64038b app/api/health/route.ts
|
||||||
|
2247e2fb201b7e4d47de515b8c01c189cc2580a5ca53339b671674feef588713 shield/source/app/src/main/res/values/styles.xml
|
||||||
|
297319b72a62826c2beebcf67a3916c60b647635ff43e3bf1dcbc5b45ac9de21 shield/source/app/src/main/java/com/dayline/tv/ScheduleReceiver.java
|
||||||
|
350a753aff373eb283dc82c4722435f66f0cfeddb7ed3561251495a165ddfaa0 .env.example
|
||||||
|
3e8863288c2d43dd33d2a71de317dcf9198f09932ea8d61e0def1b846a865435 public/favicon.svg
|
||||||
|
4386abbca613dcc781ba5128acdb9c319ce29b76044854b2a947efcd11676235 shield/configure-shield.ps1
|
||||||
|
45de38714943c75220b77c95bac7356a5b81c519ac02f9055d23498d3b0fb725 app/api/headlines/route.ts
|
||||||
|
4906e019df437c43d409ddb6e3ee27295d93d1dce6ffdc870f017759acc31d92 app/layout.tsx
|
||||||
|
498ff029fd558e35acabab74d9db8280d688a2c2d7061d7a92799f57fefba9b4 scripts/configure.sh
|
||||||
|
4d0a6cf06aee2721b65d93228ce1feb0368e94cc7e01c0bb3ca25ae0b1a05559 shield/source/settings.gradle
|
||||||
|
50244a55348072213daeddf6f1ead7ae09720cbc27e5d3b4ecf36af72425325e secrets/.gitignore
|
||||||
|
5556c7b137de362d770d473ed7d9e83aeaec1aaee243a1dcacbd524e13d27aee app/api/weather/route.ts
|
||||||
|
59854984853104df5c353e2f681a15fc7924742f9a2e468c29af248dce45ce03 VERSION
|
||||||
|
60e0cf332d6ca64095b4e3b2d2e0f2f4089266effac57e18e5a1a56122e3bcfa app/globals.css
|
||||||
|
6bffd3afe4faf1626ad81d1313a358c24e765accbaad19076ac2850b3863942d tsconfig.json
|
||||||
|
6f51557541aaab18fa69b61fa3d5afbb74ae494abf7a248ea6043719fbf7e6e4 shield/source/app/src/test/java/com/dayline/tv/DashboardConfigTest.java
|
||||||
|
7017c28c214eca8e0b4ce01f5c7106aac631d0169e895caff8ba07b11f01a799 container/start.mjs
|
||||||
|
70250691787e467bbbca3d97530d60d6a07718dcdfa66006fc7a3d98e59e5a6f shield/source/app/src/main/res/drawable/ic_dayline.xml
|
||||||
|
73096b003fedba8491cfdaca71cbccb0246f765971c2935ffa1117e20dfb523d next-env.d.ts
|
||||||
|
73a5fe77f5aa78693aa65609c1d7ec19f8210bdefe843818e8a2fdaf0f6e96f4 shield/README.md
|
||||||
|
751ae6b9ae65e578a7f609a55f6bf8dd7269a4fd4602cffa8d3bed1fc3b1a1a2 MIGRATION.md
|
||||||
|
79a1a184c6c3e65c1189f20b36bacaa77a825a8f365a4907e645b43274f765c7 examples/nginx.conf
|
||||||
|
7b299d3d3b16699ddda397c0b2373b3af4f25f8fc9ccc9b3d9e64ef083bc1c21 postcss.config.mjs
|
||||||
|
7cd80af2e968119afd7b1803b6ac51b51ea010b8da57c3606ea1ef8071a86068 .npmrc
|
||||||
|
7defc63c4c4528c51442f2f772ff96970af9d86a213366e3f254d45fd265615a shield/source/app/src/main/java/com/dayline/tv/MainActivity.java
|
||||||
|
81577b6a53a2759c0559cdaf8e05e37ae1e91354c450333974fd32a105b64c9f shield/source/app/src/main/java/com/dayline/tv/DaylineScheduler.java
|
||||||
|
88da59fb1cbec9af0d81f2d654af5827a93b77232a82ec25b3528a4b3ebfed7a scripts/configure.ps1
|
||||||
|
8bf7d6fe745ad8082cd9c0edc4af97d8a4c547ff7aa6e2cb543467bfc70359f1 app/api/markets/route.ts
|
||||||
|
8cb3d6b6680242d9d18f3b0ea86c901c30a21e7646d8a85887c4b27791aad490 proxy.ts
|
||||||
|
92638a43482e2938e1a461ca5efdd7a8b7175e79ade8b9a79af288c3ed680fca .gitignore
|
||||||
|
95128f47a746eea8b710a1ba708f20e2f83f59cf8fc06d10cbf8abcc3bec56b8 shield/source/.gitignore
|
||||||
|
9779e64941ef00fdb75ecd9ccbf290bad2a4924ea0e1688bb1c617884e2753b6 shield/source/build.gradle
|
||||||
|
9ccf53e8c648736adbf95a27d70ace297c7e3b56d6e48e878194f98c51b36b21 shield/source/app/src/main/res/xml/data_extraction_rules.xml
|
||||||
|
9f71fa926f764c9d6579a62a530b783bbec894c409381cb6047c21ad0697f88e shield/source/app/src/main/res/xml/network_security_config.xml
|
||||||
|
a14c2d6fb5ba9925cba04633226a3b93e6c5566ca6b550dbfaead7ab3fa93b24 vendor/shadcn-tailwind-4.13.0.LICENSE.md
|
||||||
|
a1bad3fc1c31829cdb85451911e5cab98dac778c64842f8b58f38e15853e2ae5 scripts/restore.ps1
|
||||||
|
a52b91e3ed169cad985e6f70644bd13f6dd88fffaaa8d9f1ca47928418495412 shield/Hobbs-Family-Organizer-Local-v1.4.apk
|
||||||
|
a7e608e83587d8b9378d430dfa4e5f944ad031cf13598d9f26f190ed43e21992 compose.yaml
|
||||||
|
a87af2bc93b15147531d9ab768d5b47491ceaf0c62875295cf861e21c13ac711 Dockerfile
|
||||||
|
bc39cd36a8474161c64cdbbbcf70fae1865f5926597cd0085d8b433653b82144 scripts/restore.sh
|
||||||
|
bd57e28eba96a29974e2a15aa1c27820d17809a37dbc6a08d1c0a1ac828ac425 eslint.config.mjs
|
||||||
|
be5c30e2fe156ded97fc0e6ac42e2150eb6695d6b59d21a5237572e12295bd33 package-lock.json
|
||||||
|
c0d416f28accc223a693a47d38ba274d1f46c196309ee5d061a0f218ad8af1db shield/source/BUILDING.md
|
||||||
|
c9d434920bf4d3188dec353c295f1307521ad20299a7bb76cf6978a928381017 shield/source/app/proguard-rules.pro
|
||||||
|
c9f120abb901e2767512ba629a56a51dc3fd68b947eb8392ad36a90136248907 examples/Caddyfile
|
||||||
|
cc38bc495ca4e4d7fa2fe7c9c1112f36af7a63fbf4fc3e7a088e215942403385 shield/source/gradle.properties
|
||||||
|
cd4cc80f038ef1b4b7b9102fe701203684506fe003951b2ceebd0c19eff3632f app/api/calendar/route.ts
|
||||||
|
cf618a848c283c1ab6525fb9bb1a4c86285b12a8284bd2d947bde54514981027 app/page.tsx
|
||||||
|
dd6fcf0d3388de060d9d1822e87c80846c01eb2dcf32906c26d9c41b0a2c0d65 README.md
|
||||||
|
e316b4d4fe3185bfc25a8f88a094e99114db7a07e6d62f7e816e37cd077b626a next.config.mjs
|
||||||
|
e52c0aad9c35df9c5d7933e60315c3b66ae1ca3faee735d07afca499ce688dbe package.json
|
||||||
|
e7049dd99c0af5246d9ca4ae4f2ee14b361c21ad1136658b65c1821ebf2b4599 shield/source/app/src/main/res/values/strings.xml
|
||||||
|
e855e4c4653adcd315a47906679607b185fff8d4303310b4f847cf6271f92248 shield/source/app/build.gradle
|
||||||
|
e8bc6ecdd7bf7e86623adf2b788303efadba2927664e855312820bbc99c8f665 shield/source/app/src/main/java/com/dayline/tv/DashboardConfig.java
|
||||||
|
ea9853f33652e0c76ddfba1b7810ca2962ecbb1a334b4f76464c06c04453f8ea scripts/backup.sh
|
||||||
|
eb47dafa34b01137544c631e675b8f749f4431c09ca96e676b62ca123419c15e shield/source/app/src/main/AndroidManifest.xml
|
||||||
|
eb8de54b2f07ed84358822e00f8325e5b896f782b9f7eaccefb81bae853cfa7a secrets/calendar_ical_urls.example.json
|
||||||
|
f221d959e80b4738fba93b4b4f01472b11cccaf6043ba7f6f9376f6768dad28e vendor/shadcn-tailwind-4.13.0.css
|
||||||
|
f6e5c76522ab3d6c8dcbed63b59cde25e115b46786aae2e7a0b1746130a4a91b shield/restore-hosted-site.ps1
|
||||||
|
fae716f0342674996a6318ed43c4d07f66044d931c735101012f013b51acb0a0 shield/source/app/src/main/res/drawable/tv_banner.xml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.4.0
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { readAuth, writeAuth } from "./store";
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, keylen: number, options: object) => Promise<Buffer>;
|
||||||
|
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
||||||
|
const SESSION_COOKIE = "hfo_admin";
|
||||||
|
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
export const MIN_PASSWORD_LENGTH = 6;
|
||||||
|
|
||||||
|
// ---------- network policy ----------
|
||||||
|
|
||||||
|
function ipv4ToInt(ip: string) {
|
||||||
|
const parts = ip.split(".").map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
||||||
|
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inCidr(ip: string, cidr: string) {
|
||||||
|
const [base, bitsText] = cidr.trim().split("/");
|
||||||
|
const address = ipv4ToInt(ip);
|
||||||
|
const network = ipv4ToInt(base);
|
||||||
|
const bits = Number(bitsText ?? 32);
|
||||||
|
if (address === null || network === null || !(bits >= 0 && bits <= 32)) return false;
|
||||||
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||||
|
return (address & mask) === (network & mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin is reachable only from a direct LAN connection. The container's front proxy
|
||||||
|
* stamps x-hfo-client-ip with the TCP peer and x-hfo-forwarded when the request came
|
||||||
|
* through another proxy (e.g. a future Traefik route), which is always refused.
|
||||||
|
*/
|
||||||
|
export function isAdminNetworkAllowed(headers: Headers) {
|
||||||
|
if (headers.get("x-hfo-forwarded")) return false;
|
||||||
|
const ip = headers.get("x-hfo-client-ip") || "";
|
||||||
|
const allowed = (process.env.ADMIN_ALLOWED_CIDRS || "192.168.86.0/24,127.0.0.1/32").split(",");
|
||||||
|
return allowed.some((cidr) => inCidr(ip, cidr));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientIp(headers: Headers) {
|
||||||
|
return headers.get("x-hfo-client-ip") || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutating requests must come from this same origin (CSRF guard on top of SameSite=Strict). */
|
||||||
|
export function sameOrigin(request: Request) {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
if (!origin || !host) return false;
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- passwords ----------
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const key = await scrypt(password, salt, 32, SCRYPT);
|
||||||
|
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, stored: string) {
|
||||||
|
const [scheme, n, r, p, saltText, keyText] = stored.split("$");
|
||||||
|
if (scheme !== "scrypt" || !saltText || !keyText) return false;
|
||||||
|
const expected = Buffer.from(keyText, "base64url");
|
||||||
|
const actual = await scrypt(password, Buffer.from(saltText, "base64url"), expected.length, {
|
||||||
|
N: Number(n),
|
||||||
|
r: Number(r),
|
||||||
|
p: Number(p),
|
||||||
|
maxmem: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- login throttling (in memory, per client IP) ----------
|
||||||
|
|
||||||
|
const failures = new Map<string, { count: number; lockedUntil: number }>();
|
||||||
|
|
||||||
|
export function loginLockedFor(ip: string) {
|
||||||
|
const entry = failures.get(ip);
|
||||||
|
return entry && entry.lockedUntil > Date.now() ? Math.ceil((entry.lockedUntil - Date.now()) / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordLoginFailure(ip: string) {
|
||||||
|
const entry = failures.get(ip) ?? { count: 0, lockedUntil: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
if (entry.count >= 5) entry.lockedUntil = Date.now() + Math.min(15 * 60, 30 * 2 ** (entry.count - 5)) * 1000;
|
||||||
|
failures.set(ip, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLoginFailures(ip: string) {
|
||||||
|
failures.delete(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- sessions ----------
|
||||||
|
|
||||||
|
function sign(payload: string, secret: string) {
|
||||||
|
return createHmac("sha256", secret).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookie(secret: string) {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + SESSION_SECONDS })).toString("base64url");
|
||||||
|
const value = `${payload}.${sign(payload, secret)}`;
|
||||||
|
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_SECONDS}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearedSessionCookie() {
|
||||||
|
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(headers: Headers, name: string) {
|
||||||
|
for (const part of (headers.get("cookie") || "").split(";")) {
|
||||||
|
const [key, ...rest] = part.trim().split("=");
|
||||||
|
if (key === name) return rest.join("=");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasValidSession(headers: Headers) {
|
||||||
|
const value = readCookie(headers, SESSION_COOKIE);
|
||||||
|
const [payload, signature] = value.split(".");
|
||||||
|
if (!payload || !signature) return false;
|
||||||
|
const auth = await readAuth();
|
||||||
|
if (!auth.passwordHash) return false;
|
||||||
|
const expected = Buffer.from(sign(payload, auth.sessionSecret));
|
||||||
|
const actual = Buffer.from(signature);
|
||||||
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
|
||||||
|
try {
|
||||||
|
const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp: number };
|
||||||
|
return typeof exp === "number" && exp > Date.now() / 1000;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets a new password and rotates the session secret, signing out every other session. */
|
||||||
|
export async function setPassword(password: string) {
|
||||||
|
const sessionSecret = randomBytes(32).toString("base64url");
|
||||||
|
await writeAuth({ passwordHash: await hashPassword(password), sessionSecret });
|
||||||
|
return sessionCookie(sessionSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- route helpers ----------
|
||||||
|
|
||||||
|
export function json(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
|
||||||
|
return Response.json(body, { status, headers: { "Cache-Control": "no-store", ...extraHeaders } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an error Response when the caller may not use admin APIs, otherwise null. */
|
||||||
|
export async function requireAdmin(request: Request, { mutating = false } = {}) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
|
||||||
|
if (mutating && !sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
|
||||||
|
if (!(await hasValidSession(request.headers))) return json({ error: "Sign in required." }, 401);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonBody<T>(request: Request): Promise<T | null> {
|
||||||
|
if (!(request.headers.get("content-type") || "").includes("application/json")) return null;
|
||||||
|
try {
|
||||||
|
return (await request.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import ICAL from "ical.js";
|
||||||
|
|
||||||
|
import type { CalendarTone } from "./store";
|
||||||
|
|
||||||
|
export type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
calendar: string;
|
||||||
|
title: string;
|
||||||
|
location?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allDay: boolean;
|
||||||
|
tone: CalendarTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxFeedBytes = 5 * 1024 * 1024;
|
||||||
|
const maxOccurrencesPerEvent = 10_000;
|
||||||
|
|
||||||
|
function cleanText(value: unknown, fallback: string, maxLength: number) {
|
||||||
|
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDate(value: ICAL.Time) {
|
||||||
|
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function organizerDate(date: Date) {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
|
||||||
|
return `${value("year")}-${value("month")}-${value("day")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCalendarDays(isoDate: string, days: number) {
|
||||||
|
const [year, month, day] = isoDate.split("-").map(Number);
|
||||||
|
const value = new Date(Date.UTC(year, month - 1, day + days));
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashId(value: string) {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLimitedText(response: Response) {
|
||||||
|
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
|
||||||
|
if (!response.body) return "";
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let total = 0;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
total += chunk.value.byteLength;
|
||||||
|
if (total > maxFeedBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error("Calendar feed is too large");
|
||||||
|
}
|
||||||
|
text += decoder.decode(chunk.value, { stream: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return text + decoder.decode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentString(component: ICAL.Component, name: string) {
|
||||||
|
const value = component.getFirstPropertyValue(name);
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCancelled(event: ICAL.Event) {
|
||||||
|
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach(component: ICAL.Component) {
|
||||||
|
return new ICAL.Component(structuredClone(component.toJSON()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIsInWindow(
|
||||||
|
start: ICAL.Time,
|
||||||
|
end: ICAL.Time,
|
||||||
|
windowStart: number,
|
||||||
|
horizon: number,
|
||||||
|
windowStartDate: string,
|
||||||
|
horizonDate: string,
|
||||||
|
) {
|
||||||
|
if (start.isDate) {
|
||||||
|
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
|
||||||
|
}
|
||||||
|
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
|
||||||
|
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeOccurrence(
|
||||||
|
event: ICAL.Event,
|
||||||
|
start: ICAL.Time,
|
||||||
|
end: ICAL.Time,
|
||||||
|
calendar: string,
|
||||||
|
tone: CalendarTone,
|
||||||
|
feedIndex: number,
|
||||||
|
) {
|
||||||
|
const allDay = start.isDate;
|
||||||
|
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
|
||||||
|
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
|
||||||
|
const uid = cleanText(event.uid, "event", 180);
|
||||||
|
const location = cleanText(event.location, "", 120);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hashId(`${feedIndex}|${uid}|${startValue}`),
|
||||||
|
calendar,
|
||||||
|
title: cleanText(event.summary, "Busy", 140),
|
||||||
|
...(location ? { location } : {}),
|
||||||
|
start: startValue,
|
||||||
|
end: endValue,
|
||||||
|
allDay,
|
||||||
|
tone,
|
||||||
|
} satisfies CalendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedOptions = { feedIndex: number; tone: CalendarTone; nameOverride: string };
|
||||||
|
|
||||||
|
function expandCalendar(
|
||||||
|
ics: string,
|
||||||
|
{ feedIndex, tone, nameOverride }: FeedOptions,
|
||||||
|
windowStart: number,
|
||||||
|
horizon: number,
|
||||||
|
windowStartDate: string,
|
||||||
|
horizonDate: string,
|
||||||
|
) {
|
||||||
|
const root = new ICAL.Component(ICAL.parse(ics));
|
||||||
|
for (const timezone of root.getAllSubcomponents("vtimezone")) {
|
||||||
|
try {
|
||||||
|
ICAL.TimezoneService.register(timezone);
|
||||||
|
} catch {
|
||||||
|
// A feed may repeat a timezone that was already registered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
|
||||||
|
const calendar = nameOverride || (sourceName.includes("@") ? "Personal" : sourceName);
|
||||||
|
const components = root.getAllSubcomponents("vevent");
|
||||||
|
const exceptions = new Map<string, ICAL.Component[]>();
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
if (!component.hasProperty("recurrence-id")) continue;
|
||||||
|
const uid = componentString(component, "uid");
|
||||||
|
if (!uid) continue;
|
||||||
|
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: CalendarEvent[] = [];
|
||||||
|
for (const component of components) {
|
||||||
|
if (component.hasProperty("recurrence-id")) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = new ICAL.Event(detach(component));
|
||||||
|
for (const exception of exceptions.get(event.uid) ?? []) {
|
||||||
|
event.relateException(detach(exception));
|
||||||
|
}
|
||||||
|
if (isCancelled(event)) continue;
|
||||||
|
|
||||||
|
if (!event.isRecurring()) {
|
||||||
|
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
|
||||||
|
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator = event.iterator();
|
||||||
|
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
|
||||||
|
const occurrence = iterator.next();
|
||||||
|
if (!occurrence) break;
|
||||||
|
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
|
||||||
|
|
||||||
|
const details = event.getOccurrenceDetails(occurrence);
|
||||||
|
if (isCancelled(details.item)) continue;
|
||||||
|
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
|
||||||
|
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore a malformed event while keeping the rest of the calendar usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { calendar, sourceName, eventCount: components.length, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCalendar(
|
||||||
|
url: string,
|
||||||
|
options: FeedOptions,
|
||||||
|
windowStart: number,
|
||||||
|
horizon: number,
|
||||||
|
windowStartDate: string,
|
||||||
|
horizonDate: string,
|
||||||
|
) {
|
||||||
|
let currentUrl = url;
|
||||||
|
let response: Response | undefined;
|
||||||
|
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
|
||||||
|
const validatedUrl = new URL(currentUrl);
|
||||||
|
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
|
||||||
|
throw new Error("Calendar redirect host is not allowed");
|
||||||
|
}
|
||||||
|
response = await fetch(validatedUrl, {
|
||||||
|
headers: { Accept: "text/calendar" },
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
|
||||||
|
currentUrl = new URL(location, validatedUrl).toString();
|
||||||
|
}
|
||||||
|
if (!response) throw new Error("Calendar feed request failed");
|
||||||
|
if (!response.ok) throw new Error("Calendar feed request failed");
|
||||||
|
return expandCalendar(await readLimitedText(response), options, windowStart, horizon, windowStartDate, horizonDate);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { type PhotoAlbum, readSettings } from "./store";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Google Photos shared albums. Google removed third-party read access to user albums from the
|
||||||
|
* Library API on 2025-03-31, so this reads the public share page the same way a browser does:
|
||||||
|
* the album's items are embedded as JSON in an AF_initDataCallback({key: 'ds:1', ...}) block.
|
||||||
|
* This is unofficial and may break when Google changes the page. Cached photos keep showing
|
||||||
|
* if it does, and the album reports lastError plus a stale lastSuccessAt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_PHOTOS_PER_ALBUM = 500;
|
||||||
|
export const MAX_UPLOADS = 500;
|
||||||
|
export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
||||||
|
const PHOTO_EDGE = 1920;
|
||||||
|
const THUMB_EDGE = 64;
|
||||||
|
/** Gallery tile (the Shield app's grid view). */
|
||||||
|
const TILE_EDGE = 400;
|
||||||
|
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
|
||||||
|
const VIDEO_MARKER = "76647426";
|
||||||
|
|
||||||
|
export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number; tile?: boolean };
|
||||||
|
export type AlbumIndex = {
|
||||||
|
title: string;
|
||||||
|
firstAttemptAt: number;
|
||||||
|
lastAttemptAt: number;
|
||||||
|
lastSuccessAt: number | null;
|
||||||
|
lastError: string;
|
||||||
|
truncated: boolean;
|
||||||
|
photos: CachedPhoto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const photosRoot = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "photos");
|
||||||
|
export const UPLOADS_DIR = "uploads";
|
||||||
|
const dirFor = (name: string) => path.join(photosRoot(), name);
|
||||||
|
const indexPath = (name: string) => path.join(dirFor(name), "index.json");
|
||||||
|
|
||||||
|
export function validPhotoDir(name: string) {
|
||||||
|
return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name);
|
||||||
|
}
|
||||||
|
export function validPhotoFile(name: string) {
|
||||||
|
return /^[a-f0-9]{20}(\.t|\.m)?\.jpg$/.test(name);
|
||||||
|
}
|
||||||
|
export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file);
|
||||||
|
|
||||||
|
function emptyIndex(): AlbumIndex {
|
||||||
|
return { title: "", firstAttemptAt: 0, lastAttemptAt: 0, lastSuccessAt: null, lastError: "", truncated: false, photos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readIndex(name: string): Promise<AlbumIndex> {
|
||||||
|
try {
|
||||||
|
return { ...emptyIndex(), ...(JSON.parse(await readFile(indexPath(name), "utf8")) as Partial<AlbumIndex>) };
|
||||||
|
} catch {
|
||||||
|
return emptyIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(name: string, index: AlbumIndex) {
|
||||||
|
await mkdir(dirFor(name), { recursive: true, mode: 0o700 });
|
||||||
|
const temporary = `${indexPath(name)}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, JSON.stringify(index), { mode: 0o600 });
|
||||||
|
await rename(temporary, indexPath(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFileAtomic(file: string, data: Uint8Array) {
|
||||||
|
const temporary = `${file}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, data, { mode: 0o600 });
|
||||||
|
await rename(temporary, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJpeg(data: Uint8Array) {
|
||||||
|
return data.length > 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLimited(response: Response, maxBytes: number) {
|
||||||
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (declared > maxBytes) throw new Error("Response too large");
|
||||||
|
if (!response.body) return new Uint8Array();
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error("Response too large");
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
out.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scraping ----------
|
||||||
|
|
||||||
|
type ScrapedItem = { googleId: string; baseUrl: string; width: number; height: number; takenAt: number | null; video: boolean };
|
||||||
|
export type ScrapedAlbum = { title: string; items: ScrapedItem[]; hasMorePages: boolean };
|
||||||
|
|
||||||
|
const ALLOWED_PAGE_HOSTS = new Set(["photos.app.goo.gl", "photos.google.com"]);
|
||||||
|
|
||||||
|
async function fetchAlbumPage(shareUrl: string) {
|
||||||
|
let current = shareUrl;
|
||||||
|
for (let hop = 0; hop <= 5; hop += 1) {
|
||||||
|
const url = new URL(current);
|
||||||
|
if (url.protocol !== "https:" || !ALLOWED_PAGE_HOSTS.has(url.hostname)) throw new Error(`Album link redirected to an unexpected host (${url.hostname})`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", Accept: "text/html" },
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
});
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (!location) throw new Error("Album link redirect had no target");
|
||||||
|
current = new URL(location, url).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.status === 404) throw new Error("Album not found. Was the share link removed?");
|
||||||
|
if (!response.ok) throw new Error(`Google Photos returned HTTP ${response.status}`);
|
||||||
|
return new TextDecoder().decode(await readLimited(response, 20 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
throw new Error("Album link redirected too many times");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAlbumPage(html: string): ScrapedAlbum {
|
||||||
|
const match = /AF_initDataCallback\(\{key: 'ds:1'[\s\S]*?data:([\s\S]*?), sideChannel: \{\}\}\);<\/script>/.exec(html);
|
||||||
|
if (!match) throw new Error("Could not find the photo list on the album page (Google may have changed the page format)");
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(match[1]);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Could not read the photo list on the album page (Google may have changed the page format)");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data) || !Array.isArray(data[1])) throw new Error("Unexpected album data format");
|
||||||
|
const meta = Array.isArray(data[3]) ? data[3] : [];
|
||||||
|
const items: ScrapedItem[] = [];
|
||||||
|
for (const raw of data[1] as unknown[]) {
|
||||||
|
if (!Array.isArray(raw) || typeof raw[0] !== "string" || !Array.isArray(raw[1])) continue;
|
||||||
|
const media = raw[1] as unknown[];
|
||||||
|
const baseUrl = String(media[0] ?? "");
|
||||||
|
let host = "";
|
||||||
|
try {
|
||||||
|
host = new URL(baseUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (host !== "lh3.googleusercontent.com") continue;
|
||||||
|
const extras = raw[raw.length - 1];
|
||||||
|
items.push({
|
||||||
|
googleId: raw[0],
|
||||||
|
baseUrl,
|
||||||
|
width: Number(media[1]) || 0,
|
||||||
|
height: Number(media[2]) || 0,
|
||||||
|
takenAt: typeof raw[2] === "number" ? raw[2] : null,
|
||||||
|
video: Boolean(extras && typeof extras === "object" && !Array.isArray(extras) && VIDEO_MARKER in extras),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: typeof meta[1] === "string" ? meta[1] : "",
|
||||||
|
items,
|
||||||
|
hasMorePages: typeof data[2] === "string" && data[2].length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeAlbum(shareUrl: string) {
|
||||||
|
return parseAlbumPage(await fetchAlbumPage(shareUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(baseUrl: string, edge: number, maxBytes: number) {
|
||||||
|
const url = new URL(`${baseUrl}=w${edge}-h${edge}`);
|
||||||
|
if (url.hostname !== "lh3.googleusercontent.com") throw new Error("Unexpected image host");
|
||||||
|
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, redirect: "error", signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!response.ok) throw new Error(`Image download failed (HTTP ${response.status})`);
|
||||||
|
const data = await readLimited(response, maxBytes);
|
||||||
|
if (!isJpeg(data)) throw new Error("Image was not a JPEG");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoKey = (googleId: string) => createHash("sha1").update(googleId).digest("hex").slice(0, 20);
|
||||||
|
|
||||||
|
// ---------- syncing ----------
|
||||||
|
|
||||||
|
const syncing = new Set<string>();
|
||||||
|
export const isSyncing = (albumId: string) => syncing.has(albumId);
|
||||||
|
|
||||||
|
async function mapLimit<T>(values: T[], limit: number, task: (value: T) => Promise<void>) {
|
||||||
|
let next = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||||
|
while (next < values.length) {
|
||||||
|
const value = values[next];
|
||||||
|
next += 1;
|
||||||
|
await task(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAlbum(album: PhotoAlbum) {
|
||||||
|
if (syncing.has(album.id)) return readIndex(album.id);
|
||||||
|
syncing.add(album.id);
|
||||||
|
const index = await readIndex(album.id);
|
||||||
|
const now = Date.now();
|
||||||
|
index.lastAttemptAt = now;
|
||||||
|
if (!index.firstAttemptAt) index.firstAttemptAt = now;
|
||||||
|
try {
|
||||||
|
const scraped = await scrapeAlbum(album.shareUrl);
|
||||||
|
const wanted = scraped.items.filter((item) => !item.video).slice(0, MAX_PHOTOS_PER_ALBUM);
|
||||||
|
if (!wanted.length && index.photos.length) throw new Error("Album page listed no photos; keeping the cached ones");
|
||||||
|
await mkdir(dirFor(album.id), { recursive: true, mode: 0o700 });
|
||||||
|
const existing = new Set((await readdir(dirFor(album.id))).filter(validPhotoFile));
|
||||||
|
const photos: CachedPhoto[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
await mapLimit(wanted, 4, async (item) => {
|
||||||
|
const key = photoKey(item.googleId);
|
||||||
|
try {
|
||||||
|
if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024));
|
||||||
|
if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024));
|
||||||
|
// The tile is optional: the gallery falls back to the full photo if it can't be fetched.
|
||||||
|
let tile = existing.has(`${key}.m.jpg`);
|
||||||
|
if (!tile) {
|
||||||
|
try {
|
||||||
|
await writeFileAtomic(photoFilePath(album.id, `${key}.m.jpg`), await downloadImage(item.baseUrl, TILE_EDGE, 2 * 1024 * 1024));
|
||||||
|
tile = true;
|
||||||
|
} catch {
|
||||||
|
tile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height, tile });
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`, `${photo.key}.m.jpg`]));
|
||||||
|
for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined);
|
||||||
|
|
||||||
|
const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position]));
|
||||||
|
photos.sort((left, right) => (order.get(left.key) ?? 0) - (order.get(right.key) ?? 0));
|
||||||
|
index.title = scraped.title;
|
||||||
|
index.photos = photos;
|
||||||
|
index.truncated = scraped.hasMorePages || scraped.items.length > MAX_PHOTOS_PER_ALBUM;
|
||||||
|
if (failures.length && !photos.length) throw new Error(`Every download failed: ${failures[0]}`);
|
||||||
|
index.lastSuccessAt = Date.now();
|
||||||
|
index.lastError = failures.length ? `${failures.length} photo(s) failed to download: ${failures[0]}` : "";
|
||||||
|
console.log(`[photos] synced "${scraped.title}" (${album.id.slice(0, 8)}): ${photos.length} photos${failures.length ? `, ${failures.length} failed` : ""}`);
|
||||||
|
} catch (error) {
|
||||||
|
index.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[photos] sync failed for ${album.id.slice(0, 8)}: ${index.lastError}`);
|
||||||
|
} finally {
|
||||||
|
await writeIndex(album.id, index).catch(() => undefined);
|
||||||
|
syncing.delete(album.id);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes cache folders of albums that are no longer in settings. */
|
||||||
|
export async function removeOrphanedAlbumFolders() {
|
||||||
|
const { photos } = await readSettings();
|
||||||
|
await mkdir(photosRoot(), { recursive: true, mode: 0o700 });
|
||||||
|
const known = new Set(photos.albums.map((album) => album.id));
|
||||||
|
for (const entry of await readdir(photosRoot())) {
|
||||||
|
if (entry !== UPLOADS_DIR && validPhotoDir(entry) && !known.has(entry) && !syncing.has(entry)) {
|
||||||
|
await rm(dirFor(entry), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncAllRunning: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Syncs every enabled album and deletes cache folders of albums that were removed. */
|
||||||
|
export function syncAll() {
|
||||||
|
if (syncAllRunning) return syncAllRunning;
|
||||||
|
syncAllRunning = (async () => {
|
||||||
|
await removeOrphanedAlbumFolders();
|
||||||
|
const { photos } = await readSettings();
|
||||||
|
for (const album of photos.albums.filter((entry) => entry.enabled)) await syncAlbum(album);
|
||||||
|
})()
|
||||||
|
.catch((error) => console.warn(`[photos] sync run failed: ${error instanceof Error ? error.message : String(error)}`))
|
||||||
|
.finally(() => {
|
||||||
|
syncAllRunning = null;
|
||||||
|
});
|
||||||
|
return syncAllRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
let schedulerStarted = false;
|
||||||
|
export function startPhotoScheduler() {
|
||||||
|
if (schedulerStarted) return;
|
||||||
|
schedulerStarted = true;
|
||||||
|
const run = () => void syncAll();
|
||||||
|
setTimeout(run, 15_000).unref();
|
||||||
|
setInterval(run, SYNC_INTERVAL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- uploads ----------
|
||||||
|
|
||||||
|
let uploadQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
function serializedUploads<T>(task: () => Promise<T>) {
|
||||||
|
const run = uploadQueue.then(task, task);
|
||||||
|
uploadQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUpload(photo: Uint8Array, thumb: Uint8Array, tile: Uint8Array | null, meta: { takenAt: number | null; width: number; height: number }) {
|
||||||
|
return serializedUploads(async () => {
|
||||||
|
const index = await readIndex(UPLOADS_DIR);
|
||||||
|
if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`);
|
||||||
|
const key = createHash("sha1").update(photo).digest("hex").slice(0, 20);
|
||||||
|
if (index.photos.some((entry) => entry.key === key)) return { key, duplicate: true };
|
||||||
|
await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 });
|
||||||
|
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo);
|
||||||
|
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb);
|
||||||
|
if (tile) await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.m.jpg`), tile);
|
||||||
|
index.photos.push({ key, ...meta, tile: Boolean(tile) });
|
||||||
|
index.title = "Uploaded photos";
|
||||||
|
index.lastSuccessAt = Date.now();
|
||||||
|
await writeIndex(UPLOADS_DIR, index);
|
||||||
|
return { key, duplicate: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeUpload(key: string) {
|
||||||
|
return serializedUploads(async () => {
|
||||||
|
if (!/^[a-f0-9]{20}$/.test(key)) return false;
|
||||||
|
const index = await readIndex(UPLOADS_DIR);
|
||||||
|
const before = index.photos.length;
|
||||||
|
index.photos = index.photos.filter((entry) => entry.key !== key);
|
||||||
|
if (index.photos.length === before) return false;
|
||||||
|
await writeIndex(UPLOADS_DIR, index);
|
||||||
|
for (const file of [`${key}.jpg`, `${key}.t.jpg`, `${key}.m.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- what the slideshow shows ----------
|
||||||
|
|
||||||
|
export type SlidePhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
|
||||||
|
|
||||||
|
export async function currentSlideshow() {
|
||||||
|
const settings = await readSettings();
|
||||||
|
const { albums, ...display } = settings.photos;
|
||||||
|
const sources = albums.filter((album) => album.enabled).map((album) => album.id);
|
||||||
|
if (display.includeUploads) sources.push(UPLOADS_DIR);
|
||||||
|
const photos: SlidePhoto[] = [];
|
||||||
|
const fingerprint = createHash("sha1").update(settings.updatedAt);
|
||||||
|
for (const source of sources) {
|
||||||
|
const index = await readIndex(source);
|
||||||
|
fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}:${index.photos.filter((photo) => photo.tile).length}`);
|
||||||
|
for (const photo of index.photos) {
|
||||||
|
photos.push({
|
||||||
|
src: `/api/photos/img/${source}/${photo.key}.jpg`,
|
||||||
|
thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`,
|
||||||
|
tile: photo.tile ? `/api/photos/img/${source}/${photo.key}.m.jpg` : `/api/photos/img/${source}/${photo.key}.jpg`,
|
||||||
|
takenAt: photo.takenAt,
|
||||||
|
width: photo.width,
|
||||||
|
height: photo.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { display, photos, version: fingerprint.digest("hex").slice(0, 16) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Albums whose last successful sync is older than 48h (or never succeeded within 48h of being added). */
|
||||||
|
export async function staleAlbums() {
|
||||||
|
const { photos } = await readSettings();
|
||||||
|
const now = Date.now();
|
||||||
|
const stale: { id: string; title: string; lastSuccessAt: number | null; lastError: string }[] = [];
|
||||||
|
for (const album of photos.albums.filter((entry) => entry.enabled)) {
|
||||||
|
const index = await readIndex(album.id);
|
||||||
|
const reference = index.lastSuccessAt ?? (index.firstAttemptAt || now);
|
||||||
|
if (now - reference > STALE_AFTER_MS) stale.push({ id: album.id, title: album.name || index.title, lastSuccessAt: index.lastSuccessAt, lastError: index.lastError });
|
||||||
|
}
|
||||||
|
return stale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const CALENDAR_TONES = ["cyan", "amber", "violet", "emerald", "rose"] as const;
|
||||||
|
export type CalendarTone = (typeof CALENDAR_TONES)[number];
|
||||||
|
export const MAX_CALENDARS = 10;
|
||||||
|
const HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
export type CalendarSetting = {
|
||||||
|
id: string;
|
||||||
|
/** Display name override; empty means "use the feed's own name". */
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
tone: CalendarTone;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotoAlbum = {
|
||||||
|
id: string;
|
||||||
|
/** Display name override; empty means "use the album's own title". */
|
||||||
|
name: string;
|
||||||
|
shareUrl: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotoDisplay = {
|
||||||
|
intervalSeconds: number;
|
||||||
|
shuffle: boolean;
|
||||||
|
showDate: boolean;
|
||||||
|
fit: "contain-blur" | "cover";
|
||||||
|
includeUploads: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotoSettings = PhotoDisplay & { albums: PhotoAlbum[] };
|
||||||
|
|
||||||
|
export const MAX_ALBUMS = 10;
|
||||||
|
export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = {
|
||||||
|
intervalSeconds: 20,
|
||||||
|
shuffle: true,
|
||||||
|
showDate: true,
|
||||||
|
fit: "contain-blur",
|
||||||
|
includeUploads: true,
|
||||||
|
albums: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-TV morning routine, run by the Shield photo app (com.dayline.photos) on each TV:
|
||||||
|
* wake the TV, set the volume, start a YouTube Music playlist, then bring up the calendar.
|
||||||
|
* `id` is the TV's Android ID, reported by the app when it checks in.
|
||||||
|
*/
|
||||||
|
export type TvSetting = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wake: { enabled: boolean; time: string; days: number[] };
|
||||||
|
music: { enabled: boolean; playlistId: string };
|
||||||
|
volume: { enabled: boolean; level: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_TVS = 10;
|
||||||
|
export const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
export function validTvId(value: unknown) {
|
||||||
|
return typeof value === "string" && /^[a-f0-9]{8,32}$/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accepts a YouTube / YouTube Music playlist link or a bare playlist ID; returns the ID. */
|
||||||
|
export function validPlaylistId(value: unknown) {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
if (/^[A-Za-z0-9_-]{10,64}$/.test(text)) return text;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:" || !["music.youtube.com", "www.youtube.com", "youtube.com", "m.youtube.com"].includes(url.hostname)) return null;
|
||||||
|
const list = url.searchParams.get("list") ?? "";
|
||||||
|
return /^[A-Za-z0-9_-]{10,64}$/.test(list) ? list : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playlistUrl = (playlistId: string) => `https://music.youtube.com/playlist?list=${playlistId}`;
|
||||||
|
|
||||||
|
function validTime(value: unknown) {
|
||||||
|
return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTvs(raw: unknown): TvSetting[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (Array.isArray(raw) ? raw : [])
|
||||||
|
.map((entry) => {
|
||||||
|
const id = validTvId(entry?.id);
|
||||||
|
if (!id || seen.has(id)) return null;
|
||||||
|
seen.add(id);
|
||||||
|
const days = Array.isArray(entry.wake?.days)
|
||||||
|
? [...new Set((entry.wake.days as unknown[]).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort()
|
||||||
|
: ALL_DAYS;
|
||||||
|
const level = Number(entry.volume?.level);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "",
|
||||||
|
wake: { enabled: entry.wake?.enabled === true, time: validTime(entry.wake?.time) ?? "06:30", days },
|
||||||
|
music: { enabled: entry.music?.enabled === true, playlistId: validPlaylistId(entry.music?.playlistId) ?? "" },
|
||||||
|
volume: { enabled: entry.volume?.enabled === true, level: Number.isFinite(level) ? Math.min(100, Math.max(0, Math.round(level))) : 40 },
|
||||||
|
} satisfies TvSetting;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is TvSetting => entry !== null)
|
||||||
|
.slice(0, MAX_TVS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
schema: 1;
|
||||||
|
updatedAt: string;
|
||||||
|
calendars: CalendarSetting[];
|
||||||
|
photos: PhotoSettings;
|
||||||
|
tvs: TvSetting[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Accepts photos.app.goo.gl short links and photos.google.com/share links. */
|
||||||
|
export function validAlbumUrl(value: unknown) {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(String(value).trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:") return null;
|
||||||
|
if (url.hostname === "photos.app.goo.gl" && /^\/[A-Za-z0-9_-]{6,64}$/.test(url.pathname)) return `https://photos.app.goo.gl${url.pathname}`;
|
||||||
|
if (url.hostname === "photos.google.com" && /^\/share\/[A-Za-z0-9_-]{10,200}$/.test(url.pathname)) return url.toString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskAlbumUrl(url: string) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const id = parsed.pathname.split("/").pop() || "";
|
||||||
|
return `${parsed.hostname}/…${id.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhotos(raw: unknown): PhotoSettings {
|
||||||
|
const value = (raw ?? {}) as Partial<PhotoSettings>;
|
||||||
|
const interval = Number(value.intervalSeconds);
|
||||||
|
return {
|
||||||
|
intervalSeconds: Number.isFinite(interval) ? Math.min(3600, Math.max(5, Math.round(interval))) : DEFAULT_PHOTO_SETTINGS.intervalSeconds,
|
||||||
|
shuffle: value.shuffle !== false,
|
||||||
|
showDate: value.showDate !== false,
|
||||||
|
fit: value.fit === "cover" ? "cover" : "contain-blur",
|
||||||
|
includeUploads: value.includeUploads !== false,
|
||||||
|
albums: (Array.isArray(value.albums) ? value.albums : [])
|
||||||
|
.map((entry) => {
|
||||||
|
const shareUrl = validAlbumUrl(entry?.shareUrl);
|
||||||
|
if (!shareUrl) return null;
|
||||||
|
return {
|
||||||
|
id: typeof entry.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(entry.id) ? entry.id : randomUUID(),
|
||||||
|
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
|
||||||
|
shareUrl,
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
} satisfies PhotoAlbum;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is PhotoAlbum => entry !== null)
|
||||||
|
.slice(0, MAX_ALBUMS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
passwordHash: string;
|
||||||
|
sessionSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataDir = () => process.env.ORGANIZER_DATA_DIR || "/data";
|
||||||
|
const settingsPath = () => path.join(dataDir(), "settings.json");
|
||||||
|
const authPath = () => path.join(dataDir(), "auth.json");
|
||||||
|
const historyDir = () => path.join(dataDir(), "history");
|
||||||
|
|
||||||
|
export function validCalendarUrl(value: unknown) {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(String(value).trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) return null;
|
||||||
|
if (!/^\/calendar\/ical\/[A-Za-z0-9._~%+@-]+\/(private-[A-Za-z0-9._~%+@-]+|public)\/basic\.ics$/.test(url.pathname)) return null;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "…/ical/fam…%40group…/private-••••3854/basic.ics" — enough to tell feeds apart, not enough to use one. */
|
||||||
|
export function maskCalendarUrl(url: string) {
|
||||||
|
const match = /\/calendar\/ical\/([^/]+)\/(private-[^/]+|public)\/basic\.ics$/.exec(new URL(url).pathname);
|
||||||
|
if (!match) return "calendar.google.com/…";
|
||||||
|
const owner = decodeURIComponent(match[1]);
|
||||||
|
const shortOwner = owner.length > 18 ? `${owner.slice(0, 8)}…${owner.slice(owner.indexOf("@"))}` : owner;
|
||||||
|
const key = match[2] === "public" ? "public" : `private-••••${match[2].slice(-4)}`;
|
||||||
|
return `${shortOwner} · ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettings(raw: unknown): Settings {
|
||||||
|
const value = (raw ?? {}) as Partial<Settings>;
|
||||||
|
const calendars = Array.isArray(value.calendars) ? value.calendars : [];
|
||||||
|
return {
|
||||||
|
schema: 1,
|
||||||
|
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date(0).toISOString(),
|
||||||
|
calendars: calendars
|
||||||
|
.map((entry) => {
|
||||||
|
const url = validCalendarUrl(entry?.url);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
id: typeof entry.id === "string" && entry.id ? entry.id : randomUUID(),
|
||||||
|
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
|
||||||
|
url,
|
||||||
|
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
} satisfies CalendarSetting;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is CalendarSetting => entry !== null)
|
||||||
|
.slice(0, MAX_CALENDARS),
|
||||||
|
photos: normalizePhotos(value.photos),
|
||||||
|
tvs: normalizeTvs(value.tvs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(file: string) {
|
||||||
|
return JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(file: string, value: unknown) {
|
||||||
|
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
await rename(temporary, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All writes go through one queue so concurrent saves cannot interleave.
|
||||||
|
let writeQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
function serialized<T>(task: () => Promise<T>) {
|
||||||
|
const run = writeQueue.then(task, task);
|
||||||
|
writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSettings(): Promise<Settings> {
|
||||||
|
try {
|
||||||
|
return normalizeSettings(await readJson(settingsPath()));
|
||||||
|
} catch {
|
||||||
|
return normalizeSettings({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
|
||||||
|
|
||||||
|
async function pruneHistory() {
|
||||||
|
const files = (await readdir(historyDir())).filter((name) => name.endsWith(".json")).sort();
|
||||||
|
for (const name of files.slice(0, Math.max(0, files.length - HISTORY_LIMIT))) {
|
||||||
|
await unlink(path.join(historyDir(), name)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves new settings (only the parts given), first snapshotting the current ones into history. */
|
||||||
|
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos" | "tvs">>, note: string) {
|
||||||
|
return serialized(async () => {
|
||||||
|
const current = await readSettings();
|
||||||
|
const calendars = patch.calendars ?? current.calendars;
|
||||||
|
const photos = patch.photos ?? current.photos;
|
||||||
|
const tvs = patch.tvs ?? current.tvs;
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
const snapshotId = `${stamp.replace(/[:.]/g, "-")}`;
|
||||||
|
await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note });
|
||||||
|
await pruneHistory();
|
||||||
|
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos, tvs });
|
||||||
|
await writeJsonAtomic(settingsPath(), next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHistory(): Promise<HistoryEntry[]> {
|
||||||
|
const files = (await readdir(historyDir()).catch(() => [] as string[])).filter((name) => name.endsWith(".json")).sort().reverse();
|
||||||
|
const entries: HistoryEntry[] = [];
|
||||||
|
for (const name of files) {
|
||||||
|
try {
|
||||||
|
const raw = (await readJson(path.join(historyDir(), name))) as Settings & { note?: string };
|
||||||
|
entries.push({
|
||||||
|
id: name.slice(0, -5),
|
||||||
|
savedAt: raw.updatedAt,
|
||||||
|
calendarCount: Array.isArray(raw.calendars) ? raw.calendars.length : 0,
|
||||||
|
albumCount: Array.isArray(raw.photos?.albums) ? raw.photos.albums.length : 0,
|
||||||
|
note: typeof raw.note === "string" ? raw.note : "",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip an unreadable snapshot.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHistorySnapshot(id: string) {
|
||||||
|
if (!/^[0-9TZ-]+$/.test(id)) return null;
|
||||||
|
try {
|
||||||
|
return normalizeSettings(await readJson(path.join(historyDir(), `${id}.json`)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAuth(): Promise<AuthState> {
|
||||||
|
const raw = (await readJson(authPath())) as Partial<AuthState>;
|
||||||
|
if (typeof raw.sessionSecret !== "string" || raw.sessionSecret.length < 32) throw new Error("auth.json is missing a session secret");
|
||||||
|
return { passwordHash: typeof raw.passwordHash === "string" ? raw.passwordHash : "", sessionSecret: raw.sessionSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAuth(next: AuthState) {
|
||||||
|
return serialized(() => writeJsonAtomic(authPath(), next));
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { playlistUrl, type TvSetting } from "./store";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TV check-ins. Each Shield's photo app polls GET /api/tv/config every few minutes with its
|
||||||
|
* Android ID; the server answers with that TV's morning-routine settings and records what the
|
||||||
|
* TV last reported (so the admin page can show "applied" vs "waiting for the TV").
|
||||||
|
* Status is runtime data, kept apart from settings.json so it never lands in settings history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often the app checks in (the app uses this value; keep in sync with the APK default). */
|
||||||
|
export const CHECK_IN_MINUTES = 5;
|
||||||
|
/** Music starts this many seconds before the wake time; the calendar comes up at the wake time. */
|
||||||
|
export const MUSIC_LEAD_SECONDS = 30;
|
||||||
|
const MAX_STATUS_ENTRIES = 20;
|
||||||
|
|
||||||
|
export type TvEvent = { at: number; kind: string; ok: boolean; detail: string };
|
||||||
|
export type TvStatus = {
|
||||||
|
id: string;
|
||||||
|
deviceName: string;
|
||||||
|
model: string;
|
||||||
|
ip: string;
|
||||||
|
appVersion: string;
|
||||||
|
lastSeenAt: number;
|
||||||
|
appliedVersion: string;
|
||||||
|
lastEvent: TvEvent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPath = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "tv-status.json");
|
||||||
|
|
||||||
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
|
function serialized<T>(task: () => Promise<T>) {
|
||||||
|
const run = queue.then(task, task);
|
||||||
|
queue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTvStatus(): Promise<Record<string, TvStatus>> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record<string, TvStatus>;
|
||||||
|
return raw && typeof raw === "object" ? raw : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTvStatus(next: Record<string, TvStatus>) {
|
||||||
|
const temporary = `${statusPath()}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
||||||
|
await rename(temporary, statusPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = (value: unknown, max: number) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.replace(/[^\x20-\x7e]/g, "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, max);
|
||||||
|
|
||||||
|
/** Records a check-in (and optionally an event such as the morning routine's result). */
|
||||||
|
export function recordTvCheckIn(
|
||||||
|
id: string,
|
||||||
|
report: { deviceName?: unknown; model?: unknown; appVersion?: unknown; appliedVersion?: unknown; ip: string },
|
||||||
|
event?: Omit<TvEvent, "at">,
|
||||||
|
knownIds: Set<string> = new Set(),
|
||||||
|
) {
|
||||||
|
return serialized(async () => {
|
||||||
|
const all = await readTvStatus();
|
||||||
|
const previous = all[id];
|
||||||
|
// Unknown TVs are remembered (so admin can add them) but capped, oldest dropped first.
|
||||||
|
if (!previous && Object.keys(all).length >= MAX_STATUS_ENTRIES) {
|
||||||
|
const evictable = Object.values(all)
|
||||||
|
.filter((entry) => !knownIds.has(entry.id))
|
||||||
|
.sort((left, right) => left.lastSeenAt - right.lastSeenAt);
|
||||||
|
if (!evictable.length) return;
|
||||||
|
delete all[evictable[0].id];
|
||||||
|
}
|
||||||
|
all[id] = {
|
||||||
|
id,
|
||||||
|
deviceName: clean(report.deviceName, 60) || previous?.deviceName || "",
|
||||||
|
model: clean(report.model, 60) || previous?.model || "",
|
||||||
|
ip: report.ip,
|
||||||
|
appVersion: clean(report.appVersion, 20) || previous?.appVersion || "",
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
appliedVersion: clean(report.appliedVersion, 20) || previous?.appliedVersion || "",
|
||||||
|
lastEvent: event ? { ...event, kind: clean(event.kind, 20), detail: clean(event.detail, 200), at: Date.now() } : (previous?.lastEvent ?? null),
|
||||||
|
};
|
||||||
|
await writeTvStatus(all);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetTvStatus(id: string) {
|
||||||
|
return serialized(async () => {
|
||||||
|
const all = await readTvStatus();
|
||||||
|
if (!all[id]) return;
|
||||||
|
delete all[id];
|
||||||
|
await writeTvStatus(all);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the TV app receives. Unknown TVs get everything disabled. */
|
||||||
|
export function tvConfig(setting: TvSetting | undefined) {
|
||||||
|
const body = {
|
||||||
|
registered: Boolean(setting),
|
||||||
|
name: setting?.name ?? "",
|
||||||
|
wake: setting?.wake ?? { enabled: false, time: "06:30", days: [] as number[] },
|
||||||
|
music: {
|
||||||
|
enabled: Boolean(setting?.music.enabled && setting.music.playlistId),
|
||||||
|
// watch?list= starts playback; playlist?list= would only open the playlist page.
|
||||||
|
url: setting?.music.playlistId ? `https://music.youtube.com/watch?list=${setting.music.playlistId}` : "",
|
||||||
|
},
|
||||||
|
volume: setting?.volume ?? { enabled: false, level: 40 },
|
||||||
|
musicLeadSeconds: MUSIC_LEAD_SECONDS,
|
||||||
|
checkInMinutes: CHECK_IN_MINUTES,
|
||||||
|
calendarPackage: "com.dayline.tv",
|
||||||
|
};
|
||||||
|
return { ...body, version: configVersion(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configVersion(body: unknown) {
|
||||||
|
return createHash("sha1").update(JSON.stringify(body)).digest("hex").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { playlistUrl };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Organizer Admin",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||||
|
import {
|
||||||
|
CALENDAR_TONES,
|
||||||
|
type CalendarSetting,
|
||||||
|
type CalendarTone,
|
||||||
|
MAX_CALENDARS,
|
||||||
|
maskCalendarUrl,
|
||||||
|
readSettings,
|
||||||
|
saveSettings,
|
||||||
|
validCalendarUrl,
|
||||||
|
} from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function publicView(calendars: CalendarSetting[]) {
|
||||||
|
return calendars.map(({ id, name, tone, enabled, url }) => ({ id, name, tone, enabled, masked: maskCalendarUrl(url) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const denied = await requireAdmin(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
const settings = await readSettings();
|
||||||
|
return json({ calendars: publicView(settings.calendars), updatedAt: settings.updatedAt, tones: CALENDAR_TONES });
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingCalendar = { id?: string; name?: string; tone?: string; enabled?: boolean; url?: string };
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ calendars?: IncomingCalendar[] }>(request);
|
||||||
|
if (!body || !Array.isArray(body.calendars)) return json({ error: "Expected a calendars list." }, 400);
|
||||||
|
if (body.calendars.length > MAX_CALENDARS) return json({ error: `At most ${MAX_CALENDARS} calendars.` }, 400);
|
||||||
|
|
||||||
|
const existing = new Map((await readSettings()).calendars.map((calendar) => [calendar.id, calendar]));
|
||||||
|
const next: CalendarSetting[] = [];
|
||||||
|
for (const [index, entry] of body.calendars.entries()) {
|
||||||
|
const label = entry.name?.trim() || `Calendar ${index + 1}`;
|
||||||
|
const previous = entry.id ? existing.get(entry.id) : undefined;
|
||||||
|
if (entry.id && !previous) return json({ error: `${label}: unknown calendar; reload the page.` }, 409);
|
||||||
|
const suppliedUrl = typeof entry.url === "string" && entry.url.trim() ? entry.url : "";
|
||||||
|
const url = suppliedUrl ? validCalendarUrl(suppliedUrl) : previous?.url;
|
||||||
|
if (!url) {
|
||||||
|
return json({ error: `${label}: paste a Google Calendar "Secret address in iCal format" (https://calendar.google.com/…/basic.ics).` }, 400);
|
||||||
|
}
|
||||||
|
next.push({
|
||||||
|
id: previous?.id ?? randomUUID(),
|
||||||
|
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
|
||||||
|
url,
|
||||||
|
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Set(next.map((calendar) => calendar.url)).size !== next.length) return json({ error: "The same calendar is listed twice." }, 400);
|
||||||
|
|
||||||
|
const saved = await saveSettings({ calendars: next }, `Saved ${next.length} calendar(s)`);
|
||||||
|
console.log(`[admin] calendars saved (${saved.calendars.length})`);
|
||||||
|
return json({ calendars: publicView(saved.calendars), updatedAt: saved.updatedAt });
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
|
||||||
|
import { addCalendarDays, organizerDate, readCalendar } from "../../../../_lib/calendar-feed";
|
||||||
|
import { readSettings, validCalendarUrl } from "../../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Fetches one feed (saved by id, or a not-yet-saved URL) and reports what it found. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ id?: string; url?: string }>(request);
|
||||||
|
let url: string | null = null;
|
||||||
|
if (body?.url) url = validCalendarUrl(body.url);
|
||||||
|
else if (body?.id) url = (await readSettings()).calendars.find((calendar) => calendar.id === body.id)?.url ?? null;
|
||||||
|
if (!url) return json({ ok: false, error: "Not a Google Calendar basic.ics address." }, 400);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const today = organizerDate(new Date(now));
|
||||||
|
try {
|
||||||
|
const result = await readCalendar(
|
||||||
|
url,
|
||||||
|
{ feedIndex: 0, tone: "cyan", nameOverride: "" },
|
||||||
|
now,
|
||||||
|
now + 60 * 24 * 60 * 60 * 1_000,
|
||||||
|
today,
|
||||||
|
addCalendarDays(today, 60),
|
||||||
|
);
|
||||||
|
return json({ ok: true, sourceName: result.sourceName, eventCount: result.eventCount, upcoming: result.items.length });
|
||||||
|
} catch (error) {
|
||||||
|
return json({ ok: false, error: error instanceof Error ? error.message : "Fetch failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||||
|
import { syncAll } from "../../../_lib/photos";
|
||||||
|
import { listHistory, readHistorySnapshot, saveSettings } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const denied = await requireAdmin(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
return json({ history: await listHistory() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverts calendars and photo settings to a snapshot (TV settings are left as they are). The current state is itself snapshotted first, so a revert can be undone. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ id?: string }>(request);
|
||||||
|
const snapshot = body?.id ? await readHistorySnapshot(body.id) : null;
|
||||||
|
if (!snapshot) return json({ error: "That version no longer exists." }, 404);
|
||||||
|
const saved = await saveSettings({ calendars: snapshot.calendars, photos: snapshot.photos }, `Reverted to version from ${snapshot.updatedAt}`);
|
||||||
|
console.log(`[admin] settings reverted to ${body?.id}`);
|
||||||
|
void syncAll(); // re-fetch any album the restored version brings back
|
||||||
|
return json({ ok: true, updatedAt: saved.updatedAt });
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
clearLoginFailures,
|
||||||
|
clientIp,
|
||||||
|
isAdminNetworkAllowed,
|
||||||
|
json,
|
||||||
|
loginLockedFor,
|
||||||
|
readJsonBody,
|
||||||
|
recordLoginFailure,
|
||||||
|
sameOrigin,
|
||||||
|
sessionCookie,
|
||||||
|
verifyPassword,
|
||||||
|
} from "../../../_lib/auth";
|
||||||
|
import { readAuth } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
|
||||||
|
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
|
||||||
|
const ip = clientIp(request.headers);
|
||||||
|
const locked = loginLockedFor(ip);
|
||||||
|
if (locked) return json({ error: `Too many attempts. Try again in ${locked} seconds.` }, 429);
|
||||||
|
|
||||||
|
const auth = await readAuth();
|
||||||
|
if (!auth.passwordHash) return json({ error: "No password is set yet." }, 409);
|
||||||
|
const body = await readJsonBody<{ password?: string }>(request);
|
||||||
|
const password = typeof body?.password === "string" ? body.password : "";
|
||||||
|
if (!password || !(await verifyPassword(password, auth.passwordHash))) {
|
||||||
|
recordLoginFailure(ip);
|
||||||
|
console.warn(`[admin] failed login from ${ip}`);
|
||||||
|
return json({ error: "Wrong password." }, 401);
|
||||||
|
}
|
||||||
|
clearLoginFailures(ip);
|
||||||
|
console.log(`[admin] login from ${ip}`);
|
||||||
|
return json({ ok: true }, 200, { "Set-Cookie": sessionCookie(auth.sessionSecret) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { clearedSessionCookie, json } from "../../../_lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
return json({ ok: true }, 200, { "Set-Cookie": clearedSessionCookie() });
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { json, MIN_PASSWORD_LENGTH, readJsonBody, requireAdmin, setPassword, verifyPassword } from "../../../_lib/auth";
|
||||||
|
import { readAuth } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Changes the password; signs out every other session. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ currentPassword?: string; newPassword?: string }>(request);
|
||||||
|
const current = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
||||||
|
const next = typeof body?.newPassword === "string" ? body.newPassword : "";
|
||||||
|
if (!(await verifyPassword(current, (await readAuth()).passwordHash))) return json({ error: "Current password is wrong." }, 401);
|
||||||
|
if (next.length < MIN_PASSWORD_LENGTH || next.length > 256) return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
|
||||||
|
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(next) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
|
||||||
|
import { MAX_PHOTOS_PER_ALBUM, scrapeAlbum } from "../../../../_lib/photos";
|
||||||
|
import { validAlbumUrl } from "../../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Reads a share link without saving it: album title and how many photos/videos it has. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ shareUrl?: string }>(request);
|
||||||
|
const shareUrl = validAlbumUrl(body?.shareUrl);
|
||||||
|
if (!shareUrl) return json({ ok: false, error: "Not a Google Photos share link." }, 400);
|
||||||
|
try {
|
||||||
|
const album = await scrapeAlbum(shareUrl);
|
||||||
|
const videos = album.items.filter((item) => item.video).length;
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
title: album.title,
|
||||||
|
photos: album.items.length - videos,
|
||||||
|
videos,
|
||||||
|
truncated: album.hasMorePages || album.items.length - videos > MAX_PHOTOS_PER_ALBUM,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return json({ ok: false, error: error instanceof Error ? error.message : "Could not read the album." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||||
|
import { isSyncing, readIndex, removeOrphanedAlbumFolders, STALE_AFTER_MS, syncAlbum, UPLOADS_DIR } from "../../../_lib/photos";
|
||||||
|
import { MAX_ALBUMS, maskAlbumUrl, type PhotoAlbum, type PhotoSettings, readSettings, saveSettings, validAlbumUrl } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function view(photos: PhotoSettings) {
|
||||||
|
const { albums, ...display } = photos;
|
||||||
|
const uploads = await readIndex(UPLOADS_DIR);
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
albums: await Promise.all(
|
||||||
|
albums.map(async (album) => {
|
||||||
|
const index = await readIndex(album.id);
|
||||||
|
return {
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
enabled: album.enabled,
|
||||||
|
masked: maskAlbumUrl(album.shareUrl),
|
||||||
|
title: index.title,
|
||||||
|
photoCount: index.photos.length,
|
||||||
|
lastSuccessAt: index.lastSuccessAt,
|
||||||
|
lastAttemptAt: index.lastAttemptAt,
|
||||||
|
lastError: index.lastError,
|
||||||
|
truncated: index.truncated,
|
||||||
|
stale: album.enabled && Date.now() - (index.lastSuccessAt ?? (index.firstAttemptAt || Date.now())) > STALE_AFTER_MS,
|
||||||
|
syncing: isSyncing(album.id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
uploads: uploads.photos.map((photo) => ({ key: photo.key, thumb: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.t.jpg`, src: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.jpg` })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const denied = await requireAdmin(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
return json(await view((await readSettings()).photos));
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingAlbum = { id?: string; name?: string; enabled?: boolean; shareUrl?: string };
|
||||||
|
type Incoming = { display?: Partial<Omit<PhotoSettings, "albums">>; albums?: IncomingAlbum[] };
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<Incoming>(request);
|
||||||
|
if (!body || !Array.isArray(body.albums)) return json({ error: "Expected albums and display settings." }, 400);
|
||||||
|
if (body.albums.length > MAX_ALBUMS) return json({ error: `At most ${MAX_ALBUMS} albums.` }, 400);
|
||||||
|
|
||||||
|
const current = (await readSettings()).photos;
|
||||||
|
const existing = new Map(current.albums.map((album) => [album.id, album]));
|
||||||
|
const albums: PhotoAlbum[] = [];
|
||||||
|
const toSync: PhotoAlbum[] = [];
|
||||||
|
for (const [position, entry] of body.albums.entries()) {
|
||||||
|
const label = entry.name?.trim() || `Album ${position + 1}`;
|
||||||
|
const previous = entry.id ? existing.get(entry.id) : undefined;
|
||||||
|
if (entry.id && !previous) return json({ error: `${label}: unknown album; reload the page.` }, 409);
|
||||||
|
const supplied = typeof entry.shareUrl === "string" && entry.shareUrl.trim() ? validAlbumUrl(entry.shareUrl) : null;
|
||||||
|
if (entry.shareUrl?.trim() && !supplied) return json({ error: `${label}: paste a Google Photos share link (https://photos.app.goo.gl/… or https://photos.google.com/share/…).` }, 400);
|
||||||
|
const shareUrl = supplied ?? previous?.shareUrl;
|
||||||
|
if (!shareUrl) return json({ error: `${label}: a share link is required.` }, 400);
|
||||||
|
const album: PhotoAlbum = {
|
||||||
|
id: previous?.id ?? randomUUID(),
|
||||||
|
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
|
||||||
|
shareUrl,
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
};
|
||||||
|
albums.push(album);
|
||||||
|
if (album.enabled && (!previous || previous.shareUrl !== shareUrl || !previous.enabled)) toSync.push(album);
|
||||||
|
}
|
||||||
|
if (new Set(albums.map((album) => album.shareUrl)).size !== albums.length) return json({ error: "The same album is listed twice." }, 400);
|
||||||
|
|
||||||
|
const saved = await saveSettings({ photos: { ...current, ...(body.display ?? {}), albums } }, `Saved photo settings (${albums.length} album(s))`);
|
||||||
|
console.log(`[admin] photo settings saved (${albums.length} album(s))`);
|
||||||
|
// Fetch new/changed albums now, and drop cache folders of removed ones, without making the browser wait.
|
||||||
|
void (async () => {
|
||||||
|
await removeOrphanedAlbumFolders();
|
||||||
|
for (const album of toSync) await syncAlbum(album);
|
||||||
|
})();
|
||||||
|
return json(await view(saved.photos));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
|
||||||
|
import { syncAlbum, syncAll } from "../../../../_lib/photos";
|
||||||
|
import { readSettings } from "../../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Syncs one album ({ id }) or all of them, and waits for the result. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ id?: string }>(request);
|
||||||
|
if (body?.id) {
|
||||||
|
const album = (await readSettings()).photos.albums.find((entry) => entry.id === body.id);
|
||||||
|
if (!album) return json({ error: "Unknown album." }, 404);
|
||||||
|
const index = await syncAlbum(album);
|
||||||
|
return json({ ok: !index.lastError || index.photos.length > 0, photoCount: index.photos.length, lastError: index.lastError });
|
||||||
|
}
|
||||||
|
await syncAll();
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { json, requireAdmin } from "../../../../_lib/auth";
|
||||||
|
import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin page resizes photos in the browser (max 1920px JPEG, a 400px gallery tile and a 64px thumbnail)
|
||||||
|
* before sending them, so the server only validates and stores.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Expected a photo upload." }, 400);
|
||||||
|
}
|
||||||
|
const photo = form.get("photo");
|
||||||
|
const thumb = form.get("thumb");
|
||||||
|
if (!(photo instanceof Blob) || !(thumb instanceof Blob)) return json({ error: "Missing photo." }, 400);
|
||||||
|
if (photo.size > 8 * 1024 * 1024 || thumb.size > 256 * 1024) return json({ error: "Photo is too large." }, 413);
|
||||||
|
const photoBytes = new Uint8Array(await photo.arrayBuffer());
|
||||||
|
const thumbBytes = new Uint8Array(await thumb.arrayBuffer());
|
||||||
|
if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
||||||
|
// The gallery tile is optional (older admin pages don't send one); the gallery then uses the full photo.
|
||||||
|
const tile = form.get("tile");
|
||||||
|
let tileBytes: Uint8Array | null = null;
|
||||||
|
if (tile instanceof Blob) {
|
||||||
|
if (tile.size > 1024 * 1024) return json({ error: "Photo is too large." }, 413);
|
||||||
|
tileBytes = new Uint8Array(await tile.arrayBuffer());
|
||||||
|
if (!isJpeg(tileBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
||||||
|
}
|
||||||
|
const takenAt = Number(form.get("takenAt"));
|
||||||
|
try {
|
||||||
|
const result = await addUpload(photoBytes, thumbBytes, tileBytes, {
|
||||||
|
takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null,
|
||||||
|
width: Math.max(0, Math.round(Number(form.get("width")) || 0)),
|
||||||
|
height: Math.max(0, Math.round(Number(form.get("height")) || 0)),
|
||||||
|
});
|
||||||
|
return json({ ok: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: error instanceof Error ? error.message : "Upload failed." }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const key = new URL(request.url).searchParams.get("key") || "";
|
||||||
|
return (await removeUpload(key)) ? json({ ok: true }) : json({ error: "Not found." }, 404);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isAdminNetworkAllowed, json, MIN_PASSWORD_LENGTH, readJsonBody, sameOrigin, setPassword } from "../../../_lib/auth";
|
||||||
|
import { readAuth } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** First-run only: sets the admin password when none exists yet. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
|
||||||
|
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
|
||||||
|
if ((await readAuth()).passwordHash) return json({ error: "A password is already set. Sign in instead." }, 409);
|
||||||
|
const body = await readJsonBody<{ password?: string }>(request);
|
||||||
|
const password = typeof body?.password === "string" ? body.password : "";
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH || password.length > 256) {
|
||||||
|
return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
|
||||||
|
}
|
||||||
|
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(password) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { hasValidSession, isAdminNetworkAllowed, json } from "../../../_lib/auth";
|
||||||
|
import { readAuth } from "../../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
|
||||||
|
const { passwordHash } = await readAuth();
|
||||||
|
return json({ setupRequired: !passwordHash, authenticated: passwordHash ? await hasValidSession(request.headers) : false });
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||||
|
import { ALL_DAYS, MAX_TVS, playlistUrl, readSettings, saveSettings, type TvSetting, validPlaylistId, validTvId } from "../../../_lib/store";
|
||||||
|
import { CHECK_IN_MINUTES, readTvStatus, tvConfig } from "../../../_lib/tvs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function view(tvs: TvSetting[]) {
|
||||||
|
const status = await readTvStatus();
|
||||||
|
const known = new Set(tvs.map((tv) => tv.id));
|
||||||
|
return {
|
||||||
|
checkInMinutes: CHECK_IN_MINUTES,
|
||||||
|
tvs: tvs.map((tv) => ({
|
||||||
|
...tv,
|
||||||
|
playlistUrl: tv.music.playlistId ? playlistUrl(tv.music.playlistId) : "",
|
||||||
|
version: tvConfig(tv).version,
|
||||||
|
status: status[tv.id] ?? null,
|
||||||
|
})),
|
||||||
|
// TVs running the photo app that have checked in but are not configured yet.
|
||||||
|
found: Object.values(status)
|
||||||
|
.filter((entry) => !known.has(entry.id))
|
||||||
|
.sort((left, right) => right.lastSeenAt - left.lastSeenAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const denied = await requireAdmin(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
return json(await view((await readSettings()).tvs));
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingTv = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
wake?: { enabled?: boolean; time?: string; days?: number[] };
|
||||||
|
music?: { enabled?: boolean; playlist?: string };
|
||||||
|
volume?: { enabled?: boolean; level?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const denied = await requireAdmin(request, { mutating: true });
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await readJsonBody<{ tvs?: IncomingTv[] }>(request);
|
||||||
|
if (!body || !Array.isArray(body.tvs)) return json({ error: "Expected a list of TVs." }, 400);
|
||||||
|
if (body.tvs.length > MAX_TVS) return json({ error: `At most ${MAX_TVS} TVs.` }, 400);
|
||||||
|
|
||||||
|
const tvs: TvSetting[] = [];
|
||||||
|
for (const [position, entry] of body.tvs.entries()) {
|
||||||
|
const label = entry.name?.trim() || `TV ${position + 1}`;
|
||||||
|
const id = validTvId(entry.id);
|
||||||
|
if (!id) return json({ error: `${label}: unknown TV id; reload the page.` }, 400);
|
||||||
|
const time = String(entry.wake?.time ?? "");
|
||||||
|
if (entry.wake?.enabled && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return json({ error: `${label}: pick a wake time.` }, 400);
|
||||||
|
const playlistText = String(entry.music?.playlist ?? "").trim();
|
||||||
|
const playlistId = playlistText ? validPlaylistId(playlistText) : "";
|
||||||
|
if (playlistText && !playlistId) return json({ error: `${label}: paste a YouTube Music playlist link (https://music.youtube.com/playlist?list=…).` }, 400);
|
||||||
|
if (entry.music?.enabled && !playlistId) return json({ error: `${label}: music is on but no playlist is set.` }, 400);
|
||||||
|
const days = Array.isArray(entry.wake?.days) ? entry.wake.days : ALL_DAYS;
|
||||||
|
if (entry.wake?.enabled && !days.length) return json({ error: `${label}: pick at least one day.` }, 400);
|
||||||
|
tvs.push({
|
||||||
|
id,
|
||||||
|
name: (entry.name ?? "").trim().slice(0, 40),
|
||||||
|
wake: { enabled: entry.wake?.enabled === true, time: time || "06:30", days },
|
||||||
|
music: { enabled: entry.music?.enabled === true, playlistId: playlistId || "" },
|
||||||
|
volume: { enabled: entry.volume?.enabled === true, level: Number(entry.volume?.level ?? 40) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Set(tvs.map((tv) => tv.id)).size !== tvs.length) return json({ error: "The same TV is listed twice." }, 400);
|
||||||
|
|
||||||
|
const saved = await saveSettings({ tvs }, `Saved TV settings (${tvs.length} TV(s))`);
|
||||||
|
console.log(`[admin] TV settings saved (${tvs.length} TV(s))`);
|
||||||
|
return json(await view(saved.tvs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { addCalendarDays, organizerDate, readCalendar } from "../../_lib/calendar-feed";
|
||||||
|
import { readSettings } from "../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { calendars } = await readSettings();
|
||||||
|
const feeds = calendars.filter((calendar) => calendar.enabled);
|
||||||
|
if (!feeds.length) {
|
||||||
|
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - 45 * 24 * 60 * 60 * 1_000;
|
||||||
|
const horizon = now + 60 * 24 * 60 * 60 * 1_000;
|
||||||
|
const today = organizerDate(new Date(now));
|
||||||
|
const windowStartDate = addCalendarDays(today, -45);
|
||||||
|
const horizonDate = addCalendarDays(today, 60);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
feeds.map((feed, feedIndex) =>
|
||||||
|
readCalendar(feed.url, { feedIndex, tone: feed.tone, nameOverride: feed.name }, windowStart, horizon, windowStartDate, horizonDate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
|
||||||
|
const items = successful
|
||||||
|
.flatMap((result) => result.value.items)
|
||||||
|
.filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftValue = left.allDay ? `${left.start}T00:00:00` : left.start;
|
||||||
|
const rightValue = right.allDay ? `${right.start}T00:00:00` : right.start;
|
||||||
|
return leftValue.localeCompare(rightValue);
|
||||||
|
})
|
||||||
|
.slice(0, 160);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
calendarsAvailable: successful.length,
|
||||||
|
calendarsTotal: feeds.length,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: successful.length ? 200 : 503,
|
||||||
|
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=240" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const configuredTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
|
||||||
|
let timeZone = "America/Chicago";
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: configuredTimeZone }).format(new Date());
|
||||||
|
timeZone = configuredTimeZone;
|
||||||
|
} catch {
|
||||||
|
// Keep a valid default if a deployment typo reaches the process.
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ timeZone },
|
||||||
|
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
type FeedSource = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedItem = {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const feeds: FeedSource[] = [
|
||||||
|
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
|
||||||
|
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
|
||||||
|
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
|
||||||
|
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
|
||||||
|
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
|
||||||
|
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
|
||||||
|
];
|
||||||
|
const maxFeedBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function decodeEntities(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'|'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract(block: string, names: string[]) {
|
||||||
|
for (const name of names) {
|
||||||
|
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
|
||||||
|
if (match?.[1]) return decodeEntities(match[1]);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLink(block: string) {
|
||||||
|
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
|
||||||
|
if (atomLink) return decodeEntities(atomLink);
|
||||||
|
return extract(block, ["link", "guid"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeed(xml: string, source: string): FeedItem[] {
|
||||||
|
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
|
||||||
|
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
return blocks.slice(0, 6).flatMap((block) => {
|
||||||
|
const title = extract(block, ["title"]);
|
||||||
|
const url = extractLink(block);
|
||||||
|
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
|
||||||
|
const parsedDate = rawDate ? new Date(rawDate) : null;
|
||||||
|
if (!title || !url) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
source,
|
||||||
|
title: title.slice(0, 180),
|
||||||
|
url,
|
||||||
|
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLimitedText(response: Response) {
|
||||||
|
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
|
||||||
|
if (!response.body) return "";
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let total = 0;
|
||||||
|
let text = "";
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
total += chunk.value.byteLength;
|
||||||
|
if (total > maxFeedBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error("Headline feed is too large");
|
||||||
|
}
|
||||||
|
text += decoder.decode(chunk.value, { stream: true });
|
||||||
|
}
|
||||||
|
return text + decoder.decode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFeed(feed: FeedSource) {
|
||||||
|
const response = await fetch(feed.url, {
|
||||||
|
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
|
||||||
|
signal: AbortSignal.timeout(6_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
|
||||||
|
return parseFeed(await readLimitedText(response), feed.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const results = await Promise.allSettled(feeds.map(readFeed));
|
||||||
|
const items = results
|
||||||
|
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
|
||||||
|
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
|
||||||
|
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
|
||||||
|
feedsTotal: feeds.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { currentSlideshow, staleAlbums } from "../../../_lib/photos";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** 503 when any enabled album has not synced successfully for 48 hours. Probed by Prometheus. */
|
||||||
|
export async function GET() {
|
||||||
|
const stale = await staleAlbums();
|
||||||
|
const { photos } = await currentSlideshow();
|
||||||
|
return Response.json(
|
||||||
|
{ status: stale.length ? "stale" : "ok", photos: photos.length, staleAlbums: stale.map(({ title, lastSuccessAt, lastError }) => ({ title, lastSuccessAt, lastError })) },
|
||||||
|
{ status: stale.length ? 503 : 200, headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { readAuth, readSettings } from "../../_lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { calendars } = await readSettings();
|
||||||
|
let adminConfigured = false;
|
||||||
|
try {
|
||||||
|
adminConfigured = Boolean((await readAuth()).passwordHash);
|
||||||
|
} catch {
|
||||||
|
adminConfigured = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
service: "hobbs-family-organizer",
|
||||||
|
version: "1.4.0",
|
||||||
|
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
|
||||||
|
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
|
||||||
|
adminConfigured,
|
||||||
|
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
type Direction = "up" | "down" | "flat";
|
||||||
|
|
||||||
|
type FredSeries = {
|
||||||
|
id: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
currency: boolean;
|
||||||
|
decimals: number;
|
||||||
|
multiplier?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Observation = {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const series: FredSeries[] = [
|
||||||
|
{ id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 },
|
||||||
|
{ id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 },
|
||||||
|
{ id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 },
|
||||||
|
{ id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 },
|
||||||
|
{ id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 },
|
||||||
|
{ id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxResponseBytes = 1024 * 1024;
|
||||||
|
const monthNumbers: Record<string, string> = {
|
||||||
|
Jan: "01",
|
||||||
|
Feb: "02",
|
||||||
|
Mar: "03",
|
||||||
|
Apr: "04",
|
||||||
|
May: "05",
|
||||||
|
Jun: "06",
|
||||||
|
Jul: "07",
|
||||||
|
Aug: "08",
|
||||||
|
Sep: "09",
|
||||||
|
Oct: "10",
|
||||||
|
Nov: "11",
|
||||||
|
Dec: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function normalizeDate(value: string) {
|
||||||
|
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (iso) return value;
|
||||||
|
const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/);
|
||||||
|
if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeriesPage(html: string) {
|
||||||
|
const observations: Observation[] = [];
|
||||||
|
for (const row of html.match(/<tr(?:\s[^>]*)?>[\s\S]*?<\/tr>/gi) ?? []) {
|
||||||
|
const rawDate = row.match(/<td[^>]*>\s*([^<]+?):(?: |\s)*<\/td>/i)?.[1]?.trim();
|
||||||
|
const rawValue = row.match(/<td[^>]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1];
|
||||||
|
if (!rawDate || !rawValue) continue;
|
||||||
|
const date = normalizeDate(rawDate);
|
||||||
|
const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/−/g, "-").trim());
|
||||||
|
if (!date || !Number.isFinite(value)) continue;
|
||||||
|
observations.push({ date, value });
|
||||||
|
}
|
||||||
|
return observations.sort((left, right) => left.date.localeCompare(right.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, config: FredSeries) {
|
||||||
|
const formatted = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: config.decimals,
|
||||||
|
maximumFractionDigits: config.decimals,
|
||||||
|
}).format(value);
|
||||||
|
return config.currency ? `$${formatted}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSeries(config: FredSeries) {
|
||||||
|
const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org");
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/html,application/xhtml+xml",
|
||||||
|
"User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`${config.id} returned ${response.status}`);
|
||||||
|
|
||||||
|
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
|
||||||
|
const html = await response.text();
|
||||||
|
if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
|
||||||
|
|
||||||
|
const observations = parseSeriesPage(html);
|
||||||
|
if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`);
|
||||||
|
const previous = observations.at(-2)!;
|
||||||
|
const latest = observations.at(-1)!;
|
||||||
|
const multiplier = config.multiplier ?? 1;
|
||||||
|
const value = latest.value * multiplier;
|
||||||
|
const move = ((latest.value - previous.value) / previous.value) * 100;
|
||||||
|
const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat";
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol: config.symbol,
|
||||||
|
name: config.name,
|
||||||
|
value: formatValue(value, config),
|
||||||
|
move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`,
|
||||||
|
direction,
|
||||||
|
seriesId: config.id,
|
||||||
|
observedAt: latest.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const results: PromiseSettledResult<Awaited<ReturnType<typeof readSeries>>>[] = [];
|
||||||
|
for (let index = 0; index < series.length; index += 2) {
|
||||||
|
const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries));
|
||||||
|
results.push(...batch);
|
||||||
|
}
|
||||||
|
const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
seriesAvailable: items.length,
|
||||||
|
seriesTotal: series.length,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: items.length ? 200 : 503,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=900, stale-while-revalidate=21600",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { photoFilePath, validPhotoDir, validPhotoFile } from "../../../../../_lib/photos";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ dir: string; file: string }> }) {
|
||||||
|
const { dir, file } = await params;
|
||||||
|
if (!validPhotoDir(dir) || !validPhotoFile(file)) return new Response("Not found", { status: 404 });
|
||||||
|
try {
|
||||||
|
const data = await readFile(photoFilePath(dir, file));
|
||||||
|
return new Response(new Uint8Array(data), {
|
||||||
|
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", "Content-Length": String(data.byteLength) },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { currentSlideshow } from "../../_lib/photos";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json(await currentSlideshow(), { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { clientIp, isAdminNetworkAllowed, json } from "../../../_lib/auth";
|
||||||
|
import { readSettings, validTvId } from "../../../_lib/store";
|
||||||
|
import { recordTvCheckIn, tvConfig } from "../../../_lib/tvs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Shield photo app every few minutes:
|
||||||
|
* GET /api/tv/config?device=<android id>&name=<device name>&model=<model>&app=<app version>&applied=<config version>
|
||||||
|
* Same network rule as admin: direct LAN only, never through a proxy.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const id = validTvId(params.get("device"));
|
||||||
|
if (!id) return json({ error: "Missing or invalid device id." }, 400);
|
||||||
|
const settings = await readSettings();
|
||||||
|
const setting = settings.tvs.find((tv) => tv.id === id);
|
||||||
|
await recordTvCheckIn(
|
||||||
|
id,
|
||||||
|
{ deviceName: params.get("name"), model: params.get("model"), appVersion: params.get("app"), appliedVersion: params.get("applied"), ip: clientIp(request.headers) },
|
||||||
|
undefined,
|
||||||
|
new Set(settings.tvs.map((tv) => tv.id)),
|
||||||
|
);
|
||||||
|
return json(tvConfig(setting));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { clientIp, isAdminNetworkAllowed, json, readJsonBody } from "../../../_lib/auth";
|
||||||
|
import { readSettings, validTvId } from "../../../_lib/store";
|
||||||
|
import { recordTvCheckIn } from "../../../_lib/tvs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Report = { device?: string; kind?: string; ok?: boolean; detail?: string; applied?: string; app?: string };
|
||||||
|
|
||||||
|
/** The Shield photo app reports the outcome of its morning routine here (shown in admin). */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
|
||||||
|
const body = await readJsonBody<Report>(request);
|
||||||
|
const id = validTvId(body?.device);
|
||||||
|
if (!body || !id) return json({ error: "Missing or invalid device id." }, 400);
|
||||||
|
const settings = await readSettings();
|
||||||
|
await recordTvCheckIn(
|
||||||
|
id,
|
||||||
|
{ appVersion: body.app, appliedVersion: body.applied, ip: clientIp(request.headers) },
|
||||||
|
{ kind: body.kind || "event", ok: body.ok === true, detail: body.detail || "" },
|
||||||
|
new Set(settings.tvs.map((tv) => tv.id)),
|
||||||
|
);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
type WeatherIcon = "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
|
||||||
|
|
||||||
|
type NwsPeriod = {
|
||||||
|
number?: number;
|
||||||
|
startTime?: string;
|
||||||
|
isDaytime?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
temperatureUnit?: string;
|
||||||
|
shortForecast?: string;
|
||||||
|
probabilityOfPrecipitation?: { value?: number | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type NwsForecast = {
|
||||||
|
properties?: { periods?: NwsPeriod[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type DayAggregate = {
|
||||||
|
date: string;
|
||||||
|
high?: number;
|
||||||
|
low?: number;
|
||||||
|
temperatures: number[];
|
||||||
|
condition: string;
|
||||||
|
icon: WeatherIcon;
|
||||||
|
precipitationChance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function numericSetting(name: string, fallback: number, minimum: number, maximum: number) {
|
||||||
|
const value = Number(process.env[name] ?? fallback);
|
||||||
|
return Number.isFinite(value) && value >= minimum && value <= maximum ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = numericSetting("WEATHER_LATITUDE", 35.9987, -90, 90);
|
||||||
|
const longitude = numericSetting("WEATHER_LONGITUDE", -96.11417, -180, 180);
|
||||||
|
const location = (process.env.WEATHER_LOCATION || "Sapulpa, OK").trim().slice(0, 100);
|
||||||
|
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
|
||||||
|
const userAgent = (process.env.WEATHER_USER_AGENT || "Hobbs-Family-Organizer/1.0 (self-hosted)").trim().slice(0, 200);
|
||||||
|
|
||||||
|
function configuredForecastUrl() {
|
||||||
|
const value = new URL(process.env.NWS_FORECAST_URL || "https://api.weather.gov/gridpoints/TSA/37,98/forecast");
|
||||||
|
if (value.protocol !== "https:" || value.hostname !== "api.weather.gov") {
|
||||||
|
throw new Error("NWS forecast URL must use https://api.weather.gov");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function weatherIcon(condition: string): WeatherIcon {
|
||||||
|
const value = condition.toLowerCase();
|
||||||
|
if (value.includes("thunder")) return "storm";
|
||||||
|
if (value.includes("snow") || value.includes("sleet") || value.includes("ice")) return "snow";
|
||||||
|
if (value.includes("rain") || value.includes("shower") || value.includes("drizzle")) return "rain";
|
||||||
|
if (value.includes("fog") || value.includes("haze")) return "fog";
|
||||||
|
if (value.includes("partly") || value.includes("mostly sunny") || value.includes("mostly clear")) return "partly-cloudy";
|
||||||
|
if (value.includes("cloud") || value.includes("overcast")) return "cloudy";
|
||||||
|
return "clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fahrenheit(value: number, unit?: string) {
|
||||||
|
return unit?.toUpperCase() === "C" ? (value * 9) / 5 + 32 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDegrees(value: number) {
|
||||||
|
return ((value % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHours(value: number) {
|
||||||
|
return ((value % 24) + 24) % 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOfYear(dateKey: string) {
|
||||||
|
const [year, month, day] = dateKey.split("-").map(Number);
|
||||||
|
const current = Date.UTC(year, month - 1, day);
|
||||||
|
return Math.floor((current - Date.UTC(year, 0, 0)) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function solarTime(dateKey: string, sunrise: boolean) {
|
||||||
|
const radians = (degrees: number) => degrees * Math.PI / 180;
|
||||||
|
const degrees = (value: number) => value * 180 / Math.PI;
|
||||||
|
const longitudeHour = longitude / 15;
|
||||||
|
const approximateTime = dayOfYear(dateKey) + (((sunrise ? 6 : 18) - longitudeHour) / 24);
|
||||||
|
const meanAnomaly = (0.9856 * approximateTime) - 3.289;
|
||||||
|
const trueLongitude = normalizeDegrees(
|
||||||
|
meanAnomaly + (1.916 * Math.sin(radians(meanAnomaly))) + (0.02 * Math.sin(radians(2 * meanAnomaly))) + 282.634,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rightAscension = normalizeDegrees(degrees(Math.atan(0.91764 * Math.tan(radians(trueLongitude)))));
|
||||||
|
rightAscension += Math.floor(trueLongitude / 90) * 90 - Math.floor(rightAscension / 90) * 90;
|
||||||
|
rightAscension /= 15;
|
||||||
|
|
||||||
|
const sinDeclination = 0.39782 * Math.sin(radians(trueLongitude));
|
||||||
|
const cosDeclination = Math.cos(Math.asin(sinDeclination));
|
||||||
|
const cosHour = (
|
||||||
|
Math.cos(radians(90.833)) - (sinDeclination * Math.sin(radians(latitude)))
|
||||||
|
) / (cosDeclination * Math.cos(radians(latitude)));
|
||||||
|
if (cosHour > 1 || cosHour < -1) return "";
|
||||||
|
|
||||||
|
const hourAngle = (sunrise ? 360 - degrees(Math.acos(cosHour)) : degrees(Math.acos(cosHour))) / 15;
|
||||||
|
const localMeanTime = hourAngle + rightAscension - (0.06571 * approximateTime) - 6.622;
|
||||||
|
const utcHours = normalizeHours(localMeanTime - longitudeHour);
|
||||||
|
const [year, month, day] = dateKey.split("-").map(Number);
|
||||||
|
const instant = new Date(Date.UTC(year, month - 1, day, 0, Math.round(utcHours * 60)));
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: organizerTimeZone,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(instant);
|
||||||
|
const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? "00";
|
||||||
|
return `${dateKey}T${part("hour")}:${part("minute")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyForecast(periods: NwsPeriod[]) {
|
||||||
|
const days = new Map<string, DayAggregate>();
|
||||||
|
|
||||||
|
for (const period of periods) {
|
||||||
|
if (!period.startTime || typeof period.temperature !== "number") continue;
|
||||||
|
const date = period.startTime.slice(0, 10);
|
||||||
|
const temperature = fahrenheit(period.temperature, period.temperatureUnit);
|
||||||
|
const condition = period.shortForecast?.trim() || "Clear";
|
||||||
|
const existing = days.get(date) ?? {
|
||||||
|
date,
|
||||||
|
temperatures: [],
|
||||||
|
condition,
|
||||||
|
icon: weatherIcon(condition),
|
||||||
|
precipitationChance: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
existing.temperatures.push(temperature);
|
||||||
|
if (period.isDaytime) {
|
||||||
|
existing.high = temperature;
|
||||||
|
existing.condition = condition;
|
||||||
|
existing.icon = weatherIcon(condition);
|
||||||
|
} else {
|
||||||
|
existing.low = temperature;
|
||||||
|
}
|
||||||
|
existing.precipitationChance = Math.max(
|
||||||
|
existing.precipitationChance,
|
||||||
|
Number(period.probabilityOfPrecipitation?.value) || 0,
|
||||||
|
);
|
||||||
|
days.set(date, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...days.values()].slice(0, 7).map((day) => ({
|
||||||
|
date: day.date,
|
||||||
|
code: 0,
|
||||||
|
condition: day.condition,
|
||||||
|
icon: day.icon,
|
||||||
|
high: day.high ?? Math.max(...day.temperatures),
|
||||||
|
low: day.low ?? Math.min(...day.temperatures),
|
||||||
|
sunrise: solarTime(day.date, true),
|
||||||
|
sunset: solarTime(day.date, false),
|
||||||
|
precipitationChance: day.precipitationChance,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(configuredForecastUrl(), {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/geo+json",
|
||||||
|
"User-Agent": userAgent,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`NWS returned ${response.status}`);
|
||||||
|
|
||||||
|
const payload = await response.json() as NwsForecast;
|
||||||
|
const items = dailyForecast(payload.properties?.periods ?? []);
|
||||||
|
if (items.length < 7) throw new Error("NWS forecast did not include seven days");
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ location, latitude, longitude, source: "National Weather Service", items, updatedAt: new Date().toISOString() },
|
||||||
|
{ headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=1800" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Weather refresh failed", error instanceof Error ? error.message : "Unknown error");
|
||||||
|
return Response.json(
|
||||||
|
{ location, items: [], updatedAt: new Date().toISOString() },
|
||||||
|
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "../vendor/shadcn-tailwind-4.13.0.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: #070b12;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--card: #0c121d;
|
||||||
|
--card-foreground: #f8fafc;
|
||||||
|
--popover: #0c121d;
|
||||||
|
--popover-foreground: #f8fafc;
|
||||||
|
--primary: #a5f3fc;
|
||||||
|
--primary-foreground: #071018;
|
||||||
|
--secondary: #121a28;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #121a28;
|
||||||
|
--muted-foreground: #64748b;
|
||||||
|
--accent: #162132;
|
||||||
|
--accent-foreground: #f8fafc;
|
||||||
|
--destructive: #fb7185;
|
||||||
|
--border: #ffffff12;
|
||||||
|
--input: #ffffff16;
|
||||||
|
--ring: #67e8f9;
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--radius-sm: calc(var(--radius) - 6px);
|
||||||
|
--radius-md: calc(var(--radius) - 3px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 6px);
|
||||||
|
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: #070b12;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-shell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 0%, rgb(34 211 238 / 7%), transparent 34%),
|
||||||
|
radial-gradient(circle at 82% 18%, rgb(167 139 250 / 7%), transparent 30%),
|
||||||
|
linear-gradient(145deg, #080d16 0%, #060a11 48%, #080c14 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
opacity: 0.24;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
mask-image: linear-gradient(to bottom, black, transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
width: 34vw;
|
||||||
|
height: 34vw;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.08;
|
||||||
|
animation: ambient-drift 22s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-one { left: -14vw; top: 22vh; background: #22d3ee; }
|
||||||
|
.ambient-two { right: -12vw; top: 10vh; background: #8b5cf6; animation-delay: -9s; }
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 7px);
|
||||||
|
align-items: end;
|
||||||
|
gap: 4px;
|
||||||
|
width: 43px;
|
||||||
|
height: 43px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid rgb(103 232 249 / 22%);
|
||||||
|
border-radius: 13px;
|
||||||
|
background: linear-gradient(145deg, rgb(103 232 249 / 13%), rgb(103 232 249 / 3%));
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), 0 10px 30px rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span { display: block; border-radius: 99px; background: linear-gradient(to top, #22d3ee, #a5f3fc); }
|
||||||
|
.brand-mark span:nth-child(1) { height: 46%; }
|
||||||
|
.brand-mark span:nth-child(2) { height: 100%; }
|
||||||
|
.brand-mark span:nth-child(3) { height: 72%; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(155deg, rgb(17 25 39 / 88%), rgb(9 15 25 / 86%));
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 24px 70px rgb(0 0 0 / 16%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 { font-size: 1.1rem; font-weight: 800; letter-spacing: -0.02em; color: #f8fafc; }
|
||||||
|
|
||||||
|
.source-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgb(255 255 255 / 3%);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-demo { border-color: rgb(251 191 36 / 15%); color: #fde68a; background: rgb(251 191 36 / 5%); }
|
||||||
|
|
||||||
|
.source-live {
|
||||||
|
border-color: rgb(52 211 153 / 18%);
|
||||||
|
color: #a7f3d0;
|
||||||
|
background: rgb(52 211 153 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-loading { border-color: rgb(103 232 249 / 15%); color: #a5f3fc; background: rgb(103 232 249 / 5%); }
|
||||||
|
|
||||||
|
.source-partial,
|
||||||
|
.source-stale,
|
||||||
|
.source-error {
|
||||||
|
border-color: rgb(251 191 36 / 18%);
|
||||||
|
color: #fde68a;
|
||||||
|
background: rgb(251 191 36 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-strip {
|
||||||
|
display: grid;
|
||||||
|
min-height: 112px;
|
||||||
|
grid-template-columns: minmax(190px, 0.9fr) minmax(0, 5.1fr);
|
||||||
|
gap: 9px;
|
||||||
|
margin: 14px 24px 0;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 9px;
|
||||||
|
background: linear-gradient(105deg, rgb(34 211 238 / 6%), rgb(9 15 25 / 86%) 42%, rgb(251 191 36 / 4%));
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 18px 50px rgb(0 0 0 / 12%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-summary {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgb(103 232 249 / 12%);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgb(103 232 249 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-summary-top,
|
||||||
|
.forecast-day-heading,
|
||||||
|
.weather-location,
|
||||||
|
.weather-now,
|
||||||
|
.forecast-condition,
|
||||||
|
.sun-times,
|
||||||
|
.sun-times span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-summary-top,
|
||||||
|
.forecast-day-heading,
|
||||||
|
.sun-times { justify-content: space-between; }
|
||||||
|
|
||||||
|
.weather-location {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-location span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.weather-status {
|
||||||
|
flex: none;
|
||||||
|
color: #6ee7b7;
|
||||||
|
font-size: 0.54rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-loading,
|
||||||
|
.weather-stale,
|
||||||
|
.weather-error { color: #fcd34d; }
|
||||||
|
|
||||||
|
.weather-now { gap: 10px; margin-top: 8px; }
|
||||||
|
.weather-now-icon { width: 38px; height: 38px; flex: none; color: #fde68a; stroke-width: 1.65; }
|
||||||
|
.weather-now-temp { display: flex; align-items: baseline; gap: 7px; line-height: 1; }
|
||||||
|
.weather-now-temp span { color: #fff; font-size: 1.65rem; font-weight: 300; letter-spacing: -0.06em; }
|
||||||
|
.weather-now-temp small { color: #64748b; font-size: 0.9rem; font-weight: 650; }
|
||||||
|
.weather-now p { overflow: hidden; margin-top: 4px; color: #94a3b8; font-size: 0.67rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.weather-pending { opacity: 0.72; }
|
||||||
|
|
||||||
|
.forecast-grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-day {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgb(255 255 255 / 7%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
background: rgb(255 255 255 / 2.2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-today {
|
||||||
|
border-color: rgb(103 232 249 / 24%);
|
||||||
|
background: rgb(103 232 249 / 5.5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-day-heading span { color: #94a3b8; font-size: 0.59rem; font-weight: 900; letter-spacing: 0.09em; }
|
||||||
|
.forecast-today .forecast-day-heading span { color: #a5f3fc; }
|
||||||
|
.forecast-day-heading small { color: #475569; font-size: 0.54rem; font-weight: 700; }
|
||||||
|
.forecast-condition { justify-content: center; gap: 8px; margin-top: 5px; }
|
||||||
|
.forecast-icon { width: 25px; height: 25px; flex: none; color: #fde68a; stroke-width: 1.6; }
|
||||||
|
.forecast-condition > span { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.forecast-condition strong { color: #f8fafc; font-size: 1rem; font-weight: 650; }
|
||||||
|
.forecast-condition small { color: #64748b; font-size: 0.68rem; font-weight: 650; }
|
||||||
|
.forecast-rain { margin-top: 3px; color: #64748b; font-size: 0.49rem; font-weight: 800; letter-spacing: 0.08em; text-align: center; }
|
||||||
|
.sun-times { gap: 4px; margin-top: 5px; color: #64748b; font-size: 0.51rem; font-weight: 650; }
|
||||||
|
.sun-times span { min-width: 0; gap: 2px; }
|
||||||
|
.sun-times svg { width: 10px; height: 10px; flex: none; color: #fcd34d; }
|
||||||
|
.forecast-placeholder { display: grid; align-content: center; gap: 8px; opacity: 0.45; }
|
||||||
|
.forecast-placeholder span { height: 7px; border-radius: 999px; background: rgb(148 163 184 / 12%); }
|
||||||
|
.forecast-placeholder span:nth-child(2) { width: 70%; }
|
||||||
|
.forecast-placeholder span:nth-child(3) { width: 48%; }
|
||||||
|
|
||||||
|
.calendar-heading-actions,
|
||||||
|
.calendar-view-tabs,
|
||||||
|
.calendar-period-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-heading-actions { gap: 10px; }
|
||||||
|
|
||||||
|
.calendar-view-tabs {
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px;
|
||||||
|
background: rgb(255 255 255 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-view-tabs button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-view-tabs button.is-active {
|
||||||
|
color: #cffafe;
|
||||||
|
background: rgb(34 211 238 / 13%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(103 232 249 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-view-tabs button:focus-visible,
|
||||||
|
.calendar-period-bar button:focus-visible,
|
||||||
|
.week-event:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgb(103 232 249 / 60%), 0 0 0 5px rgb(34 211 238 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-view-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-period-bar {
|
||||||
|
min-height: 34px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-period-bar p {
|
||||||
|
min-width: 150px;
|
||||||
|
margin-right: auto;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-period-bar button {
|
||||||
|
display: grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 9%);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgb(255 255 255 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-period-bar button svg { width: 15px; height: 15px; }
|
||||||
|
.calendar-period-bar .calendar-today-button { width: auto; padding: 0 10px; font-size: 0.58rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.week-grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(255 255 255 / 7%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 7px;
|
||||||
|
background: rgb(255 255 255 / 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day.is-today { border-color: rgb(103 232 249 / 26%); background: rgb(103 232 249 / 4.5%); }
|
||||||
|
.week-day-heading { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid rgb(255 255 255 / 6%); padding-bottom: 6px; }
|
||||||
|
.week-day-heading span { color: #64748b; font-size: 0.57rem; font-weight: 900; letter-spacing: 0.08em; }
|
||||||
|
.week-day-heading strong { color: #cbd5e1; font-size: 0.92rem; font-weight: 500; }
|
||||||
|
.week-day.is-today .week-day-heading span,
|
||||||
|
.week-day.is-today .week-day-heading strong { color: #a5f3fc; }
|
||||||
|
.week-events { display: grid; gap: 5px; margin-top: 7px; }
|
||||||
|
.week-event { min-width: 0; border: 1px solid rgb(255 255 255 / 6%); border-left: 3px solid; border-radius: 7px; padding: 6px; background: rgb(2 6 13 / 32%); }
|
||||||
|
.week-event strong,
|
||||||
|
.week-event span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.week-event strong { color: #e2e8f0; font-size: 0.65rem; font-weight: 750; }
|
||||||
|
.week-event span { margin-top: 2px; color: #64748b; font-size: 0.55rem; font-weight: 650; }
|
||||||
|
.calendar-no-events { display: block; padding-top: 8px; color: #334155; font-size: 0.78rem; text-align: center; }
|
||||||
|
.calendar-more { color: #67e8f9; font-size: 0.54rem; font-weight: 800; }
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
grid-template-rows: auto repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-weekday { padding: 0 5px 3px; color: #475569; font-size: 0.55rem; font-weight: 900; letter-spacing: 0.1em; text-align: right; }
|
||||||
|
.month-cell { min-width: 0; overflow: hidden; border: 1px solid rgb(255 255 255 / 6%); border-radius: 8px; padding: 4px 5px; background: rgb(255 255 255 / 1.8%); }
|
||||||
|
.month-cell.is-outside { opacity: 0.34; }
|
||||||
|
.month-cell.is-today { border-color: rgb(103 232 249 / 45%); background: rgb(103 232 249 / 6%); box-shadow: inset 0 0 0 1px rgb(103 232 249 / 10%); }
|
||||||
|
.month-date { display: block; color: #94a3b8; font-size: 0.61rem; font-weight: 750; line-height: 1; text-align: right; }
|
||||||
|
.month-cell.is-today .month-date { color: #cffafe; }
|
||||||
|
.month-events { display: grid; gap: 2px; margin-top: 3px; }
|
||||||
|
.month-event { display: flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; color: #cbd5e1; font-size: 0.53rem; font-weight: 650; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.month-event span { width: 5px; height: 5px; flex: none; border-radius: 999px; }
|
||||||
|
|
||||||
|
.next-event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 128px;
|
||||||
|
border: 1px solid rgb(103 232 249 / 14%);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 19px 22px;
|
||||||
|
background: linear-gradient(100deg, rgb(34 211 238 / 10%), transparent 58%), rgb(255 255 255 / 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow { font-size: 0.66rem; font-weight: 900; letter-spacing: 0.18em; color: #64748b; }
|
||||||
|
|
||||||
|
.calendar-row,
|
||||||
|
.headline-row,
|
||||||
|
.ticker-cell {
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-row {
|
||||||
|
display: flex;
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
background: rgb(255 255 255 / 2.3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-accent {
|
||||||
|
width: 4px;
|
||||||
|
height: 38px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-detail {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 210px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed rgb(255 255 255 / 10%);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-row:focus-visible,
|
||||||
|
.headline-row:focus-visible,
|
||||||
|
.ticker-cell:focus-visible {
|
||||||
|
border-color: rgb(103 232 249 / 65%);
|
||||||
|
background: rgb(103 232 249 / 7%);
|
||||||
|
box-shadow: 0 0 0 3px rgb(34 211 238 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-row:focus-visible { transform: translateX(4px); }
|
||||||
|
|
||||||
|
.headline-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 13px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 13px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-row:disabled { cursor: default; }
|
||||||
|
|
||||||
|
.headline-qr-cue {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid rgb(196 181 253 / 16%);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
color: #c4b5fd;
|
||||||
|
background: rgb(196 181 253 / 5%);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-row:focus-visible { transform: translateX(-3px); }
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 0 0 rgb(52 211 153 / 45%);
|
||||||
|
animation: pulse-ring 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-pulse-loading,
|
||||||
|
.market-pulse-demo,
|
||||||
|
.market-pulse-stale,
|
||||||
|
.market-pulse-partial {
|
||||||
|
background: #fbbf24;
|
||||||
|
box-shadow: 0 0 0 0 rgb(251 191 36 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-window {
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: linear-gradient(90deg, transparent, black 3.5%, black 96.5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-track { display: flex; width: max-content; animation: ticker-scroll 38s linear infinite; }
|
||||||
|
|
||||||
|
.ticker-cell {
|
||||||
|
width: 290px;
|
||||||
|
flex: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-right-color: rgb(255 255 255 / 8%);
|
||||||
|
padding: 4px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgb(2 6 13 / 88%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-dialog {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: min(820px, calc(100vw - 48px));
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
border: 1px solid rgb(196 181 253 / 24%);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 26px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 16%, rgb(34 211 238 / 10%), transparent 38%),
|
||||||
|
linear-gradient(145deg, #111a2a, #090f1a);
|
||||||
|
box-shadow: 0 36px 120px rgb(0 0 0 / 60%), inset 0 1px 0 rgb(255 255 255 / 7%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-frame {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 17px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 55px rgb(34 211 238 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-frame svg { display: block; width: 264px; height: 264px; }
|
||||||
|
|
||||||
|
.qr-copy { min-width: 0; padding-right: 8px; }
|
||||||
|
|
||||||
|
.qr-source {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #c4b5fd;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-source span { color: #475569; }
|
||||||
|
|
||||||
|
.qr-copy h2 {
|
||||||
|
margin: 12px 0 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.65rem;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.16;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-copy p { margin: 0; color: #94a3b8; font-size: 0.92rem; line-height: 1.55; }
|
||||||
|
|
||||||
|
.qr-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgb(255 255 255 / 5%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-close:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgb(103 232 249 / 70%);
|
||||||
|
box-shadow: 0 0 0 4px rgb(34 211 238 / 14%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker-scroll { to { transform: translateX(-50%); } }
|
||||||
|
@keyframes pulse-ring { 70% { box-shadow: 0 0 0 8px rgb(52 211 153 / 0%); } 100% { box-shadow: 0 0 0 0 rgb(52 211 153 / 0%); } }
|
||||||
|
@keyframes ambient-drift { from { transform: translate3d(0, -2%, 0) scale(0.96); } to { transform: translate3d(5%, 5%, 0) scale(1.07); } }
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.dashboard-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 60px 86px minmax(0, 1fr) 56px; }
|
||||||
|
.dashboard-header { min-height: 60px; padding: 7px 14px; }
|
||||||
|
.brand-group { gap: 11px; }
|
||||||
|
.brand-mark { width: 35px; height: 35px; padding: 7px; border-radius: 11px; }
|
||||||
|
.weather-strip { min-height: 0; margin: 5px 11px 0; gap: 5px; border-radius: 14px; padding: 5px; }
|
||||||
|
.weather-summary { border-radius: 10px; padding: 5px 8px; }
|
||||||
|
.weather-location { gap: 3px; font-size: 0.51rem; }
|
||||||
|
.weather-location svg { width: 10px; height: 10px; }
|
||||||
|
.weather-status { font-size: 0.45rem; }
|
||||||
|
.weather-now { gap: 6px; margin-top: 4px; }
|
||||||
|
.weather-now-icon { width: 27px; height: 27px; }
|
||||||
|
.weather-now-temp { gap: 4px; }
|
||||||
|
.weather-now-temp span { font-size: 1.15rem; }
|
||||||
|
.weather-now-temp small { font-size: 0.66rem; }
|
||||||
|
.weather-now p { margin-top: 2px; font-size: 0.52rem; }
|
||||||
|
.forecast-grid { gap: 4px; }
|
||||||
|
.forecast-day { border-radius: 9px; padding: 3px 5px; }
|
||||||
|
.forecast-day-heading span { font-size: 0.48rem; }
|
||||||
|
.forecast-day-heading small { font-size: 0.44rem; }
|
||||||
|
.forecast-condition { gap: 4px; margin-top: 2px; }
|
||||||
|
.forecast-icon { width: 17px; height: 17px; }
|
||||||
|
.forecast-condition > span { gap: 3px; }
|
||||||
|
.forecast-condition strong { font-size: 0.76rem; }
|
||||||
|
.forecast-condition small { font-size: 0.53rem; }
|
||||||
|
.forecast-rain { margin-top: 1px; font-size: 0.4rem; }
|
||||||
|
.sun-times { gap: 2px; margin-top: 2px; font-size: 0.43rem; }
|
||||||
|
.sun-times svg { width: 8px; height: 8px; }
|
||||||
|
.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(310px, 0.9fr); gap: 8px; padding: 7px 11px; }
|
||||||
|
.panel { padding: 10px; border-radius: 16px; }
|
||||||
|
.panel-heading { margin-bottom: 7px; }
|
||||||
|
.panel-heading h2 { font-size: 0.96rem; }
|
||||||
|
.source-pill { gap: 5px; padding: 4px 8px; font-size: 0.57rem; }
|
||||||
|
.calendar-heading-actions { gap: 6px; }
|
||||||
|
.calendar-view-tabs button { padding: 4px 7px; font-size: 0.51rem; }
|
||||||
|
.calendar-period-bar { min-height: 25px; gap: 4px; margin-bottom: 5px; }
|
||||||
|
.calendar-period-bar p { min-width: 120px; font-size: 0.59rem; }
|
||||||
|
.calendar-period-bar button { width: 25px; height: 23px; border-radius: 6px; }
|
||||||
|
.calendar-period-bar button svg { width: 13px; height: 13px; }
|
||||||
|
.calendar-period-bar .calendar-today-button { padding-inline: 7px; font-size: 0.48rem; }
|
||||||
|
.next-event-card { min-height: 66px; padding: 7px 11px; border-radius: 13px; }
|
||||||
|
.next-event-title { margin-top: 3px; font-size: 1.22rem; }
|
||||||
|
.next-event-meta { margin-top: 1px; font-size: 0.73rem; }
|
||||||
|
.next-event-countdown { padding-left: 12px; }
|
||||||
|
.next-event-countdown > p:first-child { font-size: 1.45rem; }
|
||||||
|
.next-event-countdown > p:last-child { margin-top: 0; font-size: 0.56rem; }
|
||||||
|
.eyebrow { font-size: 0.55rem; }
|
||||||
|
.event-list { margin-top: 7px; }
|
||||||
|
.event-list-heading { margin-bottom: 4px; }
|
||||||
|
.event-list-heading > p:last-child { font-size: 0.62rem; }
|
||||||
|
.event-list-grid { gap: 4px; }
|
||||||
|
.calendar-row { min-height: 43px; gap: 7px; border-radius: 10px; padding: 3px 7px; }
|
||||||
|
.calendar-date { width: 53px; padding-right: 7px; }
|
||||||
|
.calendar-day { font-size: 0.51rem; }
|
||||||
|
.calendar-date-number { margin-top: 0; font-size: 1.12rem; line-height: 1.05; }
|
||||||
|
.calendar-accent { width: 3px; height: 25px; }
|
||||||
|
.calendar-event-title { font-size: 0.83rem; }
|
||||||
|
.calendar-event-time { font-size: 0.69rem; }
|
||||||
|
.calendar-detail { gap: 4px; margin-top: 0; font-size: 0.62rem; }
|
||||||
|
.calendar-dot { width: 5px; height: 5px; }
|
||||||
|
.week-grid { gap: 4px; }
|
||||||
|
.week-day { border-radius: 9px; padding: 5px; }
|
||||||
|
.week-day-heading { padding-bottom: 4px; }
|
||||||
|
.week-day-heading span { font-size: 0.47rem; }
|
||||||
|
.week-day-heading strong { font-size: 0.72rem; }
|
||||||
|
.week-events { gap: 4px; margin-top: 5px; }
|
||||||
|
.week-event { border-radius: 6px; padding: 4px; }
|
||||||
|
.week-event strong { font-size: 0.55rem; }
|
||||||
|
.week-event span { margin-top: 1px; font-size: 0.47rem; }
|
||||||
|
.calendar-more { font-size: 0.47rem; }
|
||||||
|
.month-grid { gap: 2px; }
|
||||||
|
.month-weekday { padding: 0 3px 2px; font-size: 0.45rem; }
|
||||||
|
.month-cell { border-radius: 6px; padding: 3px 4px; }
|
||||||
|
.month-date { font-size: 0.5rem; }
|
||||||
|
.month-events { gap: 1px; margin-top: 2px; }
|
||||||
|
.month-event { gap: 3px; font-size: 0.44rem; }
|
||||||
|
.month-event span { width: 4px; height: 4px; }
|
||||||
|
.headline-row { gap: 8px; padding: 5px 6px; border-radius: 9px; }
|
||||||
|
.headline-row > div:first-child { width: 7px; height: 7px; }
|
||||||
|
.headline-row h3 { margin-top: 3px; font-size: 0.79rem; line-height: 1.2; }
|
||||||
|
.headline-row:first-child h3 { font-size: 0.87rem; }
|
||||||
|
.headline-qr-cue { padding: 1px 5px; font-size: 0.48rem; }
|
||||||
|
.phone-handoff { margin-top: 6px; padding: 7px 10px; border-radius: 12px; }
|
||||||
|
.phone-handoff p:first-child { font-size: 0.76rem; }
|
||||||
|
.phone-handoff p:last-child { font-size: 0.64rem; }
|
||||||
|
.dashboard-footer { overflow: hidden; padding-block: 2px; }
|
||||||
|
.footer-meta { min-height: 14px; margin-bottom: 0; padding-inline: 14px; line-height: 1; }
|
||||||
|
.ticker-cell { display: flex; width: 224px; height: 36px; flex-direction: column; justify-content: center; padding: 0 16px; }
|
||||||
|
.ticker-cell > div:first-child { min-height: 11px; line-height: 1; }
|
||||||
|
.ticker-cell > div:nth-child(2) { min-height: 18px; margin-top: 1px; line-height: 1; }
|
||||||
|
.quote-symbol { font-size: 0.63rem; line-height: 1; }
|
||||||
|
.quote-name { font-size: 0.58rem; line-height: 1; }
|
||||||
|
.quote-value { font-size: 0.96rem; line-height: 1; }
|
||||||
|
.quote-move { font-size: 0.68rem; line-height: 1; }
|
||||||
|
.quote-move svg { width: 12px; height: 12px; }
|
||||||
|
.qr-dialog { width: min(720px, calc(100vw - 28px)); gap: 20px; border-radius: 20px; padding: 18px; }
|
||||||
|
.qr-code-frame { border-radius: 14px; padding: 12px; }
|
||||||
|
.qr-code-frame svg { width: 220px; height: 220px; }
|
||||||
|
.qr-copy h2 { margin-block: 8px; font-size: 1.22rem; }
|
||||||
|
.qr-copy p { font-size: 0.72rem; line-height: 1.42; }
|
||||||
|
.qr-source { gap: 6px; font-size: 0.56rem; }
|
||||||
|
.qr-close { top: 9px; right: 9px; padding: 5px 8px; font-size: 0.62rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
body { overflow: auto; }
|
||||||
|
.dashboard-shell { display: flex; height: auto; min-height: 100vh; overflow: visible; }
|
||||||
|
.dashboard-shell > section { grid-template-columns: 1fr; }
|
||||||
|
.weather-strip { min-height: 208px; margin: 10px; }
|
||||||
|
.weather-summary { min-height: 70px; }
|
||||||
|
.forecast-grid { grid-template-columns: repeat(7, minmax(76px, 1fr)); overflow-x: auto; }
|
||||||
|
.panel { min-height: 560px; }
|
||||||
|
.qr-dialog { grid-template-columns: 1fr; text-align: center; }
|
||||||
|
.qr-code-frame { width: max-content; margin: 0 auto; }
|
||||||
|
.qr-copy { padding: 0; }
|
||||||
|
.qr-source { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ticker-track,
|
||||||
|
.pulse-dot,
|
||||||
|
.ambient { animation: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Hobbs' Family Organizer App",
|
||||||
|
description: "A private Hobbs family organizer for calendars, weather, markets, commodities, and AI and technology headlines.",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.svg",
|
||||||
|
shortcut: "/favicon.svg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Cloud,
|
||||||
|
CloudFog,
|
||||||
|
CloudLightning,
|
||||||
|
CloudRain,
|
||||||
|
CloudSun,
|
||||||
|
MapPin,
|
||||||
|
Minus,
|
||||||
|
Newspaper,
|
||||||
|
QrCode,
|
||||||
|
Snowflake,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Sunrise,
|
||||||
|
Sunset,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Wifi,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
|
||||||
|
type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
calendar: string;
|
||||||
|
title: string;
|
||||||
|
location?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allDay: boolean;
|
||||||
|
tone: "cyan" | "amber" | "violet" | "emerald" | "rose";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarState = "loading" | "live" | "partial" | "stale" | "error";
|
||||||
|
type CalendarView = "agenda" | "week" | "month";
|
||||||
|
|
||||||
|
type WeatherDay = {
|
||||||
|
date: string;
|
||||||
|
code: number;
|
||||||
|
condition: string;
|
||||||
|
icon: "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
sunrise: string;
|
||||||
|
sunset: string;
|
||||||
|
precipitationChance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeatherState = "loading" | "live" | "stale" | "error";
|
||||||
|
|
||||||
|
type Headline = {
|
||||||
|
source: string;
|
||||||
|
age: string;
|
||||||
|
title: string;
|
||||||
|
tone: "cyan" | "amber" | "violet";
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Quote = {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
move: string;
|
||||||
|
direction: "up" | "down" | "flat";
|
||||||
|
seriesId?: string;
|
||||||
|
observedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarketState = "loading" | "live" | "partial" | "stale" | "demo";
|
||||||
|
|
||||||
|
const demoHeadlines: Headline[] = [
|
||||||
|
{ source: "OPENAI", age: "18m", title: "New tools push multimodal assistants deeper into everyday work", tone: "cyan" },
|
||||||
|
{ source: "ARS TECHNICA", age: "42m", title: "Chipmakers rethink the data-center roadmap for efficient inference", tone: "amber" },
|
||||||
|
{ source: "GOOGLE AI", age: "1h", title: "Research update explores smaller models with stronger reasoning", tone: "violet" },
|
||||||
|
{ source: "WIRED", age: "2h", title: "The next wave of personal AI devices is taking shape", tone: "cyan" },
|
||||||
|
{ source: "NVIDIA", age: "3h", title: "New accelerated-computing platform targets local AI workloads", tone: "amber" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const demoQuotes: Quote[] = [
|
||||||
|
{ symbol: "S&P 500", name: "Previous close", value: "5,782.41", move: "+0.62%", direction: "up" },
|
||||||
|
{ symbol: "NASDAQ", name: "Previous close", value: "18,439.17", move: "+0.91%", direction: "up" },
|
||||||
|
{ symbol: "DOW", name: "Previous close", value: "42,114.40", move: "+0.38%", direction: "up" },
|
||||||
|
{ symbol: "WTI", name: "Crude oil", value: "$67.42", move: "-1.18%", direction: "down" },
|
||||||
|
{ symbol: "NAT GAS", name: "Henry Hub", value: "$3.12", move: "-0.54%", direction: "down" },
|
||||||
|
{ symbol: "COPPER", name: "Per pound", value: "$4.31", move: "+0.21%", direction: "up" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toneClasses = {
|
||||||
|
cyan: "bg-cyan-300",
|
||||||
|
amber: "bg-amber-300",
|
||||||
|
violet: "bg-violet-300",
|
||||||
|
emerald: "bg-emerald-300",
|
||||||
|
rose: "bg-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarToneColors = {
|
||||||
|
cyan: "#67e8f9",
|
||||||
|
amber: "#fcd34d",
|
||||||
|
violet: "#c4b5fd",
|
||||||
|
emerald: "#6ee7b7",
|
||||||
|
rose: "#fda4af",
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarViews: CalendarView[] = ["agenda", "week", "month"];
|
||||||
|
let organizerTimeZone = "America/Chicago";
|
||||||
|
|
||||||
|
const weatherIcons = {
|
||||||
|
clear: Sun,
|
||||||
|
"partly-cloudy": CloudSun,
|
||||||
|
cloudy: Cloud,
|
||||||
|
fog: CloudFog,
|
||||||
|
rain: CloudRain,
|
||||||
|
snow: Snowflake,
|
||||||
|
storm: CloudLightning,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatClock(date: Date | null) {
|
||||||
|
if (!date) return { time: "--:--", period: "", date: "Loading dashboard…" };
|
||||||
|
const clock = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: organizerTimeZone,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: `${clock.find((part) => part.type === "hour")?.value ?? ""}:${clock.find((part) => part.type === "minute")?.value ?? ""}`,
|
||||||
|
period: clock.find((part) => part.type === "dayPeriod")?.value ?? "",
|
||||||
|
date: new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: organizerTimeZone,
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(date),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerDateKey(date: Date) {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: organizerTimeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
|
||||||
|
return `${value("year")}-${value("month")}-${value("day")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDayDate(value: string) {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return new Date(Date.UTC(year, month - 1, day, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDateKey(value: string, days: number) {
|
||||||
|
const date = allDayDate(value);
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonthsToDateKey(value: string, months: number) {
|
||||||
|
const [year, month] = value.split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1 + months, 1, 12));
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeekKey(value: string) {
|
||||||
|
const date = allDayDate(value);
|
||||||
|
const mondayOffset = (date.getUTCDay() + 6) % 7;
|
||||||
|
return addDateKey(value, -mondayOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekDateKeys(value: string) {
|
||||||
|
const start = startOfWeekKey(value);
|
||||||
|
return Array.from({ length: 7 }, (_, index) => addDateKey(start, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthDateKeys(value: string) {
|
||||||
|
const first = `${value.slice(0, 7)}-01`;
|
||||||
|
const start = startOfWeekKey(first);
|
||||||
|
return Array.from({ length: 42 }, (_, index) => addDateKey(start, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDateKey(event: CalendarEvent) {
|
||||||
|
return event.allDay ? event.start : organizerDateKey(new Date(event.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventOccursOnDate(event: CalendarEvent, dateKey: string) {
|
||||||
|
if (event.allDay) return event.start <= dateKey && event.end > dateKey;
|
||||||
|
const start = organizerDateKey(new Date(event.start));
|
||||||
|
const inclusiveEnd = organizerDateKey(new Date(Math.max(Date.parse(event.start), Date.parse(event.end) - 1)));
|
||||||
|
return start <= dateKey && inclusiveEnd >= dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIsUpcoming(event: CalendarEvent, now: Date) {
|
||||||
|
if (event.allDay) return event.end > organizerDateKey(now);
|
||||||
|
return Date.parse(event.end) > now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateKey(value: string, options: Intl.DateTimeFormatOptions) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(allDayDate(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactEventTime(event: CalendarEvent, dateKey: string) {
|
||||||
|
if (event.allDay) return "All day";
|
||||||
|
if (eventDateKey(event) !== dateKey) return "Continues";
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: organizerTimeZone,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(event.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSunTime(value: string) {
|
||||||
|
const time = value.split("T")[1]?.slice(0, 5);
|
||||||
|
if (!time) return "--";
|
||||||
|
const [hour, minute] = time.split(":").map(Number);
|
||||||
|
const displayHour = hour % 12 || 12;
|
||||||
|
return `${displayHour}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeatherGlyph({ icon, className }: { icon: WeatherDay["icon"]; className?: string }) {
|
||||||
|
const Glyph = weatherIcons[icon] ?? CloudSun;
|
||||||
|
return <Glyph className={className} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventRow(event: CalendarEvent, now: Date) {
|
||||||
|
const today = organizerDateKey(now);
|
||||||
|
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
|
||||||
|
const eventDate = event.allDay ? event.start : organizerDateKey(startDate);
|
||||||
|
const isToday = eventDate === today;
|
||||||
|
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: isToday ? "TODAY" : new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(startDate).toUpperCase(),
|
||||||
|
date: new Intl.DateTimeFormat("en-US", { timeZone, day: "2-digit" }).format(startDate),
|
||||||
|
time: event.allDay ? "ALL DAY" : new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(startDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIsHappening(event: CalendarEvent, now: Date) {
|
||||||
|
if (event.allDay) {
|
||||||
|
const today = organizerDateKey(now);
|
||||||
|
return event.start <= today && event.end > today;
|
||||||
|
}
|
||||||
|
const time = now.getTime();
|
||||||
|
return Date.parse(event.start) <= time && Date.parse(event.end) > time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventWhen(event: CalendarEvent, now: Date) {
|
||||||
|
const row = formatEventRow(event, now);
|
||||||
|
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
|
||||||
|
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
|
||||||
|
const dateLabel = event.allDay && event.start === organizerDateKey(now)
|
||||||
|
? "Today"
|
||||||
|
: new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" }).format(startDate);
|
||||||
|
return `${dateLabel} · ${event.allDay ? "All day" : row.time} · ${event.calendar}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(event: CalendarEvent, now: Date) {
|
||||||
|
if (eventIsHappening(event, now)) return "NOW";
|
||||||
|
if (event.allDay) {
|
||||||
|
const today = allDayDate(organizerDateKey(now)).getTime();
|
||||||
|
const start = allDayDate(event.start).getTime();
|
||||||
|
const days = Math.max(1, Math.round((start - today) / 86_400_000));
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.max(1, Math.round((Date.parse(event.start) - now.getTime()) / 60_000));
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
if (minutes < 1_440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||||
|
return `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatObservationDate(value?: string) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headlineHost(value?: string) {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return "Article link";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoteCell({ quote }: { quote: Quote }) {
|
||||||
|
const rising = quote.direction === "up";
|
||||||
|
const falling = quote.direction === "down";
|
||||||
|
const TrendIcon = rising ? TrendingUp : falling ? TrendingDown : Minus;
|
||||||
|
const movementColor = rising ? "text-emerald-300" : falling ? "text-rose-300" : "text-slate-400";
|
||||||
|
const observed = formatObservationDate(quote.observedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ticker-cell" tabIndex={0}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="quote-symbol text-[0.78rem] font-black tracking-[0.12em] text-slate-100">{quote.symbol}</span>
|
||||||
|
<span className="quote-name text-[0.76rem] text-slate-500">{quote.name}{observed && ` · ${observed}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
<span className="quote-value text-[1.35rem] font-semibold tabular-nums text-white">{quote.value}</span>
|
||||||
|
<span className={`quote-move flex items-center gap-1 text-[0.9rem] font-bold tabular-nums ${movementColor}`}>
|
||||||
|
<TrendIcon className="h-4 w-4" strokeWidth={2.5} />
|
||||||
|
{quote.move}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [now, setNow] = useState<Date | null>(null);
|
||||||
|
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [calendarState, setCalendarState] = useState<CalendarState>("loading");
|
||||||
|
const [calendarFeeds, setCalendarFeeds] = useState({ available: 0, total: 0 });
|
||||||
|
const [calendarView, setCalendarView] = useState<CalendarView>("agenda");
|
||||||
|
const [calendarAnchor, setCalendarAnchor] = useState("");
|
||||||
|
const [weatherDays, setWeatherDays] = useState<WeatherDay[]>([]);
|
||||||
|
const [weatherState, setWeatherState] = useState<WeatherState>("loading");
|
||||||
|
const [weatherLocation, setWeatherLocation] = useState("Sapulpa, OK");
|
||||||
|
const [headlines, setHeadlines] = useState<Headline[]>(demoHeadlines);
|
||||||
|
const [newsLive, setNewsLive] = useState(false);
|
||||||
|
const [quotes, setQuotes] = useState<Quote[]>(demoQuotes);
|
||||||
|
const [marketState, setMarketState] = useState<MarketState>("loading");
|
||||||
|
const [marketFeeds, setMarketFeeds] = useState({ available: 0, total: demoQuotes.length });
|
||||||
|
const [selectedHeadline, setSelectedHeadline] = useState<Headline | null>(null);
|
||||||
|
const hasCalendarData = useRef(false);
|
||||||
|
const hasWeatherData = useRef(false);
|
||||||
|
const hasMarketData = useRef(false);
|
||||||
|
const qrCloseRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [, setConfigurationVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void fetch("/api/config", { cache: "no-store" })
|
||||||
|
.then(async (response) => {
|
||||||
|
const payload = await response.json() as { timeZone?: string };
|
||||||
|
if (!response.ok || !active || !payload.timeZone) return;
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: payload.timeZone }).format(new Date());
|
||||||
|
organizerTimeZone = payload.timeZone;
|
||||||
|
const value = new Date();
|
||||||
|
setNow(value);
|
||||||
|
setCalendarAnchor(organizerDateKey(value));
|
||||||
|
setConfigurationVersion((current) => current + 1);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Keep the built-in timezone when runtime configuration is unavailable.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateClock = () => {
|
||||||
|
const value = new Date();
|
||||||
|
setNow(value);
|
||||||
|
setCalendarAnchor((current) => current || organizerDateKey(value));
|
||||||
|
};
|
||||||
|
const frame = window.requestAnimationFrame(updateClock);
|
||||||
|
const timer = window.setInterval(updateClock, 15_000);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const refreshWeather = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/weather", { cache: "no-store" });
|
||||||
|
const payload = await response.json() as { location?: string; items?: WeatherDay[] };
|
||||||
|
if (!response.ok || !payload.items?.length || !active) throw new Error("Weather unavailable");
|
||||||
|
|
||||||
|
hasWeatherData.current = true;
|
||||||
|
setWeatherDays(payload.items.slice(0, 7));
|
||||||
|
setWeatherLocation(payload.location || "Sapulpa, OK");
|
||||||
|
setWeatherState("live");
|
||||||
|
} catch {
|
||||||
|
if (!active) return;
|
||||||
|
setWeatherState(hasWeatherData.current ? "stale" : "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshWeather();
|
||||||
|
const timer = window.setInterval(refreshWeather, 30 * 60_000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeQrFromHistory = () => setSelectedHeadline(null);
|
||||||
|
window.addEventListener("popstate", closeQrFromHistory);
|
||||||
|
return () => window.removeEventListener("popstate", closeQrFromHistory);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedHeadline) return;
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => qrCloseRef.current?.focus());
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const state = window.history.state as { daylineQr?: boolean } | null;
|
||||||
|
if (state?.daylineQr) window.history.back();
|
||||||
|
else setSelectedHeadline(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [selectedHeadline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const refreshMarkets = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/markets", { cache: "no-store" });
|
||||||
|
const payload = await response.json() as {
|
||||||
|
items?: Quote[];
|
||||||
|
seriesAvailable?: number;
|
||||||
|
seriesTotal?: number;
|
||||||
|
};
|
||||||
|
if (!response.ok || !payload.items?.length || !active) throw new Error("FRED unavailable");
|
||||||
|
|
||||||
|
hasMarketData.current = true;
|
||||||
|
setQuotes(payload.items);
|
||||||
|
setMarketFeeds({
|
||||||
|
available: payload.seriesAvailable ?? payload.items.length,
|
||||||
|
total: payload.seriesTotal ?? payload.items.length,
|
||||||
|
});
|
||||||
|
setMarketState(
|
||||||
|
payload.seriesAvailable === payload.seriesTotal ? "live" : "partial",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (!active) return;
|
||||||
|
setMarketState(hasMarketData.current ? "stale" : "demo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshMarkets();
|
||||||
|
const timer = window.setInterval(refreshMarkets, 15 * 60_000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const refreshCalendar = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/calendar", { cache: "no-store" });
|
||||||
|
const payload = await response.json() as {
|
||||||
|
items?: CalendarEvent[];
|
||||||
|
calendarsAvailable?: number;
|
||||||
|
calendarsTotal?: number;
|
||||||
|
};
|
||||||
|
if (!response.ok || !payload.items || !active) throw new Error("Calendar unavailable");
|
||||||
|
|
||||||
|
hasCalendarData.current = true;
|
||||||
|
setCalendarEvents(payload.items);
|
||||||
|
setCalendarFeeds({
|
||||||
|
available: payload.calendarsAvailable ?? 0,
|
||||||
|
total: payload.calendarsTotal ?? 0,
|
||||||
|
});
|
||||||
|
setCalendarState(
|
||||||
|
payload.calendarsAvailable === payload.calendarsTotal ? "live" : "partial",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (!active) return;
|
||||||
|
setCalendarState(hasCalendarData.current ? "stale" : "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshCalendar();
|
||||||
|
const timer = window.setInterval(refreshCalendar, 5 * 60_000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const refreshHeadlines = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/headlines", { cache: "no-store" });
|
||||||
|
const payload = await response.json() as {
|
||||||
|
items?: Array<{ source: string; title: string; url: string; publishedAt: string | null }>;
|
||||||
|
};
|
||||||
|
if (!response.ok || !payload.items?.length || !active) return;
|
||||||
|
|
||||||
|
const tones: Headline["tone"][] = ["cyan", "amber", "violet"];
|
||||||
|
setHeadlines(payload.items.slice(0, 5).map((item, index) => {
|
||||||
|
const published = item.publishedAt ? Date.parse(item.publishedAt) : Number.NaN;
|
||||||
|
const minutes = Number.isNaN(published) ? null : Math.max(1, Math.round((Date.now() - published) / 60_000));
|
||||||
|
const age = minutes === null ? "new" : minutes < 60 ? `${minutes}m` : minutes < 1_440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1_440)}d`;
|
||||||
|
return { ...item, age, tone: tones[index % tones.length] };
|
||||||
|
}));
|
||||||
|
setNewsLive(true);
|
||||||
|
} catch {
|
||||||
|
// The demo headlines remain visible whenever live RSS is unavailable.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshHeadlines();
|
||||||
|
const timer = window.setInterval(refreshHeadlines, 10 * 60_000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clock = useMemo(() => formatClock(now), [now]);
|
||||||
|
const calendarNow = useMemo(() => now ?? new Date(), [now]);
|
||||||
|
const todayKey = now ? organizerDateKey(now) : "";
|
||||||
|
const activeAnchor = calendarAnchor || todayKey || "2000-01-01";
|
||||||
|
const upcomingEvents = useMemo(
|
||||||
|
() => calendarEvents.filter((event) => eventIsUpcoming(event, calendarNow)),
|
||||||
|
[calendarEvents, calendarNow],
|
||||||
|
);
|
||||||
|
const nextEvent = upcomingEvents[0];
|
||||||
|
const weekKeys = useMemo(() => weekDateKeys(activeAnchor), [activeAnchor]);
|
||||||
|
const monthKeys = useMemo(() => monthDateKeys(activeAnchor), [activeAnchor]);
|
||||||
|
const visibleDateKeys = useMemo(
|
||||||
|
() => calendarView === "week" ? weekKeys : calendarView === "month" ? monthKeys : [],
|
||||||
|
[calendarView, monthKeys, weekKeys],
|
||||||
|
);
|
||||||
|
const eventsByDate = useMemo(() => {
|
||||||
|
const result = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const dateKey of visibleDateKeys) {
|
||||||
|
result.set(dateKey, calendarEvents.filter((event) => eventOccursOnDate(event, dateKey)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [calendarEvents, visibleDateKeys]);
|
||||||
|
const currentWeather = weatherDays[0];
|
||||||
|
const weatherStatus = weatherState === "loading"
|
||||||
|
? "Updating"
|
||||||
|
: weatherState === "live"
|
||||||
|
? "Live forecast"
|
||||||
|
: weatherState === "stale"
|
||||||
|
? "Last forecast"
|
||||||
|
: "Retrying";
|
||||||
|
const calendarStatus = calendarState === "loading"
|
||||||
|
? "Syncing"
|
||||||
|
: calendarState === "live"
|
||||||
|
? `${calendarFeeds.total} calendars live`
|
||||||
|
: calendarState === "partial"
|
||||||
|
? `${calendarFeeds.available}/${calendarFeeds.total} calendars`
|
||||||
|
: calendarState === "stale"
|
||||||
|
? "Last update"
|
||||||
|
: "Retrying";
|
||||||
|
const marketStatus = marketState === "loading"
|
||||||
|
? "FRED connecting"
|
||||||
|
: marketState === "live"
|
||||||
|
? `FRED live · ${marketFeeds.available}/${marketFeeds.total}`
|
||||||
|
: marketState === "partial"
|
||||||
|
? `FRED partial · ${marketFeeds.available}/${marketFeeds.total}`
|
||||||
|
: marketState === "stale"
|
||||||
|
? "FRED last update"
|
||||||
|
: "Demo data · FRED unavailable";
|
||||||
|
|
||||||
|
const periodLabel = calendarView === "week"
|
||||||
|
? `${formatDateKey(weekKeys[0], { month: "short", day: "numeric" })} – ${formatDateKey(weekKeys[6], { month: "short", day: "numeric" })}`
|
||||||
|
: formatDateKey(`${activeAnchor.slice(0, 7)}-01`, { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
const moveCalendarPeriod = (direction: -1 | 1) => {
|
||||||
|
setCalendarAnchor((current) => {
|
||||||
|
const base = current || todayKey || activeAnchor;
|
||||||
|
return calendarView === "week" ? addDateKey(base, direction * 7) : addMonthsToDateKey(base, direction);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCalendarTabKey = (event: ReactKeyboardEvent<HTMLButtonElement>, view: CalendarView) => {
|
||||||
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const currentIndex = calendarViews.indexOf(view);
|
||||||
|
const offset = event.key === "ArrowRight" ? 1 : -1;
|
||||||
|
const nextView = calendarViews[(currentIndex + offset + calendarViews.length) % calendarViews.length];
|
||||||
|
setCalendarView(nextView);
|
||||||
|
window.requestAnimationFrame(() => document.getElementById(`calendar-tab-${nextView}`)?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHeadlineQr = (headline: Headline) => {
|
||||||
|
if (!headline.url) return;
|
||||||
|
const current = window.history.state;
|
||||||
|
const base = current && typeof current === "object" ? current : {};
|
||||||
|
window.history.pushState({ ...base, daylineQr: true }, "");
|
||||||
|
setSelectedHeadline(headline);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeHeadlineQr = () => {
|
||||||
|
const state = window.history.state as { daylineQr?: boolean } | null;
|
||||||
|
if (state?.daylineQr) window.history.back();
|
||||||
|
else setSelectedHeadline(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="dashboard-shell">
|
||||||
|
<div className="ambient ambient-one" />
|
||||||
|
<div className="ambient ambient-two" />
|
||||||
|
|
||||||
|
<header className="dashboard-header relative z-10 flex min-h-[92px] items-center justify-between border-b border-white/8 px-8 py-4 2xl:px-12">
|
||||||
|
<div className="brand-group flex items-center gap-5">
|
||||||
|
<div className="brand-mark" aria-hidden="true"><span /><span /><span /></div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h1 className="whitespace-nowrap text-[1.45rem] font-black tracking-[-0.03em] text-white">HOBBS' FAMILY ORGANIZER</h1>
|
||||||
|
<span className="rounded-full border border-cyan-300/25 bg-cyan-300/8 px-2.5 py-1 text-[0.62rem] font-black tracking-[0.18em] text-cyan-200">APP</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[0.72rem] font-semibold tracking-[0.14em] text-slate-500">YOUR FAMILY, AT A GLANCE</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-baseline justify-end gap-2">
|
||||||
|
<span className="text-[2.65rem] font-extralight leading-none tracking-[-0.055em] text-white tabular-nums">{clock.time}</span>
|
||||||
|
<span className="text-sm font-black tracking-[0.14em] text-cyan-200">{clock.period}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-slate-400">{clock.date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="weather-strip relative z-10" aria-labelledby="weather-title">
|
||||||
|
<div className="weather-summary">
|
||||||
|
<div className="weather-summary-top">
|
||||||
|
<div className="weather-location"><MapPin className="h-3.5 w-3.5" /><span id="weather-title">{weatherLocation}</span></div>
|
||||||
|
<span className={`weather-status weather-${weatherState}`}>{weatherStatus}</span>
|
||||||
|
</div>
|
||||||
|
{currentWeather ? (
|
||||||
|
<div className="weather-now">
|
||||||
|
<WeatherGlyph icon={currentWeather.icon} className="weather-now-icon" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="weather-now-temp"><span>{Math.round(currentWeather.high)}°</span><small>{Math.round(currentWeather.low)}°</small></div>
|
||||||
|
<p>{currentWeather.condition}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="weather-now weather-pending"><CloudSun className="weather-now-icon" /><div><div className="weather-now-temp"><span>--°</span></div><p>{weatherState === "error" ? "Forecast unavailable" : "Loading forecast…"}</p></div></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="forecast-grid" aria-label="Seven day weather forecast">
|
||||||
|
{weatherDays.length > 0 ? weatherDays.map((day) => (
|
||||||
|
<article className={`forecast-day ${day.date === todayKey ? "forecast-today" : ""}`} key={day.date}>
|
||||||
|
<div className="forecast-day-heading"><span>{day.date === todayKey ? "TODAY" : formatDateKey(day.date, { weekday: "short" }).toUpperCase()}</span><small>{formatDateKey(day.date, { month: "numeric", day: "numeric" })}</small></div>
|
||||||
|
<div className="forecast-condition"><WeatherGlyph icon={day.icon} className="forecast-icon" /><span><strong>{Math.round(day.high)}°</strong><small>{Math.round(day.low)}°</small></span></div>
|
||||||
|
<p className="forecast-rain">RAIN {Math.round(day.precipitationChance)}%</p>
|
||||||
|
<div className="sun-times" aria-label={`Sunrise ${formatSunTime(day.sunrise)}, sunset ${formatSunTime(day.sunset)}`}>
|
||||||
|
<span><Sunrise />{formatSunTime(day.sunrise)}</span><span><Sunset />{formatSunTime(day.sunset)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)) : Array.from({ length: 7 }, (_, index) => <div className="forecast-day forecast-placeholder" key={index}><span /><span /><span /></div>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dashboard-grid relative z-10 grid min-h-0 flex-1 grid-cols-[minmax(0,1.48fr)_minmax(420px,0.92fr)] gap-5 px-6 py-5 2xl:gap-7 2xl:px-10">
|
||||||
|
<section className="panel flex min-h-0 flex-col" aria-labelledby="calendar-title">
|
||||||
|
<div className="panel-heading calendar-heading">
|
||||||
|
<div className="flex items-center gap-3"><CalendarDays className="h-6 w-6 text-cyan-200" /><h2 id="calendar-title">Calendar</h2></div>
|
||||||
|
<div className="calendar-heading-actions">
|
||||||
|
<div className="calendar-view-tabs" role="tablist" aria-label="Calendar view">
|
||||||
|
{calendarViews.map((view) => (
|
||||||
|
<button
|
||||||
|
aria-controls={`calendar-panel-${view}`}
|
||||||
|
aria-selected={calendarView === view}
|
||||||
|
className={calendarView === view ? "is-active" : ""}
|
||||||
|
id={`calendar-tab-${view}`}
|
||||||
|
key={view}
|
||||||
|
onClick={() => setCalendarView(view)}
|
||||||
|
onKeyDown={(event) => handleCalendarTabKey(event, view)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{view}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`source-pill source-${calendarState}`}><Wifi className="h-3.5 w-3.5" />{calendarStatus}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calendarView === "agenda" && (
|
||||||
|
<div className="calendar-view-content" id="calendar-panel-agenda" role="tabpanel" aria-labelledby="calendar-tab-agenda">
|
||||||
|
<div className="next-event-card">
|
||||||
|
{nextEvent ? (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="eyebrow text-cyan-200">{eventIsHappening(nextEvent, calendarNow) ? "HAPPENING NOW" : "NEXT EVENT"}</p>
|
||||||
|
<h3 className="next-event-title mt-2 truncate text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{nextEvent.title}</h3>
|
||||||
|
<p className="next-event-meta mt-1 truncate text-base text-slate-400">{formatEventWhen(nextEvent, calendarNow)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="next-event-countdown shrink-0 pl-6 text-right"><p className="text-[2.2rem] font-extralight tracking-[-0.05em] text-cyan-100">{formatCountdown(nextEvent, calendarNow)}</p><p className="mt-1 text-xs font-bold tracking-[0.12em] text-slate-500">{eventIsHappening(nextEvent, calendarNow) ? "IN PROGRESS" : "UNTIL START"}</p></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow text-cyan-200">{calendarState === "loading" ? "SYNCING" : calendarState === "error" ? "RETRYING" : "CALENDAR CLEAR"}</p>
|
||||||
|
<h3 className="next-event-title mt-2 text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{calendarState === "loading" ? "Loading calendars…" : calendarState === "error" ? "Calendar temporarily unavailable" : "No upcoming events"}</h3>
|
||||||
|
<p className="next-event-meta mt-1 text-base text-slate-400">{calendarState === "error" ? "The organizer will retry automatically" : "Looking ahead 60 days"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="event-list mt-4 min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div className="event-list-heading mb-3 flex items-center justify-between px-1"><p className="eyebrow">UP NEXT</p><p className="text-xs font-semibold text-slate-500">60-day view</p></div>
|
||||||
|
<div className="event-list-grid grid gap-2">
|
||||||
|
{upcomingEvents.slice(1, 9).map((event) => {
|
||||||
|
const row = formatEventRow(event, calendarNow);
|
||||||
|
return (
|
||||||
|
<article className="calendar-row" key={event.id} tabIndex={0} aria-label={`${event.title}, ${formatEventWhen(event, calendarNow)}`}>
|
||||||
|
<div className="calendar-date w-[76px] shrink-0 border-r border-white/8 pr-4 text-center"><p className="calendar-day text-[0.66rem] font-black tracking-[0.16em] text-slate-500">{row.day}</p><p className="calendar-date-number mt-0.5 text-2xl font-light text-slate-200 tabular-nums">{row.date}</p></div>
|
||||||
|
<div className="calendar-accent" style={{ backgroundColor: calendarToneColors[event.tone] }} aria-hidden="true" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-4"><h3 className="calendar-event-title truncate text-[1.03rem] font-bold text-slate-100">{event.title}</h3><span className="calendar-event-time shrink-0 text-sm font-bold text-slate-300 tabular-nums">{row.time}</span></div>
|
||||||
|
<p className="calendar-detail"><span className="calendar-dot" style={{ backgroundColor: calendarToneColors[event.tone] }} /><span>{event.calendar}</span>{event.location && <><span>·</span><span className="truncate">{event.location}</span></>}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!upcomingEvents.length && calendarState !== "loading" && <div className="calendar-empty"><div><CalendarDays className="mx-auto mb-3 h-7 w-7 text-slate-600" /><p>{calendarState === "error" ? "Calendar feed unavailable" : "Nothing scheduled"}</p></div></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{calendarView !== "agenda" && (
|
||||||
|
<div className="calendar-view-content" id={`calendar-panel-${calendarView}`} role="tabpanel" aria-labelledby={`calendar-tab-${calendarView}`}>
|
||||||
|
<div className="calendar-period-bar">
|
||||||
|
<button onClick={() => moveCalendarPeriod(-1)} type="button" aria-label={`Previous ${calendarView}`}><ChevronLeft /></button>
|
||||||
|
<p>{periodLabel}</p>
|
||||||
|
<button className="calendar-today-button" onClick={() => setCalendarAnchor(todayKey || activeAnchor)} type="button">Today</button>
|
||||||
|
<button onClick={() => moveCalendarPeriod(1)} type="button" aria-label={`Next ${calendarView}`}><ChevronRight /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calendarView === "week" && (
|
||||||
|
<div className="week-grid" role="grid" aria-label={periodLabel}>
|
||||||
|
{weekKeys.map((dateKey) => {
|
||||||
|
const dayEvents = eventsByDate.get(dateKey) ?? [];
|
||||||
|
return (
|
||||||
|
<section className={`week-day ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
|
||||||
|
<div className="week-day-heading"><span>{formatDateKey(dateKey, { weekday: "short" }).toUpperCase()}</span><strong>{formatDateKey(dateKey, { day: "numeric" })}</strong></div>
|
||||||
|
<div className="week-events">
|
||||||
|
{dayEvents.slice(0, 3).map((event) => (
|
||||||
|
<article className="week-event" key={`${dateKey}-${event.id}`} tabIndex={0} title={`${event.title} · ${compactEventTime(event, dateKey)}`} style={{ borderLeftColor: calendarToneColors[event.tone] }}>
|
||||||
|
<strong>{event.title}</strong><span>{compactEventTime(event, dateKey)}</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{!dayEvents.length && <span className="calendar-no-events">—</span>}
|
||||||
|
{dayEvents.length > 3 && <span className="calendar-more">+{dayEvents.length - 3} more</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{calendarView === "month" && (
|
||||||
|
<div className="month-grid" role="grid" aria-label={periodLabel}>
|
||||||
|
{weekDateKeys("2024-01-01").map((dateKey) => <div className="month-weekday" key={dateKey} role="columnheader">{formatDateKey(dateKey, { weekday: "short" }).slice(0, 2).toUpperCase()}</div>)}
|
||||||
|
{monthKeys.map((dateKey) => {
|
||||||
|
const dayEvents = eventsByDate.get(dateKey) ?? [];
|
||||||
|
const outsideMonth = dateKey.slice(0, 7) !== activeAnchor.slice(0, 7);
|
||||||
|
return (
|
||||||
|
<section className={`month-cell ${outsideMonth ? "is-outside" : ""} ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
|
||||||
|
<span className="month-date">{formatDateKey(dateKey, { day: "numeric" })}</span>
|
||||||
|
<div className="month-events">
|
||||||
|
{dayEvents.slice(0, 2).map((event) => (
|
||||||
|
<div className="month-event" key={`${dateKey}-${event.id}`} title={event.title}><span style={{ backgroundColor: calendarToneColors[event.tone] }} />{event.title}</div>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 2 && <span className="calendar-more">+{dayEvents.length - 2}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="panel flex min-h-0 flex-col" aria-labelledby="headlines-title">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div className="flex items-center gap-3"><Newspaper className="h-6 w-6 text-violet-200" /><h2 id="headlines-title">AI + Tech</h2></div>
|
||||||
|
<div className="source-pill"><Wifi className="h-3.5 w-3.5" />{newsLive ? "RSS live" : "RSS demo"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 divide-y divide-white/7 overflow-hidden">
|
||||||
|
{headlines.map((headline, index) => (
|
||||||
|
<button
|
||||||
|
className="headline-row w-full text-left"
|
||||||
|
disabled={!headline.url}
|
||||||
|
key={headline.title}
|
||||||
|
onClick={() => showHeadlineQr(headline)}
|
||||||
|
type="button"
|
||||||
|
aria-label={headline.url ? `${headline.title}. Show QR code for this article.` : headline.title}
|
||||||
|
>
|
||||||
|
<div className={`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${toneClasses[headline.tone]}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[0.65rem] font-black tracking-[0.12em]">
|
||||||
|
<span className="flex items-center gap-2"><span className="text-slate-400">{headline.source}</span><span className="text-slate-700">•</span><span className="text-slate-600">{headline.age}</span></span>
|
||||||
|
{headline.url && <span className="headline-qr-cue"><QrCode className="h-3 w-3" />SCAN</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className={`mt-1.5 font-semibold leading-[1.28] text-slate-100 ${index === 0 ? "text-[1.12rem]" : "text-[1rem]"}`}>{headline.title}</h3>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="phone-handoff mt-3 flex items-center justify-between rounded-2xl border border-violet-300/12 bg-violet-300/[0.045] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3"><Sparkles className="h-5 w-5 text-violet-200" /><div><p className="text-sm font-bold text-slate-200">Phone handoff</p><p className="text-xs text-slate-500">Select a live headline, then scan</p></div></div>
|
||||||
|
<span className="text-xs font-black tracking-[0.12em] text-violet-200">READY</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="dashboard-footer relative z-10 border-t border-white/8 bg-[#050911]/92 py-3 backdrop-blur-xl">
|
||||||
|
<div className="footer-meta mb-2 flex items-center justify-between px-8 2xl:px-12">
|
||||||
|
<div className="flex items-center gap-2"><span className={`pulse-dot market-pulse-${marketState}`} /><span className="text-[0.66rem] font-black tracking-[0.16em] text-slate-400">MARKETS + COMMODITIES</span><span className="rounded-full border border-white/8 px-2 py-0.5 text-[0.6rem] font-black tracking-[0.12em] text-slate-600">LATEST FRED OBSERVATION</span></div>
|
||||||
|
<span className="text-[0.68rem] font-semibold text-slate-600">{marketStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ticker-window" aria-label="Market ticker">
|
||||||
|
<div className="ticker-track">
|
||||||
|
<div className="flex shrink-0">{quotes.map((quote) => <QuoteCell key={`a-${quote.symbol}`} quote={quote} />)}</div>
|
||||||
|
<div className="flex shrink-0" aria-hidden="true">{quotes.map((quote) => <QuoteCell key={`b-${quote.symbol}`} quote={quote} />)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{selectedHeadline?.url && (
|
||||||
|
<div className="qr-overlay" role="presentation" onClick={closeHeadlineQr}>
|
||||||
|
<section
|
||||||
|
className="qr-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="qr-headline-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button className="qr-close" onClick={closeHeadlineQr} ref={qrCloseRef} type="button">
|
||||||
|
<X className="h-4 w-4" />Close
|
||||||
|
</button>
|
||||||
|
<div className="qr-code-frame" aria-label={`QR code for ${selectedHeadline.title}`}>
|
||||||
|
<QRCode
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#071018"
|
||||||
|
level="M"
|
||||||
|
size={264}
|
||||||
|
value={selectedHeadline.url}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="qr-copy">
|
||||||
|
<div className="qr-source"><QrCode className="h-4 w-4" />{selectedHeadline.source}<span>•</span>{headlineHost(selectedHeadline.url)}</div>
|
||||||
|
<h2 id="qr-headline-title">{selectedHeadline.title}</h2>
|
||||||
|
<p>Open your phone camera and point it at the code. Press Back on the Shield remote when you are finished.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gallery for the Shield photo app's launcher tile: a grid of tiles driven by the remote's
|
||||||
|
* D-pad (arrow keys), OK (Enter) opens a photo full screen, Back returns to the grid.
|
||||||
|
* Android's Back key never reaches the page; the app maps it to WebView history, so opening
|
||||||
|
* a photo pushes a history entry and closing it pops that entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GalleryPhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
|
||||||
|
type Payload = { photos: GalleryPhoto[]; version: string };
|
||||||
|
|
||||||
|
const REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
/** With no key press for this long, start the slideshow (the app keeps the screen on). */
|
||||||
|
const IDLE_SLIDESHOW_MS = 10 * 60 * 1000;
|
||||||
|
const TILE_TARGET_PX = 300;
|
||||||
|
const SLIDESHOW = -1;
|
||||||
|
const TIME_ZONE = "America/Chicago";
|
||||||
|
|
||||||
|
function formatTaken(takenAt: number | null) {
|
||||||
|
if (!takenAt) return "";
|
||||||
|
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first; photos without a date keep their album order at the end. */
|
||||||
|
function sortPhotos(photos: GalleryPhoto[]) {
|
||||||
|
return photos
|
||||||
|
.map((photo, position) => ({ photo, position }))
|
||||||
|
.sort((left, right) => (right.photo.takenAt ?? -1) - (left.photo.takenAt ?? -1) || left.position - right.position)
|
||||||
|
.map((entry) => entry.photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useColumns() {
|
||||||
|
const [columns, setColumns] = useState(6);
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setColumns(Math.max(2, Math.min(8, Math.round(window.innerWidth / TILE_TARGET_PX))));
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
return () => window.removeEventListener("resize", update);
|
||||||
|
}, []);
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoGallery() {
|
||||||
|
const [photos, setPhotos] = useState<GalleryPhoto[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [focus, setFocus] = useState(0);
|
||||||
|
const [viewer, setViewer] = useState<number | null>(null);
|
||||||
|
const columns = useColumns();
|
||||||
|
const versionRef = useRef("");
|
||||||
|
const focusSrc = useRef("");
|
||||||
|
const tileRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const slideshowRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewerOpenedHere = useRef(false);
|
||||||
|
|
||||||
|
// Load (and occasionally refresh) the list, keeping focus on the same photo.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/photos", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
|
const next = (await response.json()) as Payload;
|
||||||
|
if (cancelled) return;
|
||||||
|
setFailed(false);
|
||||||
|
if (next.version === versionRef.current) return;
|
||||||
|
versionRef.current = next.version;
|
||||||
|
const sorted = sortPhotos(next.photos);
|
||||||
|
setPhotos(sorted);
|
||||||
|
const kept = sorted.findIndex((photo) => photo.src === focusSrc.current);
|
||||||
|
setFocus((current) => (current === SLIDESHOW ? SLIDESHOW : kept >= 0 ? kept : Math.min(current, Math.max(0, sorted.length - 1))));
|
||||||
|
setViewer((current) => (current === null ? null : kept >= 0 ? kept : null));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(load, REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const count = photos?.length ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusSrc.current = photos && focus >= 0 ? (photos[focus]?.src ?? "") : "";
|
||||||
|
}, [photos, focus]);
|
||||||
|
|
||||||
|
// Keep the focused tile on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewer !== null) return;
|
||||||
|
// On the top row (or the Slideshow button) show the whole header, not just the focused item.
|
||||||
|
if (focus < columns) {
|
||||||
|
scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = focus === SLIDESHOW ? slideshowRef.current : tileRefs.current[focus];
|
||||||
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}, [focus, viewer, columns]);
|
||||||
|
|
||||||
|
const openViewer = useCallback((index: number) => {
|
||||||
|
window.history.pushState({ hfoViewer: true }, "");
|
||||||
|
viewerOpenedHere.current = true;
|
||||||
|
setViewer(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeViewer = useCallback(() => {
|
||||||
|
if (viewerOpenedHere.current) window.history.back();
|
||||||
|
else setViewer(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Back (the app turns the remote's Back into history.back()) closes the viewer.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => {
|
||||||
|
viewerOpenedHere.current = false;
|
||||||
|
setViewer((current) => {
|
||||||
|
if (current !== null) setFocus(current);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startSlideshow = useCallback(() => {
|
||||||
|
// A full page load on purpose: the slideshow is a separate page with its own 12h self-reload,
|
||||||
|
// and the Shield app's Back key then returns here through WebView history.
|
||||||
|
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
|
||||||
|
window.location.assign("/photo");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Idle -> slideshow.
|
||||||
|
const idleTimer = useRef<number | undefined>(undefined);
|
||||||
|
const resetIdle = useCallback(() => {
|
||||||
|
window.clearTimeout(idleTimer.current);
|
||||||
|
idleTimer.current = window.setTimeout(startSlideshow, IDLE_SLIDESHOW_MS);
|
||||||
|
}, [startSlideshow]);
|
||||||
|
useEffect(() => {
|
||||||
|
resetIdle();
|
||||||
|
return () => window.clearTimeout(idleTimer.current);
|
||||||
|
}, [resetIdle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
resetIdle();
|
||||||
|
const key = event.key;
|
||||||
|
const handled = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", " ", "Escape", "Backspace"].includes(key);
|
||||||
|
if (!handled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!count && key !== "Enter") return;
|
||||||
|
|
||||||
|
if (viewer !== null) {
|
||||||
|
if (key === "ArrowRight") setViewer((viewer + 1) % count);
|
||||||
|
else if (key === "ArrowLeft") setViewer((viewer - 1 + count) % count);
|
||||||
|
else if (key === "Enter" || key === " " || key === "Escape" || key === "Backspace") closeViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter" || key === " ") {
|
||||||
|
if (focus === SLIDESHOW || !count) startSlideshow();
|
||||||
|
else openViewer(focus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFocus((current) => {
|
||||||
|
if (current === SLIDESHOW) return key === "ArrowDown" || key === "ArrowRight" ? 0 : SLIDESHOW;
|
||||||
|
if (key === "ArrowRight") return Math.min(count - 1, current + 1);
|
||||||
|
if (key === "ArrowLeft") return Math.max(0, current - 1);
|
||||||
|
if (key === "ArrowDown") return Math.min(count - 1, current + columns);
|
||||||
|
if (key === "ArrowUp") return current - columns < 0 ? SLIDESHOW : current - columns;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [closeViewer, columns, count, focus, openViewer, resetIdle, startSlideshow, viewer]);
|
||||||
|
|
||||||
|
const focused = photos && focus >= 0 ? photos[focus] : undefined;
|
||||||
|
const shown = photos && viewer !== null ? photos[viewer] : undefined;
|
||||||
|
// Neighbours to prefetch while the viewer is open, so ◀ ▶ feel instant.
|
||||||
|
const neighbours = useMemo(() => {
|
||||||
|
if (!photos || viewer === null || count < 2) return [];
|
||||||
|
return [photos[(viewer + 1) % count], photos[(viewer - 1 + count) % count]];
|
||||||
|
}, [photos, viewer, count]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 overflow-hidden bg-neutral-950 text-white" style={{ cursor: viewer === null ? "default" : "none" }}>
|
||||||
|
<div ref={scrollerRef} className="h-full overflow-y-auto px-14 pb-16 pt-10" style={{ scrollPaddingTop: 40, scrollPaddingBottom: 56 }}>
|
||||||
|
<header className="mb-8 flex items-center gap-6">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">Family Photos</h1>
|
||||||
|
<p className="mt-1 text-lg text-white/55">
|
||||||
|
{photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`}
|
||||||
|
{focused?.takenAt ? <span className="text-white/80"> · {formatTaken(focused.takenAt)}</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={slideshowRef}
|
||||||
|
onClick={startSlideshow}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-full px-7 py-3 text-xl font-semibold transition-all duration-150 ${
|
||||||
|
focus === SLIDESHOW ? "scale-105 bg-white text-neutral-950 shadow-[0_0_0_4px_rgb(255_255_255/0.35)]" : "bg-white/10 text-white/85"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden>▶</span> Slideshow
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{photos && !count && (
|
||||||
|
<div className="mt-24 text-center text-2xl text-white/60">No photos yet. Add a Google Photos album in the admin screen.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
||||||
|
{photos?.map((photo, index) => {
|
||||||
|
const active = index === focus && viewer === null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={photo.src}
|
||||||
|
ref={(element) => {
|
||||||
|
tileRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setFocus(index);
|
||||||
|
openViewer(index);
|
||||||
|
}}
|
||||||
|
className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-white/5 transition-all duration-150 ${
|
||||||
|
active ? "z-10 scale-[1.08] shadow-[0_0_0_4px_#fff,0_18px_40px_rgb(0_0_0/0.6)]" : "opacity-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={photo.tile} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 0 && (
|
||||||
|
<p className="mt-10 text-center text-base text-white/35">Arrows to move · OK to open · Back to return</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown && (
|
||||||
|
<div key={shown.src} className="absolute inset-0 z-20 bg-black" style={{ animation: "gallery-zoom-in 260ms ease-out both" }}>
|
||||||
|
<img
|
||||||
|
src={shown.thumb}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
|
||||||
|
/>
|
||||||
|
{/* The tile is already cached, so it shows at once while the full photo loads on top. */}
|
||||||
|
<img src={shown.tile} alt="" aria-hidden className="absolute inset-0 h-full w-full object-contain" />
|
||||||
|
<img src={shown.src} alt="" className="absolute inset-0 h-full w-full object-contain" />
|
||||||
|
<div className="absolute bottom-6 left-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold text-white/85">
|
||||||
|
◀ {(viewer ?? 0) + 1} / {count} ▶
|
||||||
|
</div>
|
||||||
|
{shown.takenAt ? (
|
||||||
|
<div className="absolute bottom-6 right-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85">
|
||||||
|
{formatTaken(shown.takenAt)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{neighbours.map((photo) => (
|
||||||
|
<link key={photo.src} rel="prefetch" as="image" href={photo.src} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<style>{`@keyframes gallery-zoom-in { from { opacity: 0; transform: scale(0.82) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Hobbs Family Photos",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PhotoLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
|
||||||
|
type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean };
|
||||||
|
type Slideshow = { display: Display; photos: SlidePhoto[]; version: string };
|
||||||
|
type Layer = { photo: SlidePhoto; id: number };
|
||||||
|
|
||||||
|
const POLL_MS = 30_000;
|
||||||
|
const FADE_MS = 1_400;
|
||||||
|
const FULL_RELOAD_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const TIME_ZONE = "America/Chicago";
|
||||||
|
|
||||||
|
function shuffled<T>(values: T[]) {
|
||||||
|
const copy = [...values];
|
||||||
|
for (let index = copy.length - 1; index > 0; index -= 1) {
|
||||||
|
const other = Math.floor(Math.random() * (index + 1));
|
||||||
|
[copy[index], copy[other]] = [copy[other], copy[index]];
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaken(takenAt: number | null) {
|
||||||
|
if (!takenAt) return "";
|
||||||
|
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function preload(src: string) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve();
|
||||||
|
image.onerror = () => resolve();
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoFrame() {
|
||||||
|
const [show, setShow] = useState<Slideshow | null>(null);
|
||||||
|
const [layers, setLayers] = useState<Layer[]>([]);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const queue = useRef<SlidePhoto[]>([]);
|
||||||
|
const versionRef = useRef("");
|
||||||
|
const showRef = useRef<Slideshow | null>(null);
|
||||||
|
const layerId = useRef(0);
|
||||||
|
// The newest layer's src, readable from callbacks without depending on layers state.
|
||||||
|
const topSrc = useRef("");
|
||||||
|
|
||||||
|
const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => {
|
||||||
|
let next = shuffle ? shuffled(photos) : [...photos];
|
||||||
|
if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]];
|
||||||
|
queue.current = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const advance = useCallback(async () => {
|
||||||
|
const current = showRef.current;
|
||||||
|
if (!current || !current.photos.length) return;
|
||||||
|
if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current);
|
||||||
|
const photo = queue.current.shift();
|
||||||
|
if (!photo) return;
|
||||||
|
await preload(photo.src);
|
||||||
|
layerId.current += 1;
|
||||||
|
const id = layerId.current;
|
||||||
|
setLayers((existing) => [...existing.slice(-1), { photo, id }]);
|
||||||
|
}, [refill]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
topSrc.current = layers[layers.length - 1]?.photo.src ?? "";
|
||||||
|
}, [layers]);
|
||||||
|
|
||||||
|
// Poll settings + photo list; rebuild the queue only when something changed.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/photos", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
|
const next = (await response.json()) as Slideshow;
|
||||||
|
if (cancelled) return;
|
||||||
|
setFailed(false);
|
||||||
|
if (next.version === versionRef.current) return;
|
||||||
|
const first = !versionRef.current;
|
||||||
|
versionRef.current = next.version;
|
||||||
|
showRef.current = next;
|
||||||
|
setShow(next);
|
||||||
|
refill(next.photos, next.display.shuffle, topSrc.current);
|
||||||
|
if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance();
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const timer = window.setInterval(poll, POLL_MS);
|
||||||
|
const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.clearTimeout(reload);
|
||||||
|
};
|
||||||
|
}, [advance, refill]);
|
||||||
|
|
||||||
|
const intervalSeconds = show?.display.intervalSeconds ?? 20;
|
||||||
|
const photoCount = show?.photos.length ?? 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (photoCount < 2) return;
|
||||||
|
const timer = window.setInterval(() => void advance(), intervalSeconds * 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [advance, intervalSeconds, photoCount]);
|
||||||
|
|
||||||
|
const fit = show?.display.fit ?? "contain-blur";
|
||||||
|
const showDate = show?.display.showDate ?? true;
|
||||||
|
const top = layers[layers.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 overflow-hidden bg-black" style={{ cursor: "none" }}>
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both` }}
|
||||||
|
>
|
||||||
|
{fit === "contain-blur" && (
|
||||||
|
<img
|
||||||
|
src={layer.photo.thumb}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={layer.photo.src}
|
||||||
|
alt=""
|
||||||
|
className={`absolute inset-0 h-full w-full ${fit === "cover" ? "object-cover" : "object-contain"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showDate && top?.photo.takenAt ? (
|
||||||
|
<div
|
||||||
|
key={`date-${top.id}`}
|
||||||
|
className="absolute bottom-6 right-8 rounded-full bg-black/35 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85"
|
||||||
|
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both`, textShadow: "0 1px 4px rgb(0 0 0 / 60%)" }}
|
||||||
|
>
|
||||||
|
{formatTaken(top.photo.takenAt)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{show && !show.photos.length && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-center text-2xl text-white/60">
|
||||||
|
No photos yet. Add a Google Photos album in the admin screen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{failed && !layers.length && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-2xl text-white/60">Can't reach the photo server…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: hobbs-family-organizer
|
||||||
|
|
||||||
|
services:
|
||||||
|
organizer:
|
||||||
|
image: hobbs-family-organizer:1.4.0
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
PORT: "3000"
|
||||||
|
TZ: ${ORGANIZER_TIMEZONE:-America/Chicago}
|
||||||
|
ORGANIZER_TIMEZONE: ${ORGANIZER_TIMEZONE:-America/Chicago}
|
||||||
|
WEATHER_LOCATION: ${WEATHER_LOCATION:-Sapulpa, OK}
|
||||||
|
WEATHER_LATITUDE: ${WEATHER_LATITUDE:-35.9987}
|
||||||
|
WEATHER_LONGITUDE: ${WEATHER_LONGITUDE:--96.11417}
|
||||||
|
NWS_FORECAST_URL: ${NWS_FORECAST_URL:-https://api.weather.gov/gridpoints/TSA/37,98/forecast}
|
||||||
|
WEATHER_USER_AGENT: ${WEATHER_USER_AGENT:-Hobbs-Family-Organizer/1.0 (self-hosted)}
|
||||||
|
CALENDAR_ICAL_URLS_FILE: /run/secrets/calendar_ical_urls
|
||||||
|
ORGANIZER_ACCESS_TOKEN_FILE: /run/secrets/organizer_access_token
|
||||||
|
ORGANIZER_DATA_DIR: /data
|
||||||
|
ADMIN_ALLOWED_CIDRS: ${ADMIN_ALLOWED_CIDRS:-192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,127.0.0.1/32}
|
||||||
|
secrets:
|
||||||
|
- calendar_ical_urls
|
||||||
|
- organizer_access_token
|
||||||
|
ports:
|
||||||
|
- "${HOBBS_BIND_ADDRESS:-0.0.0.0}:${HOBBS_HTTP_PORT:-8080}:3000"
|
||||||
|
volumes:
|
||||||
|
# Admin settings, password hash and history. Must be writable by uid 10001,
|
||||||
|
# and must be local disk (not NFS with root_squash).
|
||||||
|
- ${HOBBS_DATA_DIR:-./data}:/data
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m,mode=1777
|
||||||
|
- /app/.next-build/cache:size=128m,uid=10001,gid=10001,mode=0750
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- SETGID
|
||||||
|
- SETUID
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 128
|
||||||
|
stop_grace_period: 20s
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
calendar_ical_urls:
|
||||||
|
file: ./secrets/calendar_ical_urls.json
|
||||||
|
organizer_access_token:
|
||||||
|
file: ./secrets/organizer_access_token.txt
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Clears the admin password so the next visit to /admin (from the LAN) can set a new one.
|
||||||
|
// Also rotates the session secret, signing out every existing admin session.
|
||||||
|
// Usage: docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { rename, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const path = `${process.env.ORGANIZER_DATA_DIR || "/data"}/auth.json`;
|
||||||
|
const temporary = `${path}.reset.tmp`;
|
||||||
|
await writeFile(
|
||||||
|
temporary,
|
||||||
|
`${JSON.stringify({ passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") }, null, 2)}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await rename(temporary, path);
|
||||||
|
console.log("Admin password cleared. Visit /admin from the LAN to set a new one.");
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import http from "node:http";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const APP_UID = 10001;
|
||||||
|
const APP_GID = 10001;
|
||||||
|
const DATA_DIR = process.env.ORGANIZER_DATA_DIR || "/data";
|
||||||
|
const PUBLIC_PORT = Number(process.env.PORT || "3000");
|
||||||
|
const INTERNAL_PORT = Number(process.env.ORGANIZER_INTERNAL_PORT || "3001");
|
||||||
|
const TONES = ["cyan", "amber", "violet", "emerald", "rose"];
|
||||||
|
|
||||||
|
async function readable(path) {
|
||||||
|
try {
|
||||||
|
await access(path, constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile) {
|
||||||
|
const direct = process.env[environmentName];
|
||||||
|
if (direct !== undefined) return direct.trim();
|
||||||
|
const configuredFile = process.env[fileEnvironmentName] || defaultFile;
|
||||||
|
if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCalendarUrl(value) {
|
||||||
|
const url = new URL(String(value));
|
||||||
|
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
|
||||||
|
throw new Error("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL");
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCalendarSecret(raw) {
|
||||||
|
if (!raw) return [];
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Calendar secret must be a JSON array");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(values) || values.length > 10) throw new Error("Calendar secret must contain at most 10 URLs");
|
||||||
|
return [...new Set(values.map(validCalendarUrl))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(path, value) {
|
||||||
|
const temporary = `${path}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
await rename(temporary, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropPrivileges() {
|
||||||
|
if (process.platform === "win32" || typeof process.getuid !== "function" || process.getuid() !== 0) return;
|
||||||
|
process.setgroups([]);
|
||||||
|
process.setgid(APP_GID);
|
||||||
|
process.setuid(APP_UID);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareDataDirectory(seedUrls) {
|
||||||
|
await mkdir(`${DATA_DIR}/history`, { recursive: true, mode: 0o700 });
|
||||||
|
const probe = `${DATA_DIR}/.write-test`;
|
||||||
|
try {
|
||||||
|
await writeFile(probe, "ok", { mode: 0o600 });
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${DATA_DIR} must be writable by uid ${APP_UID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsPath = `${DATA_DIR}/settings.json`;
|
||||||
|
if (!(await readable(settingsPath))) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeJsonAtomic(settingsPath, {
|
||||||
|
schema: 1,
|
||||||
|
updatedAt: now,
|
||||||
|
calendars: seedUrls.map((url, index) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: "",
|
||||||
|
url,
|
||||||
|
tone: TONES[index % TONES.length],
|
||||||
|
enabled: true,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
console.log(`[organizer] created settings.json with ${seedUrls.length} calendar(s) from the startup secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPath = `${DATA_DIR}/auth.json`;
|
||||||
|
if (!(await readable(authPath))) {
|
||||||
|
await writeJsonAtomic(authPath, { passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORWARDING_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip"];
|
||||||
|
const INTERNAL_HEADERS = ["x-hfo-client-ip", "x-hfo-forwarded"];
|
||||||
|
|
||||||
|
// The public listener stamps each request with the real TCP peer address so the
|
||||||
|
// app can enforce LAN-only admin access. Client-supplied copies are discarded.
|
||||||
|
function startFrontProxy() {
|
||||||
|
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 });
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const headers = { ...request.headers };
|
||||||
|
for (const name of INTERNAL_HEADERS) delete headers[name];
|
||||||
|
headers["x-hfo-client-ip"] = String(request.socket.remoteAddress || "").replace(/^::ffff:/, "");
|
||||||
|
if (FORWARDING_HEADERS.some((name) => request.headers[name] !== undefined)) headers["x-hfo-forwarded"] = "1";
|
||||||
|
|
||||||
|
const upstream = http.request(
|
||||||
|
{ host: "127.0.0.1", port: INTERNAL_PORT, method: request.method, path: request.url, headers, agent },
|
||||||
|
(upstreamResponse) => {
|
||||||
|
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
||||||
|
upstreamResponse.pipe(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
upstream.on("error", () => {
|
||||||
|
if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" });
|
||||||
|
response.end("Upstream unavailable");
|
||||||
|
});
|
||||||
|
request.pipe(upstream);
|
||||||
|
});
|
||||||
|
server.headersTimeout = 20_000;
|
||||||
|
server.requestTimeout = 60_000;
|
||||||
|
server.listen(PUBLIC_PORT, process.env.HOSTNAME || "0.0.0.0");
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const calendarSecret = await settingFromFile(
|
||||||
|
"CALENDAR_ICAL_URLS",
|
||||||
|
"CALENDAR_ICAL_URLS_FILE",
|
||||||
|
"/run/secrets/calendar_ical_urls",
|
||||||
|
);
|
||||||
|
const accessToken = await settingFromFile(
|
||||||
|
"ORGANIZER_ACCESS_TOKEN",
|
||||||
|
"ORGANIZER_ACCESS_TOKEN_FILE",
|
||||||
|
"/run/secrets/organizer_access_token",
|
||||||
|
);
|
||||||
|
if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) {
|
||||||
|
throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters");
|
||||||
|
}
|
||||||
|
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date());
|
||||||
|
} catch {
|
||||||
|
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
|
||||||
|
}
|
||||||
|
const seedUrls = parseCalendarSecret(calendarSecret);
|
||||||
|
|
||||||
|
// Secrets are read; everything from here on runs unprivileged.
|
||||||
|
dropPrivileges();
|
||||||
|
await prepareDataDirectory(seedUrls);
|
||||||
|
|
||||||
|
const environment = { ...process.env };
|
||||||
|
delete environment.CALENDAR_ICAL_URLS;
|
||||||
|
Object.assign(environment, {
|
||||||
|
ORGANIZER_ACCESS_TOKEN: accessToken,
|
||||||
|
ORGANIZER_TIMEZONE: organizerTimeZone,
|
||||||
|
ORGANIZER_DATA_DIR: DATA_DIR,
|
||||||
|
HOSTNAME: "127.0.0.1",
|
||||||
|
PORT: String(INTERNAL_PORT),
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
["node_modules/next/dist/bin/next", "start", "-H", "127.0.0.1", "-p", String(INTERNAL_PORT)],
|
||||||
|
{ cwd: process.cwd(), env: environment, stdio: "inherit" },
|
||||||
|
);
|
||||||
|
const server = startFrontProxy();
|
||||||
|
const forwardSignal = (signal) => {
|
||||||
|
server.close();
|
||||||
|
if (!child.killed) child.kill(signal);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (signal) process.kill(process.pid, signal);
|
||||||
|
else process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([
|
||||||
|
"dist/**",
|
||||||
|
".vinext/**",
|
||||||
|
".wrangler/**",
|
||||||
|
".sites-runtime/**",
|
||||||
|
".openai/**",
|
||||||
|
"db/**",
|
||||||
|
"scripts/**",
|
||||||
|
"worker/**",
|
||||||
|
"cloudflare-env.d.ts",
|
||||||
|
"components/**",
|
||||||
|
"hooks/**",
|
||||||
|
"lib/**",
|
||||||
|
"vite.config.ts",
|
||||||
|
"outputs/**",
|
||||||
|
"work/**",
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
files: ["components/ui/**/*.{ts,tsx}", "hooks/use-mobile.ts"],
|
||||||
|
rules: {
|
||||||
|
// These files are vendored verbatim from shadcn@4.17.0. Keep the
|
||||||
|
// registry source intact while applying the stricter rules to Site code.
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Trusted-LAN example. Caddy obtains an internal certificate and proxies to the
|
||||||
|
# host-published organizer port. Install Caddy's root CA on each Shield.
|
||||||
|
organizer.home.arpa {
|
||||||
|
tls internal
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Place this server block inside nginx's http context. Replace the certificate
|
||||||
|
# paths with your internal or publicly trusted certificate.
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name organizer.home.arpa;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/tls/organizer.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/organizer.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
const { startPhotoScheduler } = await import("./app/_lib/photos");
|
||||||
|
startPhotoScheduler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
distDir: ".next-build",
|
||||||
|
poweredByHeader: false,
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Content-Security-Policy", value: "default-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=()" },
|
||||||
|
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "hobbs-family-organizer",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint app next.config.mjs proxy.ts --ignore-pattern .next --ignore-pattern .next-build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
|
"next": "16.3.4",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"react-qr-code": "2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "4.2.1",
|
||||||
|
"@types/node": "22.19.19",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"eslint": "9.39.4",
|
||||||
|
"eslint-config-next": "16.3.4",
|
||||||
|
"tailwindcss": "4.2.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isAdminNetworkAllowed } from "./app/_lib/auth";
|
||||||
|
|
||||||
|
function tokenMatches(actual: string, expected: string) {
|
||||||
|
const actualValue = createHash("sha256").update(actual).digest();
|
||||||
|
const expectedValue = createHash("sha256").update(expected).digest();
|
||||||
|
return timingSafeEqual(actualValue, expectedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
if (pathname === "/api/health") return NextResponse.next();
|
||||||
|
|
||||||
|
// Admin has its own password + session and is reachable only from a direct LAN connection.
|
||||||
|
if (pathname === "/admin" || pathname.startsWith("/admin/") || pathname.startsWith("/api/admin/") || pathname === "/photo-admin") {
|
||||||
|
if (!isAdminNetworkAllowed(request.headers)) {
|
||||||
|
return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
if (pathname === "/photo-admin") {
|
||||||
|
const target = new URL("/admin", request.url);
|
||||||
|
target.host = request.headers.get("host") || target.host;
|
||||||
|
target.hash = "photos";
|
||||||
|
return NextResponse.redirect(target, 307);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredToken = process.env.ORGANIZER_ACCESS_TOKEN?.trim();
|
||||||
|
if (!configuredToken) return NextResponse.next();
|
||||||
|
|
||||||
|
const authorization = request.headers.get("authorization") || "";
|
||||||
|
const suppliedToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
||||||
|
if (tokenMatches(suppliedToken, configuredToken)) return NextResponse.next();
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"WWW-Authenticate": "Bearer realm=\"Hobbs Family Organizer\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api/health).*)"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||||
|
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||||
|
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||||
|
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 718 B |
@@ -0,0 +1,76 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$DestinationDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $DestinationDirectory) {
|
||||||
|
$DestinationDirectory = Join-Path $ProjectRoot "backups"
|
||||||
|
}
|
||||||
|
$DestinationDirectory = [IO.Path]::GetFullPath($DestinationDirectory)
|
||||||
|
|
||||||
|
$Sources = @{
|
||||||
|
Environment = Join-Path $ProjectRoot ".env"
|
||||||
|
Calendars = Join-Path $ProjectRoot "secrets/calendar_ical_urls.json"
|
||||||
|
Token = Join-Path $ProjectRoot "secrets/organizer_access_token.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($source in $Sources.GetEnumerator()) {
|
||||||
|
if (-not (Test-Path -LiteralPath $source.Value -PathType Leaf)) {
|
||||||
|
throw "Required configuration is missing. Run scripts/configure.ps1 before creating a backup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$suffix = [Guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
|
$archivePath = Join-Path $DestinationDirectory "hobbs-organizer-config-$timestamp-$suffix.zip"
|
||||||
|
$stagingPath = Join-Path $DestinationDirectory (".backup-staging-" + [Guid]::NewGuid().ToString("N"))
|
||||||
|
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
|
||||||
|
$destinationPrefix = $DestinationDirectory.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
||||||
|
|
||||||
|
if (-not $stagingFullPath.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Refusing to create a staging directory outside the selected backup directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $stagingPath "config") -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $stagingPath "secrets") -Force | Out-Null
|
||||||
|
Copy-Item -LiteralPath $Sources.Environment -Destination (Join-Path $stagingPath "config/environment.env")
|
||||||
|
Copy-Item -LiteralPath $Sources.Calendars -Destination (Join-Path $stagingPath "secrets/calendar_ical_urls.json")
|
||||||
|
Copy-Item -LiteralPath $Sources.Token -Destination (Join-Path $stagingPath "secrets/organizer_access_token.txt")
|
||||||
|
|
||||||
|
$manifest = @(
|
||||||
|
"format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1",
|
||||||
|
"created_utc=$((Get-Date).ToUniversalTime().ToString('o'))",
|
||||||
|
"contains_secrets=true"
|
||||||
|
) -join [Environment]::NewLine
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
(Join-Path $stagingPath "manifest.txt"),
|
||||||
|
$manifest + [Environment]::NewLine,
|
||||||
|
(New-Object System.Text.UTF8Encoding($false))
|
||||||
|
)
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $stagingPath "*") -DestinationPath $archivePath -CompressionLevel Optimal
|
||||||
|
|
||||||
|
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
|
||||||
|
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
& icacls.exe $archivePath "/inheritance:r" "/grant:r" "${userName}:(F)" | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& chmod 600 -- $archivePath
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $stagingFullPath) {
|
||||||
|
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Created a protected configuration backup at: $archivePath"
|
||||||
|
Write-Host "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
|
||||||
|
DESTINATION_DIRECTORY="${1:-$PROJECT_ROOT/backups}"
|
||||||
|
|
||||||
|
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
|
||||||
|
CALENDAR_FILE="$PROJECT_ROOT/secrets/calendar_ical_urls.json"
|
||||||
|
TOKEN_FILE="$PROJECT_ROOT/secrets/organizer_access_token.txt"
|
||||||
|
|
||||||
|
for required_file in "$ENVIRONMENT_FILE" "$CALENDAR_FILE" "$TOKEN_FILE"; do
|
||||||
|
if [[ ! -f "$required_file" ]]; then
|
||||||
|
printf '%s\n' "Required configuration is missing. Run scripts/configure.sh before creating a backup." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to create a backup." >&2; exit 1; }
|
||||||
|
mkdir -p -- "$DESTINATION_DIRECTORY"
|
||||||
|
DESTINATION_DIRECTORY="$(cd -- "$DESTINATION_DIRECTORY" && pwd -P)"
|
||||||
|
chmod 700 -- "$DESTINATION_DIRECTORY"
|
||||||
|
|
||||||
|
timestamp="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp.tar.gz"
|
||||||
|
archive_counter=1
|
||||||
|
while [[ -e "$archive_path" ]]; do
|
||||||
|
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp-$archive_counter.tar.gz"
|
||||||
|
((archive_counter += 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
staging_path="$(mktemp -d "$DESTINATION_DIRECTORY/.backup-staging-XXXXXXXX")"
|
||||||
|
cleanup_staging() {
|
||||||
|
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
|
||||||
|
}
|
||||||
|
trap cleanup_staging EXIT HUP INT TERM
|
||||||
|
|
||||||
|
mkdir -p -- "$staging_path/config" "$staging_path/secrets"
|
||||||
|
cp -- "$ENVIRONMENT_FILE" "$staging_path/config/environment.env"
|
||||||
|
cp -- "$CALENDAR_FILE" "$staging_path/secrets/calendar_ical_urls.json"
|
||||||
|
cp -- "$TOKEN_FILE" "$staging_path/secrets/organizer_access_token.txt"
|
||||||
|
chmod 600 -- "$staging_path/config/environment.env" "$staging_path/secrets/calendar_ical_urls.json" "$staging_path/secrets/organizer_access_token.txt"
|
||||||
|
{
|
||||||
|
printf '%s\n' 'format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1'
|
||||||
|
printf 'created_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf '%s\n' 'contains_secrets=true'
|
||||||
|
} >"$staging_path/manifest.txt"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd -- "$staging_path"
|
||||||
|
tar -czf "$archive_path" config secrets manifest.txt
|
||||||
|
)
|
||||||
|
chmod 600 -- "$archive_path"
|
||||||
|
|
||||||
|
rm -rf -- "$staging_path"
|
||||||
|
staging_path=""
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
printf 'Created a protected configuration backup at: %s\n' "$archive_path"
|
||||||
|
printf '%s\n' "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("trusted-lan", "bearer")]
|
||||||
|
[string]$Mode,
|
||||||
|
|
||||||
|
[switch]$RotateToken
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
$SecretsDirectory = Join-Path $ProjectRoot "secrets"
|
||||||
|
$CalendarFile = Join-Path $SecretsDirectory "calendar_ical_urls.json"
|
||||||
|
$TokenFile = Join-Path $SecretsDirectory "organizer_access_token.txt"
|
||||||
|
$EnvironmentFile = Join-Path $ProjectRoot ".env"
|
||||||
|
$EnvironmentExample = Join-Path $ProjectRoot ".env.example"
|
||||||
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
|
||||||
|
function Test-IsWindows {
|
||||||
|
if ($PSVersionTable.PSEdition -eq "Desktop") { return $true }
|
||||||
|
return [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
|
||||||
|
[Runtime.InteropServices.OSPlatform]::Windows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-HiddenText {
|
||||||
|
param([Parameter(Mandatory)][string]$Prompt)
|
||||||
|
|
||||||
|
$secureValue = Read-Host $Prompt -AsSecureString
|
||||||
|
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue)
|
||||||
|
try {
|
||||||
|
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-YesNo {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Prompt,
|
||||||
|
[bool]$Default = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
$answer = (Read-Host $Prompt).Trim().ToLowerInvariant()
|
||||||
|
if (-not $answer) { return $Default }
|
||||||
|
return $answer -in @("y", "yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PrivateGoogleCalendarUrl {
|
||||||
|
param([Parameter(Mandatory)][string]$Value)
|
||||||
|
|
||||||
|
$uri = $null
|
||||||
|
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { return $false }
|
||||||
|
if ($uri.Scheme -ne "https" -or $uri.Host -ne "calendar.google.com") { return $false }
|
||||||
|
if (-not $uri.IsDefaultPort -or $uri.UserInfo -or $uri.Fragment) { return $false }
|
||||||
|
return $uri.AbsolutePath -match "^/calendar/ical/[^/]+/(?:private-[^/]+|public)/basic[.]ics$"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Protect-SecretFile {
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|
||||||
|
if (Test-IsWindows) {
|
||||||
|
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
& icacls.exe $Path "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not restrict permissions on a secret file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& chmod 600 -- $Path
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not restrict permissions on a secret file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-SecretFile {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Path,
|
||||||
|
[Parameter(Mandatory)][AllowEmptyString()][string]$Content
|
||||||
|
)
|
||||||
|
|
||||||
|
$directory = Split-Path -Parent $Path
|
||||||
|
$temporaryPath = Join-Path $directory (".configure-" + [Guid]::NewGuid().ToString("N") + ".tmp")
|
||||||
|
try {
|
||||||
|
[IO.File]::WriteAllText($temporaryPath, $Content, $Utf8NoBom)
|
||||||
|
Protect-SecretFile -Path $temporaryPath
|
||||||
|
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
|
||||||
|
Protect-SecretFile -Path $Path
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $temporaryPath) {
|
||||||
|
Remove-Item -LiteralPath $temporaryPath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-AccessToken {
|
||||||
|
$bytes = New-Object byte[] 48
|
||||||
|
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||||
|
try {
|
||||||
|
$generator.GetBytes($bytes)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$generator.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $EnvironmentExample -PathType Leaf)) {
|
||||||
|
throw "Missing .env.example. Run this script from an intact release bundle."
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $SecretsDirectory -Force | Out-Null
|
||||||
|
if (-not (Test-IsWindows)) {
|
||||||
|
& chmod 700 -- $SecretsDirectory
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not restrict the secrets directory permissions." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $EnvironmentFile)) {
|
||||||
|
Copy-Item -LiteralPath $EnvironmentExample -Destination $EnvironmentFile
|
||||||
|
Write-Host "Created .env from .env.example."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Preserved the existing .env file."
|
||||||
|
}
|
||||||
|
|
||||||
|
$writeCalendars = -not (Test-Path -LiteralPath $CalendarFile -PathType Leaf)
|
||||||
|
if (-not $writeCalendars) {
|
||||||
|
$writeCalendars = Read-YesNo "A calendar secret already exists. Replace it? [y/N]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($writeCalendars) {
|
||||||
|
$calendarUrls = [Collections.Generic.List[string]]::new()
|
||||||
|
Write-Host "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
|
||||||
|
while ($true) {
|
||||||
|
$calendarUrl = Read-HiddenText ("Calendar URL #{0}" -f ($calendarUrls.Count + 1))
|
||||||
|
if ([string]::IsNullOrWhiteSpace($calendarUrl)) { break }
|
||||||
|
if (-not (Test-PrivateGoogleCalendarUrl -Value $calendarUrl.Trim())) {
|
||||||
|
Write-Warning "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$calendarUrls.Add($calendarUrl.Trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($calendarUrls.Count -eq 0) {
|
||||||
|
throw "At least one calendar URL is required when creating the calendar secret."
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendarJson = ConvertTo-Json -InputObject @($calendarUrls.ToArray()) -Compress
|
||||||
|
Write-SecretFile -Path $CalendarFile -Content ($calendarJson + [Environment]::NewLine)
|
||||||
|
Write-Host ("Saved {0} calendar source(s) without displaying their URLs." -f $calendarUrls.Count)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Protect-SecretFile -Path $CalendarFile
|
||||||
|
Write-Host "Preserved the existing calendar secret."
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasExistingToken = (Test-Path -LiteralPath $TokenFile -PathType Leaf) -and ((Get-Item -LiteralPath $TokenFile).Length -gt 0)
|
||||||
|
if (-not $Mode) {
|
||||||
|
$defaultMode = if ($hasExistingToken) { "bearer" } else { "trusted-lan" }
|
||||||
|
$selectedMode = (Read-Host "Access mode [trusted-lan/bearer] (default: $defaultMode)").Trim().ToLowerInvariant()
|
||||||
|
$Mode = if ($selectedMode) { $selectedMode } else { $defaultMode }
|
||||||
|
if ($Mode -notin @("trusted-lan", "bearer")) {
|
||||||
|
throw "Access mode must be trusted-lan or bearer."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq "trusted-lan") {
|
||||||
|
if ($hasExistingToken -and -not (Read-YesNo "This will erase the current access token and disable bearer authentication. Continue? [y/N]")) {
|
||||||
|
throw "Configuration cancelled before changing the access mode."
|
||||||
|
}
|
||||||
|
Write-SecretFile -Path $TokenFile -Content ""
|
||||||
|
Write-Host "Configured trusted-LAN mode with an empty token file."
|
||||||
|
}
|
||||||
|
elseif ($hasExistingToken -and -not $RotateToken) {
|
||||||
|
Protect-SecretFile -Path $TokenFile
|
||||||
|
Write-Host "Preserved the existing bearer token. Use -RotateToken to replace it."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$accessToken = New-AccessToken
|
||||||
|
try {
|
||||||
|
Write-SecretFile -Path $TokenFile -Content ($accessToken + [Environment]::NewLine)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$accessToken = $null
|
||||||
|
}
|
||||||
|
Write-Host "Generated and stored a new bearer token. The token was not displayed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Configuration is ready. Review .env, then run: docker compose up -d --build"
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
|
||||||
|
SECRETS_DIRECTORY="$PROJECT_ROOT/secrets"
|
||||||
|
CALENDAR_FILE="$SECRETS_DIRECTORY/calendar_ical_urls.json"
|
||||||
|
TOKEN_FILE="$SECRETS_DIRECTORY/organizer_access_token.txt"
|
||||||
|
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
|
||||||
|
ENVIRONMENT_EXAMPLE="$PROJECT_ROOT/.env.example"
|
||||||
|
|
||||||
|
MODE=""
|
||||||
|
ROTATE_TOKEN=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "Usage: $0 [--mode trusted-lan|bearer] [--rotate-token]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
(($# >= 2)) || { usage; exit 2; }
|
||||||
|
MODE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--mode=*)
|
||||||
|
MODE="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rotate-token)
|
||||||
|
ROTATE_TOKEN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$MODE" && "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
|
||||||
|
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$ENVIRONMENT_EXAMPLE" ]]; then
|
||||||
|
printf '%s\n' "Missing .env.example. Run this script from an intact release bundle." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "$SECRETS_DIRECTORY"
|
||||||
|
chmod 700 -- "$SECRETS_DIRECTORY"
|
||||||
|
|
||||||
|
if [[ ! -e "$ENVIRONMENT_FILE" ]]; then
|
||||||
|
cp -- "$ENVIRONMENT_EXAMPLE" "$ENVIRONMENT_FILE"
|
||||||
|
chmod 600 -- "$ENVIRONMENT_FILE"
|
||||||
|
printf '%s\n' "Created .env from .env.example."
|
||||||
|
else
|
||||||
|
printf '%s\n' "Preserved the existing .env file."
|
||||||
|
fi
|
||||||
|
|
||||||
|
replace_calendars=1
|
||||||
|
if [[ -f "$CALENDAR_FILE" ]]; then
|
||||||
|
read -r -p "A calendar secret already exists. Replace it? [y/N] " calendar_answer
|
||||||
|
case "${calendar_answer,,}" in
|
||||||
|
y|yes) replace_calendars=1 ;;
|
||||||
|
*) replace_calendars=0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((replace_calendars)); then
|
||||||
|
calendar_pattern='^https://calendar[.]google[.]com/calendar/ical/[A-Za-z0-9._~%+@-]+/(private-[A-Za-z0-9._~%+@-]+|public)/basic[.]ics$'
|
||||||
|
calendar_urls=()
|
||||||
|
printf '%s\n' "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
|
||||||
|
while true; do
|
||||||
|
printf 'Calendar URL #%d: ' "$(( ${#calendar_urls[@]} + 1 ))" >&2
|
||||||
|
IFS= read -r -s calendar_url
|
||||||
|
printf '\n' >&2
|
||||||
|
[[ -n "$calendar_url" ]] || break
|
||||||
|
if [[ ! "$calendar_url" =~ $calendar_pattern ]]; then
|
||||||
|
printf '%s\n' "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved." >&2
|
||||||
|
unset calendar_url
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
calendar_urls+=("$calendar_url")
|
||||||
|
unset calendar_url
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((${#calendar_urls[@]} == 0)); then
|
||||||
|
printf '%s\n' "At least one calendar URL is required when creating the calendar secret." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
calendar_temporary="$(mktemp "$SECRETS_DIRECTORY/.calendar-XXXXXXXX")"
|
||||||
|
cleanup_calendar() {
|
||||||
|
[[ -n "${calendar_temporary:-}" && -e "$calendar_temporary" ]] && rm -f -- "$calendar_temporary"
|
||||||
|
}
|
||||||
|
trap cleanup_calendar EXIT HUP INT TERM
|
||||||
|
chmod 600 -- "$calendar_temporary"
|
||||||
|
{
|
||||||
|
printf '['
|
||||||
|
for calendar_index in "${!calendar_urls[@]}"; do
|
||||||
|
((calendar_index == 0)) || printf ','
|
||||||
|
printf '"%s"' "${calendar_urls[$calendar_index]}"
|
||||||
|
done
|
||||||
|
printf ']\n'
|
||||||
|
} >"$calendar_temporary"
|
||||||
|
mv -f -- "$calendar_temporary" "$CALENDAR_FILE"
|
||||||
|
calendar_temporary=""
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
chmod 600 -- "$CALENDAR_FILE"
|
||||||
|
printf 'Saved %d calendar source(s) without displaying their URLs.\n' "${#calendar_urls[@]}"
|
||||||
|
unset calendar_urls
|
||||||
|
else
|
||||||
|
chmod 600 -- "$CALENDAR_FILE"
|
||||||
|
printf '%s\n' "Preserved the existing calendar secret."
|
||||||
|
fi
|
||||||
|
|
||||||
|
has_existing_token=0
|
||||||
|
[[ -s "$TOKEN_FILE" ]] && has_existing_token=1
|
||||||
|
|
||||||
|
if [[ -z "$MODE" ]]; then
|
||||||
|
default_mode="trusted-lan"
|
||||||
|
((has_existing_token)) && default_mode="bearer"
|
||||||
|
read -r -p "Access mode [trusted-lan/bearer] (default: $default_mode) " selected_mode
|
||||||
|
MODE="${selected_mode:-$default_mode}"
|
||||||
|
if [[ "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
|
||||||
|
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "trusted-lan" ]]; then
|
||||||
|
if ((ROTATE_TOKEN)); then
|
||||||
|
printf '%s\n' "--rotate-token can only be used with bearer mode." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ((has_existing_token)); then
|
||||||
|
read -r -p "This will erase the current access token and disable bearer authentication. Continue? [y/N] " erase_answer
|
||||||
|
case "${erase_answer,,}" in
|
||||||
|
y|yes) ;;
|
||||||
|
*) printf '%s\n' "Configuration cancelled before changing the access mode." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
|
||||||
|
chmod 600 -- "$token_temporary"
|
||||||
|
: >"$token_temporary"
|
||||||
|
mv -f -- "$token_temporary" "$TOKEN_FILE"
|
||||||
|
chmod 600 -- "$TOKEN_FILE"
|
||||||
|
printf '%s\n' "Configured trusted-LAN mode with an empty token file."
|
||||||
|
elif ((has_existing_token)) && ((ROTATE_TOKEN == 0)); then
|
||||||
|
chmod 600 -- "$TOKEN_FILE"
|
||||||
|
printf '%s\n' "Preserved the existing bearer token. Use --rotate-token to replace it."
|
||||||
|
else
|
||||||
|
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
|
||||||
|
cleanup_token() {
|
||||||
|
[[ -n "${token_temporary:-}" && -e "$token_temporary" ]] && rm -f -- "$token_temporary"
|
||||||
|
}
|
||||||
|
trap cleanup_token EXIT HUP INT TERM
|
||||||
|
chmod 600 -- "$token_temporary"
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -base64 48 2>/dev/null | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
|
||||||
|
elif command -v base64 >/dev/null 2>&1 && [[ -r /dev/urandom ]]; then
|
||||||
|
dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
|
||||||
|
else
|
||||||
|
printf '%s\n' "Bearer mode requires openssl, or base64 with /dev/urandom." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ -s "$token_temporary" ]] || { printf '%s\n' "Secure token generation failed." >&2; exit 1; }
|
||||||
|
mv -f -- "$token_temporary" "$TOKEN_FILE"
|
||||||
|
token_temporary=""
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
chmod 600 -- "$TOKEN_FILE"
|
||||||
|
printf '%s\n' "Generated and stored a new bearer token. The token was not displayed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "Configuration is ready. Review .env, then run: docker compose up -d --build"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Archive,
|
||||||
|
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
$Archive = [IO.Path]::GetFullPath($Archive)
|
||||||
|
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
|
||||||
|
throw "The backup archive does not exist."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Force) {
|
||||||
|
$answer = (Read-Host "Restore will overwrite the current .env and runtime secrets. Continue? [y/N]").Trim().ToLowerInvariant()
|
||||||
|
if ($answer -notin @("y", "yes")) {
|
||||||
|
throw "Restore cancelled."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||||
|
try {
|
||||||
|
$allowedEntries = @(
|
||||||
|
"config/environment.env",
|
||||||
|
"secrets/calendar_ical_urls.json",
|
||||||
|
"secrets/organizer_access_token.txt",
|
||||||
|
"manifest.txt"
|
||||||
|
)
|
||||||
|
$totalBytes = 0L
|
||||||
|
foreach ($entry in $zip.Entries) {
|
||||||
|
$entryName = $entry.FullName.Replace("\", "/").TrimEnd("/")
|
||||||
|
if (-not $entryName) { continue }
|
||||||
|
if ($entryName -notin $allowedEntries -and $entryName -notin @("config", "secrets")) {
|
||||||
|
throw "The archive contains an unexpected path and will not be restored."
|
||||||
|
}
|
||||||
|
$totalBytes += $entry.Length
|
||||||
|
}
|
||||||
|
if ($totalBytes -gt 10MB) {
|
||||||
|
throw "The configuration archive is unexpectedly large."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$stagingPath = Join-Path $ProjectRoot (".restore-staging-" + [Guid]::NewGuid().ToString("N"))
|
||||||
|
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
|
||||||
|
$projectPrefix = $ProjectRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
||||||
|
if (-not $stagingFullPath.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Refusing to create a restore staging directory outside the application directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Expand-Archive -LiteralPath $Archive -DestinationPath $stagingPath
|
||||||
|
$restoredEnvironment = Join-Path $stagingPath "config/environment.env"
|
||||||
|
$restoredCalendars = Join-Path $stagingPath "secrets/calendar_ical_urls.json"
|
||||||
|
$restoredToken = Join-Path $stagingPath "secrets/organizer_access_token.txt"
|
||||||
|
foreach ($required in @($restoredEnvironment, $restoredCalendars, $restoredToken)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||||
|
throw "The archive is missing required configuration files."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$secretsDirectory = Join-Path $ProjectRoot "secrets"
|
||||||
|
New-Item -ItemType Directory -Path $secretsDirectory -Force | Out-Null
|
||||||
|
Copy-Item -LiteralPath $restoredEnvironment -Destination (Join-Path $ProjectRoot ".env") -Force
|
||||||
|
Copy-Item -LiteralPath $restoredCalendars -Destination (Join-Path $secretsDirectory "calendar_ical_urls.json") -Force
|
||||||
|
Copy-Item -LiteralPath $restoredToken -Destination (Join-Path $secretsDirectory "organizer_access_token.txt") -Force
|
||||||
|
|
||||||
|
$secretFiles = @(
|
||||||
|
(Join-Path $secretsDirectory "calendar_ical_urls.json"),
|
||||||
|
(Join-Path $secretsDirectory "organizer_access_token.txt")
|
||||||
|
)
|
||||||
|
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
|
||||||
|
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
foreach ($secretFile in $secretFiles) {
|
||||||
|
& icacls.exe $secretFile "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& chmod 600 -- @secretFiles
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $stagingFullPath) {
|
||||||
|
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Configuration restored. Restart with: docker compose up -d --build"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
|
||||||
|
FORCE=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "Usage: $0 [--force] BACKUP.tar.gz" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--force" ]]; then
|
||||||
|
FORCE=1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
[[ $# -eq 1 ]] || { usage; exit 2; }
|
||||||
|
|
||||||
|
archive_argument="$1"
|
||||||
|
archive_directory="$(cd -- "$(dirname -- "$archive_argument")" && pwd -P)"
|
||||||
|
archive_path="$archive_directory/$(basename -- "$archive_argument")"
|
||||||
|
[[ -f "$archive_path" ]] || { printf '%s\n' "The backup archive does not exist." >&2; exit 1; }
|
||||||
|
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to restore a backup." >&2; exit 1; }
|
||||||
|
|
||||||
|
if ((FORCE == 0)); then
|
||||||
|
read -r -p "Restore will overwrite the current .env and runtime secrets. Continue? [y/N] " restore_answer
|
||||||
|
case "${restore_answer,,}" in
|
||||||
|
y|yes) ;;
|
||||||
|
*) printf '%s\n' "Restore cancelled." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r archive_entry; do
|
||||||
|
normalized_entry="${archive_entry%/}"
|
||||||
|
case "$normalized_entry" in
|
||||||
|
config|config/environment.env|secrets|secrets/calendar_ical_urls.json|secrets/organizer_access_token.txt|manifest.txt) ;;
|
||||||
|
*) printf '%s\n' "The archive contains an unexpected path and will not be restored." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done < <(tar -tzf "$archive_path")
|
||||||
|
|
||||||
|
staging_path="$(mktemp -d "$PROJECT_ROOT/.restore-staging-XXXXXXXX")"
|
||||||
|
cleanup_staging() {
|
||||||
|
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
|
||||||
|
}
|
||||||
|
trap cleanup_staging EXIT HUP INT TERM
|
||||||
|
|
||||||
|
tar -xzf "$archive_path" -C "$staging_path"
|
||||||
|
for required_file in \
|
||||||
|
"$staging_path/config/environment.env" \
|
||||||
|
"$staging_path/secrets/calendar_ical_urls.json" \
|
||||||
|
"$staging_path/secrets/organizer_access_token.txt"; do
|
||||||
|
[[ -f "$required_file" && ! -L "$required_file" ]] || {
|
||||||
|
printf '%s\n' "The archive is missing a required regular file." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p -- "$PROJECT_ROOT/secrets"
|
||||||
|
cp -- "$staging_path/config/environment.env" "$PROJECT_ROOT/.env"
|
||||||
|
cp -- "$staging_path/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/calendar_ical_urls.json"
|
||||||
|
cp -- "$staging_path/secrets/organizer_access_token.txt" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
|
||||||
|
chmod 600 -- "$PROJECT_ROOT/.env" "$PROJECT_ROOT/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
|
||||||
|
|
||||||
|
rm -rf -- "$staging_path"
|
||||||
|
staging_path=""
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
printf '%s\n' "Configuration restored. Restart with: docker compose up -d --build"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Runtime secrets are created by scripts/configure.* and must never be committed.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!calendar_ical_urls.example.json
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"https://calendar.google.com/calendar/ical/your-calendar-id%40group.calendar.google.com/private-REPLACE_WITH_YOUR_PRIVATE_KEY/basic.ics"
|
||||||
|
]
|
||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
# Hobbs' Family Organizer App for NVIDIA Shield
|
||||||
|
|
||||||
|
Android TV wrapper for the private Hobbs family organizer dashboard. Secrets are never stored in source or the APK; configuration is provisioned into app-private files over ADB.
|
||||||
|
|
||||||
|
## Dashboard configuration
|
||||||
|
|
||||||
|
The wrapper supports both the hosted Site and a self-hosted server:
|
||||||
|
|
||||||
|
- No `files/base_url`: use `https://dayline-tv-jeremy.tautfoal1.chatgpt.site/` with the legacy `files/service_token` and `OAI-Sites-Authorization` header.
|
||||||
|
- `files/base_url` present: use that exact origin. An optional `files/access_token` is sent as `Authorization: Bearer ...`.
|
||||||
|
- HTTP additionally requires `files/allow_insecure_http` and is restricted to private LAN IPs or `.local`, `.lan`, and `.home.arpa` names.
|
||||||
|
|
||||||
|
Every navigation, resource request, and redirect is restricted to the configured scheme, host, and effective port. TLS errors are always rejected. The network policy trusts system certificates and user-installed CAs so an internal CA can be used without disabling certificate validation.
|
||||||
|
|
||||||
|
Use `scripts/configure-shield.ps1` to write local settings. The script streams tokens through ADB standard input so they never appear in command arguments or shell history:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\configure-shield.ps1 `
|
||||||
|
-Device 192.168.86.179:5555 `
|
||||||
|
-BaseUrl https://organizer.home.arpa/ `
|
||||||
|
-PromptForToken `
|
||||||
|
-AdbPath C:\path\to\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
For an unauthenticated HTTP service on a trusted LAN, omit `-PromptForToken` and add `-AllowInsecureHttp`. Run `scripts/restore-hosted-site.ps1` to remove only the local settings and return to the preserved hosted token.
|
||||||
|
|
||||||
|
The daily 6:30 AM wake-and-launch schedule is enabled per device by creating the app-private `files/daily_wake_0630_enabled` flag. When enabled, the alarm is restored after app updates, device restarts, and wall-clock or timezone changes. It can wake a sleeping Shield, but not one that is fully powered off or disconnected from power. HDMI-CEC power control must be enabled on the Shield and television for the TV to wake with it.
|
||||||
|
|
||||||
|
## Included APK identity
|
||||||
|
|
||||||
|
- File: `Hobbs-Family-Organizer-Local-v1.4.apk`
|
||||||
|
- Package: `com.dayline.tv`
|
||||||
|
- Version: `1.4` (`versionCode 5`)
|
||||||
|
- APK SHA-256: `A52B91E3ED169CAD985E6F70644BD13F6DD88FFFAAA8D9F1CA47928418495412`
|
||||||
|
- Signing certificate SHA-256: `27a386f6529b25831ed4139859d308a0ea4351b16dfc92a5ffd159a4227ccafe`
|
||||||
|
|
||||||
|
The signing certificate matches the installed v1.3 build, so `adb install -r` performs an in-place upgrade. The private signing key is not included.
|
||||||
|
|
||||||
|
This internal wrapper is a debuggable build because its provisioning and rollback scripts use Android's `run-as` command to write app-private configuration without exposing tokens in ADB arguments. Disable network debugging on the Shield after provisioning and re-enable it only for controlled maintenance.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Device,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$BaseUrl,
|
||||||
|
|
||||||
|
[string]$AdbPath = "adb",
|
||||||
|
|
||||||
|
[switch]$AllowInsecureHttp,
|
||||||
|
|
||||||
|
[switch]$PromptForToken,
|
||||||
|
|
||||||
|
[string]$TokenFile,
|
||||||
|
|
||||||
|
[switch]$NoLaunch
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ($PromptForToken -and $PSBoundParameters.ContainsKey("TokenFile")) {
|
||||||
|
throw "Use either -PromptForToken or -TokenFile, not both."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-AdbExecutable {
|
||||||
|
param([string]$RequestedPath)
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $RequestedPath -PathType Leaf) {
|
||||||
|
return (Resolve-Path -LiteralPath $RequestedPath).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = Get-Command -Name $RequestedPath -CommandType Application -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $command) {
|
||||||
|
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
|
||||||
|
}
|
||||||
|
return $command.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Adb {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string[]]$Arguments,
|
||||||
|
|
||||||
|
[AllowNull()]
|
||||||
|
[string]$StandardInput = $null
|
||||||
|
)
|
||||||
|
|
||||||
|
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$startInfo.FileName = $script:ResolvedAdbPath
|
||||||
|
$startInfo.UseShellExecute = $false
|
||||||
|
$startInfo.CreateNoWindow = $true
|
||||||
|
$startInfo.RedirectStandardInput = $true
|
||||||
|
$startInfo.RedirectStandardOutput = $true
|
||||||
|
$startInfo.RedirectStandardError = $true
|
||||||
|
foreach ($argument in $Arguments) {
|
||||||
|
[void]$startInfo.ArgumentList.Add($argument)
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = [System.Diagnostics.Process]::new()
|
||||||
|
$process.StartInfo = $startInfo
|
||||||
|
if (-not $process.Start()) {
|
||||||
|
throw "ADB could not be started."
|
||||||
|
}
|
||||||
|
|
||||||
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||||
|
if ($null -ne $StandardInput) {
|
||||||
|
$process.StandardInput.Write($StandardInput)
|
||||||
|
}
|
||||||
|
$process.StandardInput.Close()
|
||||||
|
$process.WaitForExit()
|
||||||
|
|
||||||
|
$stdout = $stdoutTask.GetAwaiter().GetResult()
|
||||||
|
$stderr = $stderrTask.GetAwaiter().GetResult()
|
||||||
|
if ($process.ExitCode -ne 0) {
|
||||||
|
$detail = $stderr.Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($detail)) {
|
||||||
|
$detail = $stdout.Trim()
|
||||||
|
}
|
||||||
|
throw "ADB failed with exit code $($process.ExitCode): $detail"
|
||||||
|
}
|
||||||
|
return $stdout.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-AppPrivateFile {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("base_url", "access_token", "allow_insecure_http")]
|
||||||
|
[string]$Name,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
$remoteCommand = "run-as com.dayline.tv sh -c 'umask 077; cat > files/.$Name.new && chmod 600 files/.$Name.new && mv files/.$Name.new files/$Name'"
|
||||||
|
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand) -StandardInput $Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-AppPrivateFile {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("access_token", "allow_insecure_http")]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$remoteCommand = "run-as com.dayline.tv sh -c 'rm -f files/$Name files/.$Name.new'"
|
||||||
|
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PrivateLanHost {
|
||||||
|
param([string]$HostName)
|
||||||
|
|
||||||
|
$normalized = $HostName.Trim().Trim("[", "]").ToLowerInvariant()
|
||||||
|
if ($normalized -eq "localhost" -or
|
||||||
|
$normalized.EndsWith(".local") -or
|
||||||
|
$normalized.EndsWith(".lan") -or
|
||||||
|
$normalized.EndsWith(".home.arpa")) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $null
|
||||||
|
if (-not [System.Net.IPAddress]::TryParse($normalized, [ref]$address)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = $address.GetAddressBytes()
|
||||||
|
if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
return $bytes[0] -eq 10 -or
|
||||||
|
$bytes[0] -eq 127 -or
|
||||||
|
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
|
||||||
|
($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or
|
||||||
|
($bytes[0] -eq 169 -and $bytes[1] -eq 254)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($address.Equals([System.Net.IPAddress]::IPv6Loopback)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return (($bytes[0] -band 0xFE) -eq 0xFC) -or
|
||||||
|
($bytes[0] -eq 0xFE -and ($bytes[1] -band 0xC0) -eq 0x80)
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:ResolvedAdbPath = Resolve-AdbExecutable -RequestedPath $AdbPath
|
||||||
|
|
||||||
|
$dashboardUri = $null
|
||||||
|
if (-not [Uri]::TryCreate($BaseUrl.Trim(), [UriKind]::Absolute, [ref]$dashboardUri)) {
|
||||||
|
throw "BaseUrl is not a valid absolute URL."
|
||||||
|
}
|
||||||
|
if ($dashboardUri.Scheme -notin @("http", "https")) {
|
||||||
|
throw "BaseUrl must use http or https."
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrEmpty($dashboardUri.UserInfo) -or
|
||||||
|
-not [string]::IsNullOrEmpty($dashboardUri.Query) -or
|
||||||
|
-not [string]::IsNullOrEmpty($dashboardUri.Fragment)) {
|
||||||
|
throw "BaseUrl cannot contain credentials, a query, or a fragment."
|
||||||
|
}
|
||||||
|
if ($dashboardUri.AbsolutePath -notin @("", "/")) {
|
||||||
|
throw "The organizer must be served from the URL root path."
|
||||||
|
}
|
||||||
|
if ($dashboardUri.Scheme -eq "http") {
|
||||||
|
if (-not $AllowInsecureHttp) {
|
||||||
|
throw "HTTP requires the explicit -AllowInsecureHttp switch. HTTPS is recommended."
|
||||||
|
}
|
||||||
|
if (-not (Test-PrivateLanHost -HostName $dashboardUri.Host)) {
|
||||||
|
throw "HTTP is restricted to private LAN IP addresses or .local, .lan, and .home.arpa hosts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedBaseUrl = $dashboardUri.GetLeftPart([UriPartial]::Authority) + "/"
|
||||||
|
$plainToken = $null
|
||||||
|
try {
|
||||||
|
if ($PSBoundParameters.ContainsKey("TokenFile")) {
|
||||||
|
if (-not (Test-Path -LiteralPath $TokenFile -PathType Leaf)) {
|
||||||
|
throw "TokenFile was not found."
|
||||||
|
}
|
||||||
|
$plainToken = (Get-Content -LiteralPath $TokenFile -Raw).Trim()
|
||||||
|
} elseif ($PromptForToken) {
|
||||||
|
$secureToken = Read-Host "Local bearer token" -AsSecureString
|
||||||
|
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||||
|
try {
|
||||||
|
$plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
||||||
|
} finally {
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $plainToken -and $plainToken -notmatch "^[A-Za-z0-9._~-]{32,512}$") {
|
||||||
|
throw "The bearer token must contain 32 to 512 URL-safe characters."
|
||||||
|
}
|
||||||
|
|
||||||
|
[void](Invoke-Adb -Arguments @("connect", $Device))
|
||||||
|
$state = Invoke-Adb -Arguments @("-s", $Device, "get-state")
|
||||||
|
if ($state -ne "device") {
|
||||||
|
throw "The Shield is not connected and authorized."
|
||||||
|
}
|
||||||
|
$packagePath = Invoke-Adb -Arguments @("-s", $Device, "shell", "pm path com.dayline.tv")
|
||||||
|
if (-not $packagePath.StartsWith("package:")) {
|
||||||
|
throw "Hobbs' Family Organizer App is not installed on the selected Shield."
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-AppPrivateFile -Name "base_url" -Value $normalizedBaseUrl
|
||||||
|
if ($null -ne $plainToken) {
|
||||||
|
Set-AppPrivateFile -Name "access_token" -Value $plainToken
|
||||||
|
} else {
|
||||||
|
Remove-AppPrivateFile -Name "access_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dashboardUri.Scheme -eq "http") {
|
||||||
|
Set-AppPrivateFile -Name "allow_insecure_http" -Value "enabled"
|
||||||
|
} else {
|
||||||
|
Remove-AppPrivateFile -Name "allow_insecure_http"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoLaunch) {
|
||||||
|
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am force-stop com.dayline.tv"))
|
||||||
|
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am start -W -n com.dayline.tv/.MainActivity"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Configured Hobbs' Family Organizer App on $Device"
|
||||||
|
Write-Host "Dashboard: $normalizedBaseUrl"
|
||||||
|
Write-Host "Bearer token: $(if ($null -eq $plainToken) { 'not configured' } else { 'configured' })"
|
||||||
|
Write-Host "Insecure HTTP: $(if ($dashboardUri.Scheme -eq 'http') { 'enabled for this private LAN origin' } else { 'disabled' })"
|
||||||
|
Write-Host "The legacy hosted service_token was preserved for rollback."
|
||||||
|
} finally {
|
||||||
|
$plainToken = $null
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Hobbs Family Photos (Shield app)
|
||||||
|
|
||||||
|
Package `com.dayline.photos`. It has a launcher tile plus an Android TV screen saver (DreamService). Both show the
|
||||||
|
organizer's `/photo` page in a locked-down WebView.
|
||||||
|
|
||||||
|
- The default URL `http://192.168.86.100:8340/photo` is baked in through `BuildConfig.DEFAULT_URL`.
|
||||||
|
- To override it, write `files/base_url` with `adb shell run-as com.dayline.photos`. The release build is debuggable for exactly this.
|
||||||
|
- The signing key is **not in git**. Lose it and every future update needs an uninstall and reinstall on each TV.
|
||||||
|
- It lives at `/var/lib/hobbs-organizer/apk-signing/` on hf-pdocker-02, with a copy on the NAS at `/mnt/docker-data/configs/hobbs-organizer/apk-signing/`.
|
||||||
|
- `keystore.properties` sets `storeFile`, `storePassword`, `keyAlias` and `keyPassword`.
|
||||||
|
- Build: `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` on hf-pdocker-02. It runs a JDK 17 container with the
|
||||||
|
Android SDK (platform 35, build-tools 35.0.0) and Gradle 8.9, cached in the Docker volume `hobbs-android-sdk`.
|
||||||
|
- Set it as the screen saver from the TV (Settings → Device Preferences → Screen saver), or over ADB:
|
||||||
|
`settings put secure screensaver_components com.dayline.photos/.PhotoDreamService`
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing key lives outside the repo (see BUILDING.md). keystore.properties provides
|
||||||
|
// storeFile, storePassword, keyAlias, keyPassword.
|
||||||
|
def keystoreProps = new Properties()
|
||||||
|
def keystoreFile = rootProject.file("keystore.properties")
|
||||||
|
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.dayline.photos"
|
||||||
|
compileSdk 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.dayline.photos"
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 35
|
||||||
|
versionCode 4
|
||||||
|
versionName "1.2.1"
|
||||||
|
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (keystoreProps["storeFile"]) {
|
||||||
|
storeFile file(keystoreProps["storeFile"])
|
||||||
|
storePassword keystoreProps["storePassword"]
|
||||||
|
keyAlias keystoreProps["keyAlias"]
|
||||||
|
keyPassword keystoreProps["keyPassword"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
// debuggable so `adb shell run-as com.dayline.photos` can write files/base_url,
|
||||||
|
// the same provisioning model as the dashboard app.
|
||||||
|
debuggable true
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError false
|
||||||
|
checkReleaseBuilds false
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Morning routine (TvAgent): exact wake alarms, re-arm after reboot, start other apps
|
||||||
|
from the background (SYSTEM_ALERT_WINDOW is granted over ADB: appops set ... allow). -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Android 11 package visibility: the apps the morning routine opens. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.dayline.tv" />
|
||||||
|
<package android:name="com.google.android.youtube.tvmusic" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="true" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:icon="@drawable/ic_photos"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:theme="@style/Theme.HobbsPhotos"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<!-- Launcher tile: opens the photo frame full screen. -->
|
||||||
|
<activity
|
||||||
|
android:name=".PhotoActivity"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|uiMode"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="landscape">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Screen saver: Settings > Device Preferences > Screen saver > Hobbs Family Photos. -->
|
||||||
|
<service
|
||||||
|
android:name=".PhotoDreamService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.dreams.DreamService" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.dream"
|
||||||
|
android:resource="@xml/dream_info" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Morning routine: wakes the screen, sets the volume, starts the music. -->
|
||||||
|
<activity
|
||||||
|
android:name=".MorningActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".AgentReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="com.dayline.photos.action.CHECK_IN" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
|
||||||
|
/** Boot, app update, clock changes and the periodic check-in alarm all lead to a check-in. */
|
||||||
|
public final class AgentReceiver extends BroadcastReceiver {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
// Re-arm from the cached settings at once (the server may be unreachable right after boot).
|
||||||
|
TvAgent.schedule(app, TvAgent.cachedConfig(app));
|
||||||
|
PendingResult pending = goAsync();
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
TvAgent.checkIn(app);
|
||||||
|
} finally {
|
||||||
|
pending.finish();
|
||||||
|
}
|
||||||
|
}, "tv-agent-check-in").start();
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Started by the morning alarm. Turns the screen on (the Shield then wakes the TV over HDMI-CEC),
|
||||||
|
* sets the volume and starts the playlist in YouTube Music. The calendar is opened at the wake
|
||||||
|
* time by a second alarm, or straight away from here when music is off.
|
||||||
|
*/
|
||||||
|
public final class MorningActivity extends Activity {
|
||||||
|
/** Give the TV a moment to power up and switch input before audio starts. */
|
||||||
|
private static final long SETTLE_MS = 5_000;
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||||
|
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
||||||
|
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setTurnScreenOn(true);
|
||||||
|
setShowWhenLocked(true);
|
||||||
|
}
|
||||||
|
TextView text = new TextView(this);
|
||||||
|
text.setText("Good morning");
|
||||||
|
text.setTextColor(Color.argb(170, 255, 255, 255));
|
||||||
|
text.setTextSize(42);
|
||||||
|
text.setGravity(Gravity.CENTER);
|
||||||
|
text.setBackgroundColor(Color.BLACK);
|
||||||
|
setContentView(text);
|
||||||
|
|
||||||
|
long wakeAt = getIntent().getLongExtra(TvAgent.EXTRA_WAKE_AT, System.currentTimeMillis());
|
||||||
|
// Hold off re-arming until this morning's calendar alarm has fired.
|
||||||
|
TvAgent.prefs(this).edit().putLong(TvAgent.KEY_ROUTINE_UNTIL, Math.max(wakeAt, System.currentTimeMillis()) + 60_000).apply();
|
||||||
|
handler.postDelayed(() -> run(wakeAt), SETTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run(long wakeAt) {
|
||||||
|
JSONObject config = TvAgent.cachedConfig(this);
|
||||||
|
StringBuilder detail = new StringBuilder();
|
||||||
|
boolean ok = true;
|
||||||
|
|
||||||
|
JSONObject volume = config.optJSONObject("volume");
|
||||||
|
if (volume != null && volume.optBoolean("enabled", false)) {
|
||||||
|
AudioManager audio = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||||
|
if (audio != null) {
|
||||||
|
int max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||||
|
int level = Math.round(Math.max(0, Math.min(100, volume.optInt("level", 40))) / 100f * max);
|
||||||
|
try {
|
||||||
|
audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0);
|
||||||
|
detail.append("volume ").append(level).append('/').append(max).append("; ");
|
||||||
|
} catch (SecurityException denied) {
|
||||||
|
ok = false;
|
||||||
|
detail.append("volume refused; ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String calendarPackage = config.optString("calendarPackage", "com.dayline.tv");
|
||||||
|
JSONObject music = config.optJSONObject("music");
|
||||||
|
String url = music != null && music.optBoolean("enabled", false) ? music.optString("url", "") : "";
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
Intent play = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
.setPackage(TvAgent.MUSIC_PACKAGE)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
try {
|
||||||
|
startActivity(play);
|
||||||
|
detail.append("music started");
|
||||||
|
} catch (ActivityNotFoundException missing) {
|
||||||
|
ok = false;
|
||||||
|
detail.append("YouTube Music not installed");
|
||||||
|
openCalendar(calendarPackage, detail);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openCalendar(calendarPackage, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
TvAgent.report(this, ok, detail.toString());
|
||||||
|
// Re-arm for the next day once this morning's calendar alarm is past.
|
||||||
|
long delay = Math.max(0, wakeAt + 60_000 - System.currentTimeMillis());
|
||||||
|
android.content.Context app = getApplicationContext();
|
||||||
|
handler.postDelayed(() -> {
|
||||||
|
TvAgent.prefs(app).edit().remove(TvAgent.KEY_ROUTINE_UNTIL).apply();
|
||||||
|
TvAgent.checkInAsync(app);
|
||||||
|
}, delay);
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCalendar(String calendarPackage, StringBuilder detail) {
|
||||||
|
Intent launch = TvAgent.calendarLaunchIntent(this, calendarPackage);
|
||||||
|
if (launch == null) {
|
||||||
|
detail.append("; calendar app not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startActivity(launch);
|
||||||
|
detail.append(detail.length() > 0 ? "; " : "").append("calendar opened");
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
/** Launcher tile: the photo gallery (remote-driven grid), full screen, screen kept on. Back steps back, then exits. */
|
||||||
|
public final class PhotoActivity extends Activity {
|
||||||
|
private PhotoFrameView frame;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
|
frame = new PhotoFrameView(this, "/gallery", true);
|
||||||
|
setContentView(frame.view());
|
||||||
|
hideSystemUi();
|
||||||
|
frame.view().requestFocus();
|
||||||
|
frame.load();
|
||||||
|
TvAgent.checkInAsync(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
|
super.onWindowFocusChanged(hasFocus);
|
||||||
|
if (hasFocus) hideSystemUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (frame != null && frame.goBack()) return;
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private void hideSystemUi() {
|
||||||
|
getWindow().getDecorView().setSystemUiVisibility(
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
| View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
if (frame != null) frame.destroy();
|
||||||
|
frame = null;
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.service.dreams.DreamService;
|
||||||
|
|
||||||
|
/** Android TV screen saver showing the photo frame. Any remote button wakes the TV as usual. */
|
||||||
|
public final class PhotoDreamService extends DreamService {
|
||||||
|
private PhotoFrameView frame;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
setInteractive(false);
|
||||||
|
setFullscreen(true);
|
||||||
|
setScreenBright(true);
|
||||||
|
frame = new PhotoFrameView(this, "", false);
|
||||||
|
setContentView(frame.view());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDreamingStarted() {
|
||||||
|
super.onDreamingStarted();
|
||||||
|
if (frame != null) frame.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDreamingStopped() {
|
||||||
|
if (frame != null) frame.view().stopLoading();
|
||||||
|
super.onDreamingStopped();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDetachedFromWindow() {
|
||||||
|
if (frame != null) frame.destroy();
|
||||||
|
frame = null;
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.net.http.SslError;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.webkit.CookieManager;
|
||||||
|
import android.webkit.SslErrorHandler;
|
||||||
|
import android.webkit.WebChromeClient;
|
||||||
|
import android.webkit.WebResourceError;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A locked-down full-screen WebView showing the organizer's photo pages.
|
||||||
|
* Shared by the launcher activity (interactive gallery, /photo/gallery) and the screen saver
|
||||||
|
* (non-interactive slideshow, /photo).
|
||||||
|
*/
|
||||||
|
final class PhotoFrameView {
|
||||||
|
private static final long RETRY_MS = 30_000;
|
||||||
|
private static final String BASE_URL_FILE = "base_url";
|
||||||
|
|
||||||
|
private final WebView webView;
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private final String url;
|
||||||
|
private final String origin;
|
||||||
|
private boolean destroyed;
|
||||||
|
/** Set by load(): the next finished page becomes the bottom of the Back stack. */
|
||||||
|
private boolean clearHistoryWhenLoaded;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param subPath appended to the configured photo URL (e.g. "/gallery"), or "" for the slideshow
|
||||||
|
* @param interactive true to take remote (D-pad) input; the screen saver passes false
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
PhotoFrameView(Context context, String subPath, boolean interactive) {
|
||||||
|
String base = configuredUrl(context);
|
||||||
|
while (base.endsWith("/")) base = base.substring(0, base.length() - 1);
|
||||||
|
url = base + subPath;
|
||||||
|
Uri parsed = Uri.parse(url);
|
||||||
|
origin = parsed.getScheme() + "://" + parsed.getAuthority();
|
||||||
|
|
||||||
|
WebView.setWebContentsDebuggingEnabled(false);
|
||||||
|
webView = new WebView(context);
|
||||||
|
webView.setBackgroundColor(Color.BLACK);
|
||||||
|
webView.setFocusable(interactive);
|
||||||
|
webView.setFocusableInTouchMode(interactive);
|
||||||
|
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
settings.setDomStorageEnabled(true);
|
||||||
|
settings.setAllowFileAccess(false);
|
||||||
|
settings.setAllowContentAccess(false);
|
||||||
|
settings.setJavaScriptCanOpenWindowsAutomatically(false);
|
||||||
|
settings.setSupportMultipleWindows(false);
|
||||||
|
settings.setSupportZoom(false);
|
||||||
|
settings.setUseWideViewPort(true);
|
||||||
|
settings.setLoadWithOverviewMode(true);
|
||||||
|
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||||
|
settings.setMediaPlaybackRequiresUserGesture(true);
|
||||||
|
settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/" + BuildConfig.VERSION_NAME);
|
||||||
|
CookieManager.getInstance().setAcceptCookie(false);
|
||||||
|
|
||||||
|
webView.setWebChromeClient(new WebChromeClient());
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||||
|
return !sameOrigin(request.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String finishedUrl) {
|
||||||
|
// Drop the "waiting for server" page (and anything before it) from the Back stack.
|
||||||
|
if (clearHistoryWhenLoaded && finishedUrl != null && finishedUrl.startsWith(origin)) {
|
||||||
|
clearHistoryWhenLoaded = false;
|
||||||
|
view.clearHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceivedSslError(WebView view, SslErrorHandler sslHandler, SslError error) {
|
||||||
|
sslHandler.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||||
|
if (request.isForMainFrame()) {
|
||||||
|
showMessage("Waiting for the photo server…");
|
||||||
|
scheduleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WebView view() {
|
||||||
|
return webView;
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
if (destroyed) return;
|
||||||
|
clearHistoryWhenLoaded = true;
|
||||||
|
webView.loadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back key: step back inside the pages (e.g. close a full-screen photo). False = nothing left, exit. */
|
||||||
|
boolean goBack() {
|
||||||
|
if (destroyed || !webView.canGoBack()) return false;
|
||||||
|
webView.goBack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
handler.removeCallbacksAndMessages(null);
|
||||||
|
webView.stopLoading();
|
||||||
|
webView.setWebViewClient(new WebViewClient());
|
||||||
|
webView.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameOrigin(Uri uri) {
|
||||||
|
return (uri.getScheme() + "://" + uri.getAuthority()).equalsIgnoreCase(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleRetry() {
|
||||||
|
handler.removeCallbacksAndMessages(null);
|
||||||
|
handler.postDelayed(this::load, RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showMessage(String text) {
|
||||||
|
String html = "<html><body style=\"margin:0;height:100vh;display:flex;align-items:center;justify-content:center;"
|
||||||
|
+ "background:#000;color:#ffffff99;font:28px sans-serif\">" + text + "</body></html>";
|
||||||
|
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** files/base_url (written over ADB run-as) overrides the built-in default. */
|
||||||
|
static String configuredUrl(Context context) {
|
||||||
|
File file = new File(context.getFilesDir(), BASE_URL_FILE);
|
||||||
|
if (file.isFile() && file.length() > 0 && file.length() < 2048) {
|
||||||
|
try (FileInputStream input = new FileInputStream(file)) {
|
||||||
|
byte[] data = new byte[(int) file.length()];
|
||||||
|
int read = input.read(data);
|
||||||
|
String value = new String(data, 0, Math.max(read, 0), StandardCharsets.UTF_8).trim();
|
||||||
|
Uri parsed = Uri.parse(value);
|
||||||
|
if (("http".equals(parsed.getScheme()) || "https".equals(parsed.getScheme())) && parsed.getHost() != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// fall back to the default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BuildConfig.DEFAULT_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
package com.dayline.photos;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.app.AlarmManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-TV morning routine controlled from the organizer's admin page (TVs tab).
|
||||||
|
*
|
||||||
|
* Every few minutes the app checks in at GET /api/tv/config with this TV's Android ID and
|
||||||
|
* receives its settings: wake time + days, playlist, volume. It then arms two alarms:
|
||||||
|
* A) wake time minus the music lead: MorningActivity wakes the screen, sets the volume, starts music;
|
||||||
|
* B) the wake time itself: opens the calendar app (com.dayline.tv) on top.
|
||||||
|
* With music off only one alarm is used and MorningActivity opens the calendar itself.
|
||||||
|
*/
|
||||||
|
final class TvAgent {
|
||||||
|
static final String TAG = "HobbsTvAgent";
|
||||||
|
static final String ACTION_CHECK_IN = "com.dayline.photos.action.CHECK_IN";
|
||||||
|
static final String EXTRA_WAKE_AT = "wakeAt";
|
||||||
|
static final String MUSIC_PACKAGE = "com.google.android.youtube.tvmusic";
|
||||||
|
|
||||||
|
private static final String PREFS = "tv_agent";
|
||||||
|
private static final String KEY_CONFIG = "config";
|
||||||
|
private static final String KEY_VERSION = "version";
|
||||||
|
/** While a routine is running (music started, calendar alarm pending) do not re-arm. */
|
||||||
|
static final String KEY_ROUTINE_UNTIL = "routineUntil";
|
||||||
|
private static final int REQ_MORNING = 7001;
|
||||||
|
private static final int REQ_CALENDAR = 7002;
|
||||||
|
private static final int REQ_CHECK_IN = 7003;
|
||||||
|
private static final long DEFAULT_CHECK_IN_MINUTES = 5;
|
||||||
|
|
||||||
|
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
private TvAgent() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- identity / server ----------
|
||||||
|
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
|
static String deviceId(Context context) {
|
||||||
|
String id = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
|
||||||
|
return id == null ? "" : id.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String origin(Context context) {
|
||||||
|
Uri parsed = Uri.parse(PhotoFrameView.configuredUrl(context));
|
||||||
|
return parsed.getScheme() + "://" + parsed.getAuthority();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enc(String value) {
|
||||||
|
try {
|
||||||
|
return URLEncoder.encode(value == null ? "" : value, "UTF-8");
|
||||||
|
} catch (IOException impossible) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String deviceName(Context context) {
|
||||||
|
String name = Settings.Global.getString(context.getContentResolver(), "device_name");
|
||||||
|
return name != null ? name : Build.MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- cached config ----------
|
||||||
|
|
||||||
|
static SharedPreferences prefs(Context context) {
|
||||||
|
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JSONObject cachedConfig(Context context) {
|
||||||
|
try {
|
||||||
|
return new JSONObject(prefs(context).getString(KEY_CONFIG, "{}"));
|
||||||
|
} catch (JSONException broken) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String appliedVersion(Context context) {
|
||||||
|
return prefs(context).getString(KEY_VERSION, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- check-in ----------
|
||||||
|
|
||||||
|
static void checkInAsync(Context context) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
EXECUTOR.execute(() -> checkIn(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocking: fetch settings, re-arm alarms if they changed, confirm the applied version. */
|
||||||
|
static void checkIn(Context context) {
|
||||||
|
try {
|
||||||
|
JSONObject config = fetchConfig(context, appliedVersion(context));
|
||||||
|
String version = config.optString("version", "");
|
||||||
|
boolean changed = !version.equals(appliedVersion(context));
|
||||||
|
prefs(context).edit().putString(KEY_CONFIG, config.toString()).putString(KEY_VERSION, version).apply();
|
||||||
|
schedule(context, config);
|
||||||
|
scheduleCheckIns(context, config.optLong("checkInMinutes", DEFAULT_CHECK_IN_MINUTES));
|
||||||
|
// Tell the server at once that the new settings are in place (admin shows "on the TV").
|
||||||
|
if (changed) fetchConfig(context, version);
|
||||||
|
} catch (IOException | JSONException error) {
|
||||||
|
Log.w(TAG, "Check-in failed: " + error);
|
||||||
|
// Keep the last known schedule.
|
||||||
|
schedule(context, cachedConfig(context));
|
||||||
|
scheduleCheckIns(context, DEFAULT_CHECK_IN_MINUTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject fetchConfig(Context context, String applied) throws IOException, JSONException {
|
||||||
|
String url = origin(context) + "/api/tv/config?device=" + enc(deviceId(context))
|
||||||
|
+ "&name=" + enc(deviceName(context))
|
||||||
|
+ "&model=" + enc(Build.MODEL)
|
||||||
|
+ "&app=" + enc(BuildConfig.VERSION_NAME)
|
||||||
|
+ "&applied=" + enc(applied);
|
||||||
|
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
connection.setConnectTimeout(10_000);
|
||||||
|
connection.setReadTimeout(10_000);
|
||||||
|
connection.setInstanceFollowRedirects(false);
|
||||||
|
try {
|
||||||
|
int code = connection.getResponseCode();
|
||||||
|
if (code != 200) throw new IOException("HTTP " + code);
|
||||||
|
return new JSONObject(readAll(connection.getInputStream()));
|
||||||
|
} finally {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget report of the morning routine's outcome (shown in admin). */
|
||||||
|
static void report(Context context, boolean ok, String detail) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
JSONObject body = new JSONObject()
|
||||||
|
.put("device", deviceId(app))
|
||||||
|
.put("kind", "morning")
|
||||||
|
.put("ok", ok)
|
||||||
|
.put("detail", detail)
|
||||||
|
.put("applied", appliedVersion(app))
|
||||||
|
.put("app", BuildConfig.VERSION_NAME);
|
||||||
|
connection = (HttpURLConnection) new URL(origin(app) + "/api/tv/report").openConnection();
|
||||||
|
connection.setConnectTimeout(10_000);
|
||||||
|
connection.setReadTimeout(10_000);
|
||||||
|
connection.setRequestMethod("POST");
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
|
try (OutputStream out = connection.getOutputStream()) {
|
||||||
|
out.write(body.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
connection.getResponseCode();
|
||||||
|
} catch (IOException | JSONException error) {
|
||||||
|
Log.w(TAG, "Report failed: " + error);
|
||||||
|
} finally {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readAll(InputStream input) throws IOException {
|
||||||
|
try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
byte[] buffer = new byte[4096];
|
||||||
|
int read;
|
||||||
|
int total = 0;
|
||||||
|
while ((read = in.read(buffer)) != -1) {
|
||||||
|
total += read;
|
||||||
|
if (total > 64 * 1024) throw new IOException("Response too large");
|
||||||
|
out.write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
return out.toString("UTF-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alarms ----------
|
||||||
|
|
||||||
|
private static PendingIntent checkInIntent(Context context) {
|
||||||
|
Intent intent = new Intent(context, AgentReceiver.class).setAction(ACTION_CHECK_IN);
|
||||||
|
return PendingIntent.getBroadcast(context, REQ_CHECK_IN, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingIntent morningIntent(Context context, long wakeAt) {
|
||||||
|
Intent intent = new Intent(context, MorningActivity.class)
|
||||||
|
.putExtra(EXTRA_WAKE_AT, wakeAt)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
return PendingIntent.getActivity(context, REQ_MORNING, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Intent calendarLaunchIntent(Context context, String calendarPackage) {
|
||||||
|
Intent launch = context.getPackageManager().getLeanbackLaunchIntentForPackage(calendarPackage);
|
||||||
|
if (launch == null) launch = context.getPackageManager().getLaunchIntentForPackage(calendarPackage);
|
||||||
|
if (launch == null) return null;
|
||||||
|
return launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingIntent calendarIntent(Context context, String calendarPackage) {
|
||||||
|
Intent launch = calendarLaunchIntent(context, calendarPackage);
|
||||||
|
if (launch == null) launch = new Intent().setComponent(new ComponentName(calendarPackage, calendarPackage + ".MainActivity"));
|
||||||
|
return PendingIntent.getActivity(context, REQ_CALENDAR, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void scheduleCheckIns(Context context, long minutes) {
|
||||||
|
AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (alarms == null) return;
|
||||||
|
long interval = Math.max(1, minutes) * 60_000L;
|
||||||
|
// One-shot and chained (every check-in arms the next): "allow while idle" alarms still fire
|
||||||
|
// while the Shield sleeps in Doze, where repeating alarms would wait for a maintenance window.
|
||||||
|
alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + interval, checkInIntent(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arms (or cancels) the morning alarms from a config object. */
|
||||||
|
static void schedule(Context context, JSONObject config) {
|
||||||
|
AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (alarms == null) return;
|
||||||
|
if (System.currentTimeMillis() < prefs(context).getLong(KEY_ROUTINE_UNTIL, 0)) {
|
||||||
|
Log.i(TAG, "Routine in progress; re-arming after it ends");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String calendarPackage = config.optString("calendarPackage", "com.dayline.tv");
|
||||||
|
alarms.cancel(morningIntent(context, 0));
|
||||||
|
alarms.cancel(calendarIntent(context, calendarPackage));
|
||||||
|
|
||||||
|
JSONObject wake = config.optJSONObject("wake");
|
||||||
|
if (wake == null || !wake.optBoolean("enabled", false)) {
|
||||||
|
Log.i(TAG, "Morning routine off");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long wakeAt = nextWake(wake.optString("time", "06:30"), wake.optJSONArray("days"), System.currentTimeMillis());
|
||||||
|
if (wakeAt <= 0) return;
|
||||||
|
JSONObject music = config.optJSONObject("music");
|
||||||
|
boolean musicOn = music != null && music.optBoolean("enabled", false) && !music.optString("url", "").isEmpty();
|
||||||
|
long lead = musicOn ? config.optLong("musicLeadSeconds", 30) * 1000L : 0;
|
||||||
|
|
||||||
|
// setAlarmClock: exact, wakes the device from idle, and may start activities.
|
||||||
|
PendingIntent morning = morningIntent(context, wakeAt);
|
||||||
|
alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt - lead, morning), morning);
|
||||||
|
if (musicOn) {
|
||||||
|
alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt, morning), calendarIntent(context, calendarPackage));
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Morning routine armed for " + wakeAt + (musicOn ? " (music " + lead / 1000 + "s earlier)" : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next time at HH:MM (local) on one of the allowed days (0 = Sunday), strictly after now plus a minute. */
|
||||||
|
static long nextWake(String time, JSONArray days, long now) {
|
||||||
|
String[] parts = time.split(":");
|
||||||
|
int hour;
|
||||||
|
int minute;
|
||||||
|
try {
|
||||||
|
hour = Integer.parseInt(parts[0]);
|
||||||
|
minute = Integer.parseInt(parts[1]);
|
||||||
|
} catch (RuntimeException bad) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
boolean[] allowed = new boolean[7];
|
||||||
|
boolean any = false;
|
||||||
|
if (days == null) {
|
||||||
|
java.util.Arrays.fill(allowed, true);
|
||||||
|
any = true;
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < days.length(); i++) {
|
||||||
|
int day = days.optInt(i, -1);
|
||||||
|
if (day >= 0 && day <= 6) {
|
||||||
|
allowed[day] = true;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) return -1;
|
||||||
|
Calendar candidate = Calendar.getInstance();
|
||||||
|
candidate.setTimeInMillis(now);
|
||||||
|
candidate.set(Calendar.HOUR_OF_DAY, hour);
|
||||||
|
candidate.set(Calendar.MINUTE, minute);
|
||||||
|
candidate.set(Calendar.SECOND, 0);
|
||||||
|
candidate.set(Calendar.MILLISECOND, 0);
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
int day = candidate.get(Calendar.DAY_OF_WEEK) - 1; // Calendar.SUNDAY == 1
|
||||||
|
if (candidate.getTimeInMillis() > now + 60_000 && allowed[day]) return candidate.getTimeInMillis();
|
||||||
|
candidate.add(Calendar.DAY_OF_YEAR, 1);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#07101D" android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||||
|
<path android:fillColor="#A5F3FC" android:pathData="M22,28 L86,28 L86,80 L22,80 Z" />
|
||||||
|
<path android:fillColor="#07101D" android:pathData="M27,33 L81,33 L81,75 L27,75 Z" />
|
||||||
|
<path android:fillColor="#34D399" android:pathData="M27,75 L45,52 L58,66 L66,58 L81,75 Z" />
|
||||||
|
<path android:fillColor="#FBBF24" android:pathData="M70,46 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
|
||||||
|
</vector>
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="320dp"
|
||||||
|
android:height="180dp"
|
||||||
|
android:viewportWidth="320"
|
||||||
|
android:viewportHeight="180">
|
||||||
|
<path android:fillColor="#07101D" android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
|
||||||
|
<path android:fillColor="#0E2233" android:pathData="M0,0 L150,0 L62,180 L0,180 Z" />
|
||||||
|
<!-- picture frame -->
|
||||||
|
<path android:fillColor="#A5F3FC" android:pathData="M40,45 L150,45 L150,135 L40,135 Z" />
|
||||||
|
<path android:fillColor="#07101D" android:pathData="M48,53 L142,53 L142,127 L48,127 Z" />
|
||||||
|
<path android:fillColor="#34D399" android:pathData="M48,127 L80,86 L103,111 L117,97 L142,127 Z" />
|
||||||
|
<path android:fillColor="#FBBF24" android:pathData="M123,75 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0" />
|
||||||
|
<!-- "PHOTOS" accent bars -->
|
||||||
|
<path android:fillColor="#E2E8F0" android:pathData="M170,70 L290,70 L290,84 L170,84 Z" />
|
||||||
|
<path android:fillColor="#94A3B8" android:pathData="M170,96 L260,96 L260,106 L170,106 Z" />
|
||||||
|
<path android:fillColor="#67E8F9" android:pathData="M170,120 L290,120 L290,125 L170,125 Z" />
|
||||||
|
</vector>
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hobbs Family Photos</string>
|
||||||
|
</resources>
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.HobbsPhotos" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowFullscreen">true</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowBackground">#000000</item>
|
||||||
|
<item name="android:navigationBarColor">#000000</item>
|
||||||
|
<item name="android:statusBarColor">#000000</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<dream xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application" version "8.7.3" apply false
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=false
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "HobbsPhotoFrame"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Device,
|
||||||
|
|
||||||
|
[string]$AdbPath = "adb"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $AdbPath -PathType Leaf) {
|
||||||
|
$resolvedAdb = (Resolve-Path -LiteralPath $AdbPath).Path
|
||||||
|
} else {
|
||||||
|
$command = Get-Command -Name $AdbPath -CommandType Application -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $command) {
|
||||||
|
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
|
||||||
|
}
|
||||||
|
$resolvedAdb = $command.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
& $resolvedAdb connect $Device | Out-Null
|
||||||
|
if ((& $resolvedAdb -s $Device get-state) -ne "device") {
|
||||||
|
throw "The Shield is not connected and authorized."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $resolvedAdb -s $Device shell "run-as com.dayline.tv sh -c 'rm -f files/base_url files/.base_url.new files/access_token files/.access_token.new files/allow_insecure_http files/.allow_insecure_http.new'"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "The local configuration could not be removed."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $resolvedAdb -s $Device shell am force-stop com.dayline.tv
|
||||||
|
& $resolvedAdb -s $Device shell am start -W -n com.dayline.tv/.MainActivity
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "The organizer could not be relaunched."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Restored the hosted Site configuration on $Device. The legacy service_token was not changed."
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user