feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source) - compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script - CLAUDE.md: service tree + internal ports Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
@@ -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,39 @@
|
||||
# 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 ./container/start.mjs
|
||||
COPY --from=build --chown=10001:10001 /app/package.json /app/next.config.mjs ./
|
||||
RUN mkdir -p /app/.next-build/cache && chown -R 10001:10001 /app/.next-build
|
||||
# The tiny supervisor starts as root only long enough to read Docker secrets,
|
||||
# then launches Next.js as uid/gid 10001. Compose grants only SETUID/SETGID.
|
||||
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,250 @@
|
||||
# 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.
|
||||
|
||||
## 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,12 @@
|
||||
# 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.0.0
|
||||
@@ -0,0 +1,297 @@
|
||||
import ICAL from "ical.js";
|
||||
|
||||
type CalendarTone = "cyan" | "amber" | "violet" | "emerald" | "rose";
|
||||
|
||||
type CalendarEvent = {
|
||||
id: string;
|
||||
calendar: string;
|
||||
title: string;
|
||||
location?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
tone: CalendarTone;
|
||||
};
|
||||
|
||||
const tones: CalendarTone[] = ["cyan", "amber", "violet", "emerald", "rose"];
|
||||
const maxFeedBytes = 5 * 1024 * 1024;
|
||||
const maxOccurrencesPerEvent = 10_000;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function parseFeedUrls() {
|
||||
const raw = process.env.CALENDAR_ICAL_URLS;
|
||||
if (!raw) return [];
|
||||
|
||||
const values = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
|
||||
throw new Error("Calendar feed configuration is invalid");
|
||||
}
|
||||
|
||||
return values.map((value) => {
|
||||
const url = new URL(String(value));
|
||||
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
|
||||
throw new Error("Calendar feed host is not allowed");
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function expandCalendar(
|
||||
ics: string,
|
||||
feedIndex: number,
|
||||
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 = sourceName.includes("@") ? "Personal" : sourceName;
|
||||
const tone = tones[feedIndex % tones.length];
|
||||
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, items };
|
||||
}
|
||||
|
||||
async function readCalendar(
|
||||
url: string,
|
||||
feedIndex: number,
|
||||
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), feedIndex, windowStart, horizon, windowStartDate, horizonDate);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
let urls: string[];
|
||||
try {
|
||||
urls = parseFeedUrls();
|
||||
} catch {
|
||||
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
|
||||
}
|
||||
|
||||
if (!urls.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(
|
||||
urls.map((url, index) => readCalendar(url, index, 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: urls.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,23 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
let calendarConfigurationValid = false;
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.CALENDAR_ICAL_URLS || "[]") as unknown;
|
||||
calendarConfigurationValid = Array.isArray(parsed) && parsed.length > 0;
|
||||
} catch {
|
||||
calendarConfigurationValid = false;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
status: "ok",
|
||||
service: "hobbs-family-organizer",
|
||||
version: "1.0.0",
|
||||
calendarConfigurationValid,
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,58 @@
|
||||
name: hobbs-family-organizer
|
||||
|
||||
services:
|
||||
organizer:
|
||||
image: hobbs-family-organizer:1.0.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
|
||||
secrets:
|
||||
- calendar_ical_urls
|
||||
- organizer_access_token
|
||||
ports:
|
||||
- "${HOBBS_BIND_ADDRESS:-0.0.0.0}:${HOBBS_HTTP_PORT:-8080}:3000"
|
||||
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,106 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
async function readable(path) {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = false) {
|
||||
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();
|
||||
if (required) throw new Error(`${environmentName} is required; create ${configuredFile}`);
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateCalendarConfiguration(raw) {
|
||||
let values;
|
||||
try {
|
||||
values = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Calendar secret must be a JSON array");
|
||||
}
|
||||
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
|
||||
throw new Error("Calendar secret must contain between 1 and 10 URLs");
|
||||
}
|
||||
|
||||
const normalized = values.map((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();
|
||||
});
|
||||
return JSON.stringify([...new Set(normalized)]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const calendarSecret = await settingFromFile(
|
||||
"CALENDAR_ICAL_URLS",
|
||||
"CALENDAR_ICAL_URLS_FILE",
|
||||
"/run/secrets/calendar_ical_urls",
|
||||
true,
|
||||
);
|
||||
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 environment = {
|
||||
...process.env,
|
||||
CALENDAR_ICAL_URLS: validateCalendarConfiguration(calendarSecret),
|
||||
ORGANIZER_ACCESS_TOKEN: accessToken,
|
||||
ORGANIZER_TIMEZONE: organizerTimeZone,
|
||||
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
|
||||
PORT: process.env.PORT || "3000",
|
||||
};
|
||||
|
||||
const childOptions = {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
stdio: "inherit",
|
||||
};
|
||||
if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0) {
|
||||
childOptions.uid = 10001;
|
||||
childOptions.gid = 10001;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "start", "-H", environment.HOSTNAME, "-p", environment.PORT],
|
||||
childOptions,
|
||||
);
|
||||
const forwardSignal = (signal) => {
|
||||
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,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.0.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,35 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
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) {
|
||||
if (request.nextUrl.pathname === "/api/health") 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.
@@ -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,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
|
||||
@@ -0,0 +1,13 @@
|
||||
# Building the Android wrapper
|
||||
|
||||
The source targets Android SDK 35, Java 17, Android Gradle Plugin 8.7.3, and Gradle 8.9.
|
||||
|
||||
From this directory, with the Android SDK and Gradle installed:
|
||||
|
||||
```text
|
||||
gradle testDebugUnitTest lintDebug assembleDebug
|
||||
```
|
||||
|
||||
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
Android requires the same signing certificate for upgrades. The release bundle intentionally excludes the private key used for the prebuilt APK. Use the included signed APK for existing Shields unless you have retained the original key separately.
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.dayline.tv"
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.dayline.tv"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 5
|
||||
versionName "1.4"
|
||||
|
||||
testInstrumentationRunner "android.test.InstrumentationTestRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
# Hobbs' Family Organizer App currently has no release-only shrinking rules.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<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:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@drawable/ic_dayline"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DaylineTV"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
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>
|
||||
|
||||
<receiver
|
||||
android:name=".ScheduleReceiver"
|
||||
android:enabled="true"
|
||||
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" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.dayline.tv;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class DashboardConfig {
|
||||
private static final String LEGACY_SITE_URL = "https://dayline-tv-jeremy.tautfoal1.chatgpt.site/";
|
||||
private static final String LEGACY_AUTH_HEADER = "OAI-Sites-Authorization";
|
||||
private static final String LOCAL_AUTH_HEADER = "Authorization";
|
||||
private static final String BASE_URL_FILE = "base_url";
|
||||
private static final String ACCESS_TOKEN_FILE = "access_token";
|
||||
private static final String LEGACY_TOKEN_FILE = "service_token";
|
||||
private static final String ALLOW_HTTP_FILE = "allow_insecure_http";
|
||||
private static final int MAX_URL_BYTES = 2048;
|
||||
private static final int MAX_TOKEN_BYTES = 1024;
|
||||
private static final Pattern TOKEN_PATTERN = Pattern.compile("[A-Za-z0-9._~-]{32,512}");
|
||||
|
||||
private final URI baseOrigin;
|
||||
private final String baseUrl;
|
||||
private final String authHeader;
|
||||
private final String token;
|
||||
|
||||
private DashboardConfig(URI baseOrigin, String baseUrl, String authHeader, String token) {
|
||||
this.baseOrigin = baseOrigin;
|
||||
this.baseUrl = baseUrl;
|
||||
this.authHeader = authHeader;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static DashboardConfig load(Context context) throws ConfigException {
|
||||
String configuredUrl = readOptionalText(context, BASE_URL_FILE, MAX_URL_BYTES);
|
||||
boolean hostedFallback = configuredUrl == null;
|
||||
String rawUrl = hostedFallback ? LEGACY_SITE_URL : configuredUrl;
|
||||
URI origin = parseBaseUrl(rawUrl, context.getFileStreamPath(ALLOW_HTTP_FILE).isFile());
|
||||
|
||||
String token;
|
||||
String authHeader;
|
||||
if (hostedFallback) {
|
||||
token = readOptionalText(context, LEGACY_TOKEN_FILE, MAX_TOKEN_BYTES);
|
||||
if (!isValidToken(token)) {
|
||||
throw new ConfigException(
|
||||
"The hosted dashboard access token is missing. Provision files/service_token over ADB or configure files/base_url for a local server."
|
||||
);
|
||||
}
|
||||
authHeader = LEGACY_AUTH_HEADER;
|
||||
} else {
|
||||
token = readOptionalText(context, ACCESS_TOKEN_FILE, MAX_TOKEN_BYTES);
|
||||
if (token != null && !isValidToken(token)) {
|
||||
throw new ConfigException("The local access token must be 32 to 512 URL-safe characters.");
|
||||
}
|
||||
authHeader = token == null ? null : LOCAL_AUTH_HEADER;
|
||||
}
|
||||
|
||||
String normalizedUrl = normalizedRootUrl(origin);
|
||||
try {
|
||||
return new DashboardConfig(new URI(normalizedUrl), normalizedUrl, authHeader, token);
|
||||
} catch (URISyntaxException error) {
|
||||
throw new ConfigException("The dashboard URL could not be normalized.");
|
||||
}
|
||||
}
|
||||
|
||||
String baseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
boolean hasToken() {
|
||||
return token != null;
|
||||
}
|
||||
|
||||
String authHeader() {
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
String authorizationValue() {
|
||||
return token == null ? null : "Bearer " + token;
|
||||
}
|
||||
|
||||
boolean isAllowed(Uri uri) {
|
||||
if (uri == null) return false;
|
||||
try {
|
||||
return sameOrigin(baseOrigin, new URI(uri.toString()));
|
||||
} catch (URISyntaxException error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static URI parseBaseUrl(String value, boolean allowHttp) throws ConfigException {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(value.trim());
|
||||
} catch (URISyntaxException error) {
|
||||
throw new ConfigException("The configured dashboard URL is invalid.");
|
||||
}
|
||||
|
||||
String scheme = lower(uri.getScheme());
|
||||
String host = normalizeHost(uri.getHost());
|
||||
if (scheme == null || host == null || host.isBlank()) {
|
||||
throw new ConfigException("The dashboard URL must include an http or https host.");
|
||||
}
|
||||
if (!scheme.equals("https") && !scheme.equals("http")) {
|
||||
throw new ConfigException("The dashboard URL must use http or https.");
|
||||
}
|
||||
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) {
|
||||
throw new ConfigException("The dashboard URL cannot contain credentials, a query, or a fragment.");
|
||||
}
|
||||
String path = uri.getRawPath();
|
||||
if (path != null && !path.isEmpty() && !path.equals("/")) {
|
||||
throw new ConfigException("The dashboard must be served from the URL root path.");
|
||||
}
|
||||
if (uri.getPort() == 0 || uri.getPort() < -1 || uri.getPort() > 65535) {
|
||||
throw new ConfigException("The dashboard URL contains an invalid port.");
|
||||
}
|
||||
if (scheme.equals("http") && (!allowHttp || !isPrivateLanHost(host))) {
|
||||
throw new ConfigException(
|
||||
"HTTP requires files/allow_insecure_http and a private LAN address or .local, .lan, or .home.arpa host."
|
||||
);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
static boolean sameOrigin(URI expected, URI candidate) {
|
||||
if (expected == null || candidate == null || candidate.getUserInfo() != null) return false;
|
||||
String expectedScheme = lower(expected.getScheme());
|
||||
String candidateScheme = lower(candidate.getScheme());
|
||||
if (!"http".equals(candidateScheme) && !"https".equals(candidateScheme)) return false;
|
||||
return expectedScheme != null
|
||||
&& expectedScheme.equals(candidateScheme)
|
||||
&& normalizeHost(expected.getHost()).equals(normalizeHost(candidate.getHost()))
|
||||
&& effectivePort(expected) == effectivePort(candidate);
|
||||
}
|
||||
|
||||
static boolean isPrivateLanHost(String value) {
|
||||
String host = normalizeHost(value);
|
||||
if (host == null || host.isBlank()) return false;
|
||||
if (host.equals("localhost") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
|
||||
return true;
|
||||
}
|
||||
if (isPrivateIpv4(host)) return true;
|
||||
|
||||
String ipv6 = host.toLowerCase(Locale.ROOT);
|
||||
if (ipv6.startsWith("[") && ipv6.endsWith("]")) ipv6 = ipv6.substring(1, ipv6.length() - 1);
|
||||
return ipv6.equals("::1")
|
||||
|| ipv6.startsWith("fc")
|
||||
|| ipv6.startsWith("fd")
|
||||
|| ipv6.startsWith("fe8")
|
||||
|| ipv6.startsWith("fe9")
|
||||
|| ipv6.startsWith("fea")
|
||||
|| ipv6.startsWith("feb");
|
||||
}
|
||||
|
||||
private static boolean isPrivateIpv4(String host) {
|
||||
String[] parts = host.split("\\.", -1);
|
||||
if (parts.length != 4) return false;
|
||||
int[] octets = new int[4];
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
if (parts[index].isEmpty() || parts[index].length() > 3) return false;
|
||||
try {
|
||||
octets[index] = Integer.parseInt(parts[index]);
|
||||
} catch (NumberFormatException error) {
|
||||
return false;
|
||||
}
|
||||
if (octets[index] < 0 || octets[index] > 255) return false;
|
||||
}
|
||||
return octets[0] == 10
|
||||
|| octets[0] == 127
|
||||
|| octets[0] == 192 && octets[1] == 168
|
||||
|| octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31
|
||||
|| octets[0] == 169 && octets[1] == 254;
|
||||
}
|
||||
|
||||
private static int effectivePort(URI uri) {
|
||||
if (uri.getPort() != -1) return uri.getPort();
|
||||
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
|
||||
private static String normalizedRootUrl(URI uri) throws ConfigException {
|
||||
String scheme = lower(uri.getScheme());
|
||||
String host = normalizeHost(uri.getHost());
|
||||
try {
|
||||
return new URI(scheme, null, host, uri.getPort(), "/", null, null).toString();
|
||||
} catch (URISyntaxException error) {
|
||||
throw new ConfigException("The dashboard URL could not be normalized.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeHost(String host) {
|
||||
if (host == null) return null;
|
||||
String normalized = host.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
return normalized.substring(1, normalized.length() - 1);
|
||||
}
|
||||
if (normalized.contains(":")) return normalized;
|
||||
try {
|
||||
return IDN.toASCII(normalized, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
|
||||
} catch (IllegalArgumentException error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean isValidToken(String value) {
|
||||
return value != null && TOKEN_PATTERN.matcher(value).matches();
|
||||
}
|
||||
|
||||
private static String readOptionalText(Context context, String name, int maxBytes) throws ConfigException {
|
||||
File file = context.getFileStreamPath(name);
|
||||
if (!file.isFile()) return null;
|
||||
if (file.length() == 0) return null;
|
||||
if (file.length() > maxBytes) throw new ConfigException("Configuration file " + name + " is too large.");
|
||||
|
||||
try (InputStream input = new FileInputStream(file)) {
|
||||
byte[] bytes = new byte[(int) file.length()];
|
||||
int offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
int read = input.read(bytes, offset, bytes.length - offset);
|
||||
if (read < 0) break;
|
||||
offset += read;
|
||||
}
|
||||
String value = new String(bytes, 0, offset, StandardCharsets.UTF_8).trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
} catch (IOException error) {
|
||||
throw new ConfigException("Configuration file " + name + " could not be read.");
|
||||
}
|
||||
}
|
||||
|
||||
static final class ConfigException extends Exception {
|
||||
ConfigException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.dayline.tv;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Calendar;
|
||||
|
||||
final class DaylineScheduler {
|
||||
static final String ACTION_DAILY_WAKE = "com.dayline.tv.action.DAILY_WAKE";
|
||||
static final int WAKE_HOUR = 6;
|
||||
static final int WAKE_MINUTE = 30;
|
||||
|
||||
private static final String TAG = "DaylineScheduler";
|
||||
private static final int REQUEST_CODE = 630;
|
||||
private static final String ENABLED_FLAG_FILE = "daily_wake_0630_enabled";
|
||||
|
||||
private DaylineScheduler() {
|
||||
}
|
||||
|
||||
static void scheduleNext(Context context) {
|
||||
Context appContext = context.getApplicationContext();
|
||||
AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE);
|
||||
if (alarmManager == null) {
|
||||
Log.e(TAG, "AlarmManager is unavailable; daily launch was not scheduled");
|
||||
return;
|
||||
}
|
||||
|
||||
PendingIntent launchIntent = launchPendingIntent(appContext);
|
||||
if (!appContext.getFileStreamPath(ENABLED_FLAG_FILE).isFile()) {
|
||||
alarmManager.cancel(launchIntent);
|
||||
Log.i(TAG, "Daily launch is disabled on this device");
|
||||
return;
|
||||
}
|
||||
|
||||
long triggerAtMillis = nextWakeMillis(System.currentTimeMillis());
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
|
||||
Log.w(TAG, "Exact-alarm access is unavailable; scheduled an approximate 6:30 AM launch");
|
||||
} else {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
|
||||
Log.i(TAG, "Scheduled daily launch for " + triggerAtMillis);
|
||||
}
|
||||
}
|
||||
|
||||
static long nextWakeMillis(long nowMillis) {
|
||||
Calendar next = Calendar.getInstance();
|
||||
next.setTimeInMillis(nowMillis);
|
||||
next.set(Calendar.HOUR_OF_DAY, WAKE_HOUR);
|
||||
next.set(Calendar.MINUTE, WAKE_MINUTE);
|
||||
next.set(Calendar.SECOND, 0);
|
||||
next.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
if (next.getTimeInMillis() <= nowMillis) {
|
||||
next.add(Calendar.DAY_OF_YEAR, 1);
|
||||
}
|
||||
return next.getTimeInMillis();
|
||||
}
|
||||
|
||||
private static PendingIntent launchPendingIntent(Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class)
|
||||
.setAction(ACTION_DAILY_WAKE)
|
||||
.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
| Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
);
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
package com.dayline.tv;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.net.http.SslError;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.SslErrorHandler;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
|
||||
private WebView webView;
|
||||
private volatile DashboardConfig dashboardConfig;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureWakeBehavior();
|
||||
DaylineScheduler.scheduleNext(this);
|
||||
enterImmersiveMode();
|
||||
configureWebView();
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
configureWakeBehavior();
|
||||
DaylineScheduler.scheduleNext(this);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void configureWakeBehavior() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private void configureWebView() {
|
||||
WebView.setWebContentsDebuggingEnabled(false);
|
||||
webView = new WebView(this);
|
||||
webView.setBackgroundColor(Color.rgb(7, 11, 18));
|
||||
webView.setFocusable(true);
|
||||
webView.setFocusableInTouchMode(true);
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setJavaScriptCanOpenWindowsAutomatically(false);
|
||||
settings.setSupportMultipleWindows(false);
|
||||
settings.setBuiltInZoomControls(false);
|
||||
settings.setDisplayZoomControls(false);
|
||||
settings.setSupportZoom(false);
|
||||
settings.setUseWideViewPort(true);
|
||||
settings.setLoadWithOverviewMode(true);
|
||||
settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||
settings.setSafeBrowsingEnabled(true);
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " HobbsOrganizer/1.4");
|
||||
webView.setInitialScale(0);
|
||||
|
||||
CookieManager cookies = CookieManager.getInstance();
|
||||
cookies.setAcceptCookie(false);
|
||||
cookies.setAcceptThirdPartyCookies(webView, false);
|
||||
|
||||
webView.setWebChromeClient(new WebChromeClient());
|
||||
webView.setWebViewClient(new DaylineWebViewClient());
|
||||
setContentView(webView);
|
||||
webView.requestFocus(View.FOCUS_DOWN);
|
||||
}
|
||||
|
||||
private void loadDashboard() {
|
||||
try {
|
||||
dashboardConfig = DashboardConfig.load(this);
|
||||
} catch (DashboardConfig.ConfigException error) {
|
||||
showLocalMessage(
|
||||
"Setup required",
|
||||
error.getMessage()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
if (dashboardConfig.hasToken()) {
|
||||
headers.put(dashboardConfig.authHeader(), dashboardConfig.authorizationValue());
|
||||
}
|
||||
webView.loadUrl(dashboardConfig.baseUrl(), headers);
|
||||
}
|
||||
|
||||
private void showLocalMessage(String title, String detail) {
|
||||
String html = "<!doctype html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
||||
+ "<style>html,body{height:100%;margin:0;background:#070b12;color:#f8fafc;font-family:sans-serif}"
|
||||
+ "body{display:grid;place-items:center}.card{max-width:760px;border:1px solid #ffffff1a;border-radius:28px;padding:52px;"
|
||||
+ "background:#0c121d;text-align:center;box-shadow:0 30px 90px #0006}h1{font-size:44px;margin:0 0 16px;color:#a5f3fc}"
|
||||
+ "p{font-size:24px;line-height:1.5;color:#94a3b8;margin:0}</style></head><body><div class=\"card\"><h1>"
|
||||
+ escapeHtml(title) + "</h1><p>" + escapeHtml(detail) + "</p></div></body></html>";
|
||||
webView.loadDataWithBaseURL("https://dayline.local/", html, "text/html", "UTF-8", null);
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enterImmersiveMode();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void enterImmersiveMode() {
|
||||
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
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && webView != null && webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (webView != null) {
|
||||
webView.stopLoading();
|
||||
webView.setWebChromeClient(null);
|
||||
webView.setWebViewClient(null);
|
||||
webView.destroy();
|
||||
webView = null;
|
||||
}
|
||||
dashboardConfig = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private final class DaylineWebViewClient extends WebViewClient {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
DashboardConfig config = dashboardConfig;
|
||||
return config == null || !config.isAllowed(request.getUrl());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||
DashboardConfig config = dashboardConfig;
|
||||
return config == null || !config.isAllowed(Uri.parse(url));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
DashboardConfig config = dashboardConfig;
|
||||
if (config == null) return localError(503, "Unavailable", "Dashboard configuration is unavailable.");
|
||||
if (!config.isAllowed(request.getUrl())) return localError(403, "Forbidden", "Blocked cross-origin request.");
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) return null;
|
||||
return fetchRequest(request.getUrl(), request.getRequestHeaders(), config, request.isForMainFrame(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
|
||||
handler.cancel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
showLocalMessage("The organizer is offline", "Check the Shield network connection. The dashboard will be available after relaunching the app.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private WebResourceResponse fetchRequest(
|
||||
Uri uri,
|
||||
Map<String, String> requestHeaders,
|
||||
DashboardConfig config,
|
||||
boolean mainFrame,
|
||||
int redirectCount
|
||||
) {
|
||||
if (!config.isAllowed(uri)) return localError(403, "Forbidden", "Blocked cross-origin request.");
|
||||
if (redirectCount > MAX_REDIRECTS) return localError(508, "Loop Detected", "Too many redirects.");
|
||||
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = (HttpURLConnection) new URL(uri.toString()).openConnection();
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(10_000);
|
||||
connection.setReadTimeout(20_000);
|
||||
connection.setRequestMethod("GET");
|
||||
if (config.hasToken()) {
|
||||
connection.setRequestProperty(config.authHeader(), config.authorizationValue());
|
||||
}
|
||||
connection.setRequestProperty("Accept-Encoding", "identity");
|
||||
copyRequestHeader(requestHeaders, connection, "Accept");
|
||||
copyRequestHeader(requestHeaders, connection, "Accept-Language");
|
||||
copyRequestHeader(requestHeaders, connection, "User-Agent");
|
||||
|
||||
int status = connection.getResponseCode();
|
||||
if (status >= 300 && status < 400) {
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (location == null || location.isBlank()) {
|
||||
connection.disconnect();
|
||||
return localError(502, "Bad Gateway", "Invalid redirect from the organizer.");
|
||||
}
|
||||
|
||||
Uri redirect = Uri.parse(new URL(new URL(uri.toString()), location).toString());
|
||||
connection.disconnect();
|
||||
if (!config.isAllowed(redirect)) {
|
||||
return localError(401, "Unauthorized", "Organizer access needs to be provisioned again.");
|
||||
}
|
||||
return fetchRequest(redirect, requestHeaders, config, mainFrame, redirectCount + 1);
|
||||
}
|
||||
|
||||
String contentType = connection.getContentType();
|
||||
String mimeType = mimeType(contentType, uri);
|
||||
String encoding = charset(contentType, mimeType);
|
||||
InputStream raw = status >= 400 ? connection.getErrorStream() : connection.getInputStream();
|
||||
if (raw == null) raw = new ByteArrayInputStream(new byte[0]);
|
||||
Map<String, String> responseHeaders = responseHeaders(connection.getHeaderFields());
|
||||
String reason = connection.getResponseMessage();
|
||||
if (reason == null || reason.isBlank()) reason = status >= 400 ? "Request Failed" : "OK";
|
||||
|
||||
if (mainFrame && "text/html".equalsIgnoreCase(mimeType)) {
|
||||
byte[] original = readLimited(raw, 2 * 1024 * 1024);
|
||||
raw.close();
|
||||
connection.disconnect();
|
||||
String html = new String(original, StandardCharsets.UTF_8);
|
||||
String viewport = "<meta name=\"viewport\" content=\"width=1920, initial-scale=1, user-scalable=no\">";
|
||||
String rewritten = html.replaceFirst(
|
||||
"(?i)<meta[^>]+name\\s*=\\s*[\\\"']viewport[\\\"'][^>]*>",
|
||||
viewport
|
||||
);
|
||||
if (rewritten.equals(html)) {
|
||||
rewritten = html.replaceFirst("(?i)<head[^>]*>", "$0" + viewport);
|
||||
}
|
||||
return new WebResourceResponse(
|
||||
mimeType,
|
||||
"UTF-8",
|
||||
status,
|
||||
reason,
|
||||
responseHeaders,
|
||||
new ByteArrayInputStream(rewritten.getBytes(StandardCharsets.UTF_8))
|
||||
);
|
||||
}
|
||||
|
||||
InputStream body = new DisconnectingInputStream(raw, connection);
|
||||
|
||||
return new WebResourceResponse(mimeType, encoding, status, reason, responseHeaders, body);
|
||||
} catch (IOException error) {
|
||||
if (connection != null) connection.disconnect();
|
||||
return localError(502, "Bad Gateway", "The organizer could not be reached.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyRequestHeader(Map<String, String> source, HttpURLConnection connection, String name) {
|
||||
if (source == null) return;
|
||||
for (Map.Entry<String, String> entry : source.entrySet()) {
|
||||
if (name.equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) {
|
||||
connection.setRequestProperty(name, entry.getValue());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> responseHeaders(Map<String, List<String>> source) {
|
||||
if (source == null) return Collections.emptyMap();
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> entry : source.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
if (name == null || entry.getValue() == null) continue;
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (lower.equals("content-length") || lower.equals("content-encoding")
|
||||
|| lower.equals("transfer-encoding") || lower.equals("connection")
|
||||
|| lower.equals("set-cookie")) continue;
|
||||
result.put(name, String.join(", ", entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String mimeType(String contentType, Uri uri) {
|
||||
if (contentType != null && !contentType.isBlank()) {
|
||||
return contentType.split(";", 2)[0].trim();
|
||||
}
|
||||
String path = uri.getPath() == null ? "" : uri.getPath().toLowerCase(Locale.ROOT);
|
||||
if (path.endsWith(".js") || path.endsWith(".mjs")) return "application/javascript";
|
||||
if (path.endsWith(".css")) return "text/css";
|
||||
if (path.endsWith(".json")) return "application/json";
|
||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (path.endsWith(".webp")) return "image/webp";
|
||||
if (path.endsWith(".woff2")) return "font/woff2";
|
||||
return "text/html";
|
||||
}
|
||||
|
||||
private static String charset(String contentType, String mimeType) {
|
||||
if (contentType != null) {
|
||||
for (String part : contentType.split(";")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.toLowerCase(Locale.ROOT).startsWith("charset=")) {
|
||||
return trimmed.substring(8).replace("\"", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return mimeType.startsWith("text/") || mimeType.contains("javascript") || mimeType.contains("json")
|
||||
? "UTF-8"
|
||||
: null;
|
||||
}
|
||||
|
||||
private static byte[] readLimited(InputStream input, int limit) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int total = 0;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > limit) throw new IOException("Response body too large");
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static WebResourceResponse localError(int status, String reason, String message) {
|
||||
byte[] body = message.getBytes(StandardCharsets.UTF_8);
|
||||
return new WebResourceResponse(
|
||||
"text/plain",
|
||||
"UTF-8",
|
||||
status,
|
||||
reason,
|
||||
Collections.singletonMap("Cache-Control", "no-store"),
|
||||
new ByteArrayInputStream(body)
|
||||
);
|
||||
}
|
||||
|
||||
private static final class DisconnectingInputStream extends FilterInputStream {
|
||||
private final HttpURLConnection connection;
|
||||
|
||||
DisconnectingInputStream(InputStream input, HttpURLConnection connection) {
|
||||
super(input);
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.dayline.tv;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
public final class ScheduleReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent == null ? null : intent.getAction();
|
||||
if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
|
||||
&& !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)
|
||||
&& !Intent.ACTION_TIME_CHANGED.equals(action)
|
||||
&& !Intent.ACTION_TIMEZONE_CHANGED.equals(action)) {
|
||||
return;
|
||||
}
|
||||
DaylineScheduler.scheduleNext(context);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?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="#08111E" android:pathData="M8,0 L100,0 Q108,0 108,8 L108,100 Q108,108 100,108 L8,108 Q0,108 0,100 L0,8 Q0,0 8,0" />
|
||||
<path android:fillColor="#22D3EE" android:pathData="M24,62 L36,62 L36,82 L24,82 Z" />
|
||||
<path android:fillColor="#A5F3FC" android:pathData="M48,26 L60,26 L60,82 L48,82 Z" />
|
||||
<path android:fillColor="#67E8F9" android:pathData="M72,43 L84,43 L84,82 L72,82 Z" />
|
||||
</vector>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?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" />
|
||||
<path android:fillColor="#22D3EE" android:pathData="M54,97 L70,97 L70,132 L54,132 Z" />
|
||||
<path android:fillColor="#A5F3FC" android:pathData="M82,53 L98,53 L98,132 L82,132 Z" />
|
||||
<path android:fillColor="#67E8F9" android:pathData="M110,74 L126,74 L126,132 L110,132 Z" />
|
||||
<path android:fillColor="#E2E8F0" android:pathData="M150,64 L160,64 L160,86 L180,86 L180,64 L190,64 L190,120 L180,120 L180,97 L160,97 L160,120 L150,120 Z" />
|
||||
<path android:fillColor="#E2E8F0" android:pathData="M203,64 L238,64 L238,75 L213,75 L213,87 L234,87 L234,98 L213,98 L213,120 L203,120 Z" />
|
||||
<path android:fillColor="#E2E8F0" android:pathData="M250,64 L280,64 Q290,64 290,74 L290,110 Q290,120 280,120 L250,120 Q240,120 240,110 L240,74 Q240,64 250,64 Z" />
|
||||
<path android:fillColor="#07101D" android:pathData="M254,75 L276,75 Q280,75 280,79 L280,105 Q280,109 276,109 L254,109 Q250,109 250,105 L250,79 Q250,75 254,75 Z" />
|
||||
<path android:fillColor="#67E8F9" android:pathData="M150,132 L290,132 L290,137 L150,137 Z" />
|
||||
</vector>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hobbs\' Family Organizer App</string>
|
||||
</resources>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.DaylineTV" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">#070B12</item>
|
||||
<item name="android:colorAccent">#67E8F9</item>
|
||||
<item name="android:navigationBarColor">#070B12</item>
|
||||
<item name="android:statusBarColor">#070B12</item>
|
||||
</style>
|
||||
</resources>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
+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>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.dayline.tv;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
public final class DashboardConfigTest {
|
||||
@Test
|
||||
public void acceptsPrivateLanHosts() {
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("10.0.0.10"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("172.16.0.1"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("172.31.255.254"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("192.168.86.50"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("organizer.home.arpa"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("organizer.local"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("organizer.lan"));
|
||||
assertTrue(DashboardConfig.isPrivateLanHost("fd12:3456::10"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPublicOrDeceptiveHosts() {
|
||||
assertFalse(DashboardConfig.isPrivateLanHost("8.8.8.8"));
|
||||
assertFalse(DashboardConfig.isPrivateLanHost("172.32.0.1"));
|
||||
assertFalse(DashboardConfig.isPrivateLanHost("192.168.86.999"));
|
||||
assertFalse(DashboardConfig.isPrivateLanHost("organizer.lan.example.com"));
|
||||
assertFalse(DashboardConfig.isPrivateLanHost("example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameOriginRequiresMatchingSchemeHostAndEffectivePort() throws Exception {
|
||||
URI expected = new URI("https://organizer.home.arpa/");
|
||||
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://ORGANIZER.home.arpa/api/weather")));
|
||||
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:443/assets/app.js")));
|
||||
assertFalse(DashboardConfig.sameOrigin(expected, new URI("http://organizer.home.arpa/")));
|
||||
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:8443/")));
|
||||
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://other.home.arpa/")));
|
||||
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://user@organizer.home.arpa/")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id "com.android.application" version "8.7.3" apply false
|
||||
}
|
||||
@@ -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 = "HobbsFamilyOrganizer"
|
||||
include(":app")
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"proxy.ts",
|
||||
".next-build/types/**/*.ts",
|
||||
".next-build/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"examples",
|
||||
"dist",
|
||||
".vinext",
|
||||
".wrangler",
|
||||
".sites-runtime",
|
||||
"outputs",
|
||||
"work",
|
||||
".openai",
|
||||
"db",
|
||||
"scripts",
|
||||
"worker",
|
||||
"cloudflare-env.d.ts",
|
||||
"vite.config.ts",
|
||||
"next.config.ts"
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 shadcn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
@theme inline {
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(
|
||||
--radix-accordion-content-height,
|
||||
var(--accordion-panel-height, auto)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(
|
||||
--radix-accordion-content-height,
|
||||
var(--accordion-panel-height, auto)
|
||||
);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom variants */
|
||||
@custom-variant data-open {
|
||||
&:where([data-state="open"]),
|
||||
&:where([data-open]:not([data-open="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-closed {
|
||||
&:where([data-state="closed"]),
|
||||
&:where([data-closed]:not([data-closed="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-checked {
|
||||
&:where([data-state="checked"]),
|
||||
&:where([data-checked]:not([data-checked="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-unchecked {
|
||||
&:where([data-state="unchecked"]),
|
||||
&:where([data-unchecked]:not([data-unchecked="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-selected {
|
||||
&:where([data-selected="true"]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-disabled {
|
||||
&:where([data-disabled="true"]),
|
||||
&:where([data-disabled]:not([data-disabled="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-active {
|
||||
&:where([data-state="active"]),
|
||||
&:where([data-active]:not([data-active="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-horizontal {
|
||||
&:where([data-orientation="horizontal"]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-vertical {
|
||||
&:where([data-orientation="vertical"]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* scroll-fade */
|
||||
@property --scroll-fade-t {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --scroll-fade-b {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --scroll-fade-s {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --scroll-fade-e {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --scroll-fade-mask {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes scroll-fade-reveal-t {
|
||||
from {
|
||||
--scroll-fade-t: 0px;
|
||||
}
|
||||
to {
|
||||
--scroll-fade-t: var(--_scroll-fade-size-t, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
}
|
||||
@keyframes scroll-fade-reveal-b {
|
||||
from {
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
to {
|
||||
--scroll-fade-b: 0px;
|
||||
}
|
||||
}
|
||||
@keyframes scroll-fade-reveal-s {
|
||||
from {
|
||||
--scroll-fade-s: 0px;
|
||||
}
|
||||
to {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
}
|
||||
@keyframes scroll-fade-reveal-e {
|
||||
from {
|
||||
--scroll-fade-e: var(--_scroll-fade-size-e, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
|
||||
}
|
||||
to {
|
||||
--scroll-fade-e: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade {
|
||||
--_scroll-fade-size-t: var(
|
||||
--scroll-fade-t-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--_scroll-fade-size-b: var(
|
||||
--scroll-fade-b-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-block: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-t, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-b, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-t 1ms ease-in-out,
|
||||
scroll-fade-reveal-b 1ms ease-in-out;
|
||||
animation-timeline: scroll(self y), scroll(self y);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-t: var(--_scroll-fade-size-t);
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-y {
|
||||
--_scroll-fade-size-t: var(
|
||||
--scroll-fade-t-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--_scroll-fade-size-b: var(
|
||||
--scroll-fade-b-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-block: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-t, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-b, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-t 1ms ease-in-out,
|
||||
scroll-fade-reveal-b 1ms ease-in-out;
|
||||
animation-timeline: scroll(self y), scroll(self y);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-t: var(--_scroll-fade-size-t);
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-x {
|
||||
--_scroll-fade-size-s: var(
|
||||
--scroll-fade-s-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--_scroll-fade-size-e: var(
|
||||
--scroll-fade-e-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-inline: linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
--scroll-fade-inline: linear-gradient(
|
||||
to left,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-s 1ms ease-in-out,
|
||||
scroll-fade-reveal-e 1ms ease-in-out;
|
||||
animation-timeline: scroll(self inline), scroll(self inline);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s);
|
||||
--scroll-fade-e: var(--_scroll-fade-size-e);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-t {
|
||||
--_scroll-fade-size-t: var(
|
||||
--scroll-fade-t-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-t, 0px),
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-t 1ms ease-in-out;
|
||||
animation-timeline: scroll(self y);
|
||||
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-t: var(--_scroll-fade-size-t);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-b {
|
||||
--_scroll-fade-size-b: var(
|
||||
--scroll-fade-b-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to bottom,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--scroll-fade-b, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-b 1ms ease-in-out;
|
||||
animation-timeline: scroll(self y);
|
||||
animation-range: calc(
|
||||
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
|
||||
)
|
||||
100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-b: var(--_scroll-fade-size-b);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-l {
|
||||
--_scroll-fade-size-s: var(
|
||||
--scroll-fade-s-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-s 1ms ease-in-out;
|
||||
animation-timeline: scroll(self x);
|
||||
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-r {
|
||||
--_scroll-fade-size-e: var(
|
||||
--scroll-fade-e-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to right,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-e 1ms ease-in-out;
|
||||
animation-timeline: scroll(self x);
|
||||
animation-range: calc(
|
||||
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
|
||||
)
|
||||
100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-e: var(--_scroll-fade-size-e);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-s {
|
||||
--_scroll-fade-size-s: var(
|
||||
--scroll-fade-s-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 100%
|
||||
);
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to left,
|
||||
transparent 0,
|
||||
#000 var(--scroll-fade-s, 0px),
|
||||
#000 100%
|
||||
);
|
||||
}
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-s 1ms ease-in-out;
|
||||
animation-timeline: scroll(self inline);
|
||||
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-s: var(--_scroll-fade-size-s);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-e {
|
||||
--_scroll-fade-size-e: var(
|
||||
--scroll-fade-e-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
|
||||
);
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to right,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
--scroll-fade-mask: linear-gradient(
|
||||
to left,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--scroll-fade-e, 0px)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
-webkit-mask-image: var(--scroll-fade-mask);
|
||||
mask-image: var(--scroll-fade-mask);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: scroll-fade-reveal-e 1ms ease-in-out;
|
||||
animation-timeline: scroll(self inline);
|
||||
animation-range: calc(
|
||||
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
|
||||
)
|
||||
100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
--scroll-fade-e: var(--_scroll-fade-size-e);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-* {
|
||||
--scroll-fade-size: calc(var(--spacing) * --value(integer));
|
||||
--scroll-fade-size: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility scroll-fade-t-* {
|
||||
--scroll-fade-t-size: calc(var(--spacing) * --value(integer));
|
||||
--scroll-fade-t-size: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility scroll-fade-b-* {
|
||||
--scroll-fade-b-size: calc(var(--spacing) * --value(integer));
|
||||
--scroll-fade-b-size: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility scroll-fade-s-* {
|
||||
--scroll-fade-s-size: calc(var(--spacing) * --value(integer));
|
||||
--scroll-fade-s-size: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility scroll-fade-e-* {
|
||||
--scroll-fade-e-size: calc(var(--spacing) * --value(integer));
|
||||
--scroll-fade-e-size: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility scroll-fade-none {
|
||||
--scroll-fade-mask: none;
|
||||
}
|
||||
|
||||
/* shimmer */
|
||||
@property --shimmer-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: true;
|
||||
initial-value: 20deg;
|
||||
}
|
||||
@property --shimmer-image {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --shimmer-text-fill {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes tw-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility shimmer {
|
||||
--_spread: var(--shimmer-spread, calc(3ch + 40px));
|
||||
--_base: currentColor;
|
||||
--_highlight: var(
|
||||
--shimmer-color,
|
||||
oklch(from currentColor l c h / calc(alpha* 0.2))
|
||||
);
|
||||
|
||||
background-image: var(
|
||||
--shimmer-image,
|
||||
linear-gradient(
|
||||
calc(90deg + var(--shimmer-angle)),
|
||||
var(--_base) calc(50% - var(--_spread)),
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%)
|
||||
calc(50% - var(--_spread) * 0.5),
|
||||
var(--_highlight) 50%,
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%)
|
||||
calc(50% + var(--_spread) * 0.5),
|
||||
var(--_base) calc(50% + var(--_spread))
|
||||
)
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(200% + var(--_spread) * 2) 100%;
|
||||
background-position: 0 0;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
|
||||
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||
|
||||
@variant dark {
|
||||
--_highlight: var(
|
||||
--shimmer-color,
|
||||
oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4))
|
||||
);
|
||||
}
|
||||
|
||||
&:where([dir="rtl"], [dir="rtl"] *) {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@utility shimmer-once {
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@utility shimmer-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@utility shimmer-none {
|
||||
--shimmer-image: none;
|
||||
--shimmer-text-fill: currentColor;
|
||||
}
|
||||
|
||||
@utility shimmer-color-* {
|
||||
--shimmer-color: --value(--color, [color]);
|
||||
--shimmer-color: color-mix(
|
||||
in oklch,
|
||||
--value(--color, [color]) calc(--modifier(integer) * 1%),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@utility shimmer-duration-* {
|
||||
--shimmer-duration: calc(--value(integer) * 1ms);
|
||||
}
|
||||
|
||||
@utility shimmer-spread-* {
|
||||
--shimmer-spread: calc(var(--spacing) * --value(integer));
|
||||
--shimmer-spread: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility shimmer-angle-* {
|
||||
--shimmer-angle: calc(--value(integer) * 1deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user