Compare commits

..
8 Commits
Author SHA1 Message Date
ginnoir 8921cb0444 chore: release v0.3.0
Release / build-and-push (push) Has been cancelled
2026-06-01 18:43:13 -05:00
ginnoir e3d2b5a364 chore: sync version to v0.2.0 and load .env for release scripts 2026-06-01 18:41:32 -05:00
ginnoir ed310d042f docs: add github_token to .env.example 2026-06-01 18:34:57 -05:00
ginnoir 30af9f63bf chore: add release workflow (commitlint, husky, lint-staged, release-it) 2026-06-01 18:33:37 -05:00
ginnoir e9bb2a5444 feat: expand theme system to 10 full-skin palettes with light and dark modes 2026-06-01 18:25:58 -05:00
ginnoir a95f10fcde fix: scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop 2026-06-01 18:00:49 -05:00
ginnoir b255aaeac1 fix: refresh server components after nav style change to fix broken layout 2026-06-01 17:53:21 -05:00
ginnoir 19308be768 feat: add dev startup script, fix push notifications, and scope test sends to device
- pnpm dev:local/dev:reset: orchestrate DB container, migrations, seed, and Next.js dev server in one command; Caddy snippet + docs for HTTPS via dev.ginnoir.com
- Fix dev login on HTTPS: set both authjs.session-token and __Secure-authjs.session-token so Auth.js finds the session regardless of cookie name resolution
- Suppress hydration mismatch on <html> caused by pre-paint script changing data-nav before React hydrates
- VAPID startup warning if keys not configured; remove dead NEXT_PUBLIC_VAPID_PUBLIC_KEY var
- PushOptIn: hydrate subscription state on mount; reuse existing subscription on iOS to avoid redundant prompts
- sendPushToEndpoint: new function to send to a single device endpoint
- sendTestNotification: scoped to the calling device's endpoint (ownership-verified) instead of all user subscriptions
2026-06-01 17:45:20 -05:00
25 changed files with 2460 additions and 74 deletions
+3 -1
View File
@@ -22,7 +22,6 @@ AUTH_OIDC_CLIENT_SECRET=replace-me
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:you@example.com
# ntfy (optional fallback channel; leave blank to disable)
@@ -31,3 +30,6 @@ NTFY_TOPIC=
# Logging
LOG_LEVEL=info
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
+1
View File
@@ -0,0 +1 @@
pnpm commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+33
View File
@@ -0,0 +1,33 @@
{
"npm": {
"publish": false
},
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}",
"requireBranch": "main"
},
"github": {
"release": true,
"releaseName": "v${version}"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": {
"name": "conventionalcommits",
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "chore", "section": "Maintenance", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true }
]
},
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to famapp are documented here.\n"
}
}
}
+25 -3
View File
@@ -1,10 +1,32 @@
# Changelog
One line per release. "What's in prod" = the highest tag listed under a date that's been deployed.
All notable changes to famapp are documented here.
Format: `## vX.Y.Z — YYYY-MM-DD`
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
## Unreleased
### Features
- add dev startup script, fix push notifications, and scope test sends to device ([0c12b05](https://github.com/ginnoir/famapp/commit/0c12b05d0989630b3ec1445f65048bd766d5f8d1))
- expand theme system to 10 full-skin palettes with light and dark modes ([c3c8ae3](https://github.com/ginnoir/famapp/commit/c3c8ae37a280206186dfde82ecb270aea5e0c512))
### Bug Fixes
- refresh server components after nav style change to fix broken layout ([f55d685](https://github.com/ginnoir/famapp/commit/f55d685a794a539d2d6256d97c1a9c4e5fa3b8bf))
- scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop ([671b5fa](https://github.com/ginnoir/famapp/commit/671b5fae469b3cfcafe08b20ee2ce8ac15eef994))
### Documentation
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
# Changelog
All notable changes to famapp are documented here.
<!-- release-it prepends new entries above this line -->
## Pre-release history
Changes from before the release workflow was established (2026-06-01):
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
+12
View File
@@ -0,0 +1,12 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "revert"],
],
"subject-case": [2, "always", "lower-case"],
"subject-max-length": [2, "always", 100],
},
};
+36
View File
@@ -0,0 +1,36 @@
# 0003 — Release workflow
Date: 2026-06-01
Status: accepted
## Context
The project needed a consistent, enforced standard for commit messages, changelog generation, versioning, and GitHub releases — especially important since both humans and AI agents (Claude Code, Codex) commit to the repo.
## Decision
- **commitlint** (`@commitlint/config-conventional`) enforces conventional commit format at the `commit-msg` git hook
- **husky** v9 wires the hooks; `prepare` installs them on `pnpm install`
- **lint-staged** runs prettier + eslint on staged files at the `pre-commit` hook
- **release-it** + `@release-it/conventional-changelog` manages the full release cycle:
- bumps `package.json` version (semver)
- generates/prepends to `CHANGELOG.md`
- creates a signed git tag (`v{version}`)
- creates a GitHub Release with the generated notes
- pushes the tag and commit
Release commands:
- `pnpm release` — interactive (prompts for increment type)
- `pnpm release:patch` / `:minor` / `:major` — non-interactive
- `pnpm release:dry` — preview without writing anything
- Requires `GITHUB_TOKEN` in env for GitHub Release creation
Conventional commit types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
## Consequences
- Every commit is validated; bad format is rejected immediately
- CHANGELOG.md is auto-generated from commit history — no manual upkeep
- Releases are reproducible: one command, idempotent output
- `chore`, `ci`, `test` commits are hidden in the changelog; `feat`, `fix`, `perf`, `refactor`, `docs` are surfaced
+57 -8
View File
@@ -34,20 +34,69 @@ This note tracks the development-only work added to make the app easy to run and
## Current Local Run Procedure
One command starts the database, runs migrations, seeds, and launches the dev server:
```powershell
docker compose -f docker-compose.dev.yaml up -d
pnpm db:migrate
pnpm db:seed
pnpm dev
pnpm dev:local
```
Then open:
The script prints three URLs at startup:
```text
http://127.0.0.1:3000/login
| URL | Use for |
|-----|---------|
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
Then open `/login` and click **Dev login**.
### HMR and restarts
`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart.
A full restart (`Ctrl+C``pnpm dev:local`) is needed for:
- `.env` changes
- `next.config.ts` changes
### Clean slate
To delete all local data and start fresh (e.g. after a destructive schema change):
```powershell
pnpm dev:reset
```
Click **Dev login**.
### HTTPS for push notification and PWA testing (one-time setup)
Service workers and Web Push require HTTPS. The plain LAN address won't work for these.
Route through the existing Caddy server on the home server instead — no extra tooling needed.
**Step 1 — DHCP reservation**
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
**Step 2 — DNS record**
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
**Step 3 — Windows Firewall**
Allow inbound TCP 3000 on the dev machine (run once in an elevated PowerShell):
```powershell
New-NetFirewallRule -DisplayName "famapp dev" -Direction Inbound `
-Protocol TCP -LocalPort 3000 -Action Allow
```
**Step 4 — Caddy snippet**
Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
```bash
caddy reload --config /path/to/Caddyfile
```
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
## Current Local E2E Procedure
+30 -2
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -9,6 +9,9 @@
},
"scripts": {
"dev": "next dev",
"dev:network": "next dev --hostname 0.0.0.0",
"dev:local": "node scripts/dev.mjs",
"dev:reset": "node scripts/dev-reset.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -22,24 +25,49 @@
"db:seed": "tsx --env-file=.env scripts/seed.ts",
"db:studio": "drizzle-kit studio",
"gen:icons": "node scripts/generate-icons.mjs",
"vapid:generate": "node scripts/vapid-generate.mjs"
"vapid:generate": "node scripts/vapid-generate.mjs",
"prepare": "husky",
"release": "dotenv -e .env -- release-it",
"release:patch": "dotenv -e .env -- release-it patch",
"release:minor": "dotenv -e .env -- release-it minor",
"release:major": "dotenv -e .env -- release-it major",
"release:dry": "dotenv -e .env -- release-it --dry-run"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,mjs,cjs}": [
"prettier --write"
],
"*.{json,md,css,yaml,yml}": [
"prettier --write"
]
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0",
"eslint-config-next": "^16.2.4",
"globals": "^15.12.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"release-it": "^20.2.0",
"tailwindcss": "^4.2.4",
"tsx": "^4.19.4",
"typescript": "^5.6.3",
+1747
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
/**
* Dev environment reset script.
*
* Tears down the local Postgres container and deletes its volume (all local
* data is lost), then performs a fresh startup: container up, migrate, seed,
* Next.js dev server.
*
* Use this when you want a completely clean local database — e.g. after a
* destructive schema change or to reproduce a fresh-install scenario.
*
* Usage: pnpm dev:reset
*/
import { execSync } from "child_process";
import { main } from "./dev.mjs";
console.log("\n⚠ Resetting dev environment — all local data will be deleted.");
execSync("docker compose -f docker-compose.dev.yaml down -v", {
stdio: "inherit",
shell: true,
});
console.log("\n▸ Restarting from scratch...");
await main();
+118
View File
@@ -0,0 +1,118 @@
/**
* Dev environment startup script.
*
* Starts the local Postgres container, waits for it to be ready, runs
* migrations + seed, then spawns `next dev` bound to 0.0.0.0 so the app
* is reachable from other devices on the LAN.
*
* Usage: pnpm dev:local
*/
import { execSync, spawn } from "child_process";
import net from "net";
import os from "os";
import { fileURLToPath } from "url";
/** Return the first LAN IPv4 address (prefers 192.168.x / 10.x ranges). */
function getLanIp() {
const all = Object.values(os.networkInterfaces())
.flat()
.filter((n) => n.family === "IPv4" && !n.internal)
.map((n) => n.address);
return (
all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ??
all[0] ??
"unknown"
);
}
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
function waitForTcp(host, port, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
function attempt() {
const sock = net.createConnection({ host, port });
sock.on("connect", () => {
sock.destroy();
resolve();
});
sock.on("error", () => {
sock.destroy();
if (Date.now() >= deadline) {
reject(
new Error(
`Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` +
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs"
)
);
} else {
setTimeout(attempt, 500);
}
});
}
attempt();
});
}
/** Run a shell command synchronously, streaming output to the terminal. */
function run(cmd) {
execSync(cmd, { stdio: "inherit", shell: true });
}
export async function main() {
// 1. Start DB container (idempotent — safe to call when already running)
console.log("\n▸ Starting database container...");
run("docker compose -f docker-compose.dev.yaml up -d");
// 2. Wait for Postgres to accept connections
process.stdout.write("▸ Waiting for Postgres");
const tick = setInterval(() => process.stdout.write("."), 500);
try {
await waitForTcp("127.0.0.1", 5432);
} finally {
clearInterval(tick);
process.stdout.write(" ready\n");
}
// 3. Apply any pending migrations (no-op if already current)
console.log("▸ Running migrations...");
run("pnpm db:migrate");
// 4. Seed default data (idempotent)
console.log("▸ Seeding...");
run("pnpm db:seed");
// 5. Print access URLs before the Next.js banner appears
const lanIp = getLanIp();
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(` Local → http://localhost:3000`);
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
// 6. Spawn Next.js dev server bound to all interfaces.
// HMR handles most code changes without a restart.
// Restart needed for: .env changes, next.config.ts changes.
// Pass command as a single string with no args array — shell handles .cmd
// resolution on Windows and the empty args avoids DEP0190.
const next = spawn("pnpm run dev:network", [], { stdio: "inherit", shell: true });
// Forward Ctrl+C / SIGTERM to the child so it shuts down cleanly.
const forward = (sig) => () => next.kill(sig);
process.on("SIGINT", forward("SIGINT"));
process.on("SIGTERM", forward("SIGTERM"));
await new Promise((resolve) => next.on("exit", (code) => resolve(code)));
process.exit(0);
}
// Run when invoked directly (not imported by dev-reset.mjs)
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
main().catch((err) => {
console.error("\ndev startup failed:", err.message);
process.exit(1);
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Dev script: send a test push notification to all subscriptions for the dev user.
* Usage: tsx --env-file=.env scripts/send-test-push.mts
*/
import webPush from "web-push";
import postgres from "postgres";
const subject = process.env["VAPID_SUBJECT"];
const publicKey = process.env["VAPID_PUBLIC_KEY"];
const privateKey = process.env["VAPID_PRIVATE_KEY"];
const databaseUrl = process.env["DATABASE_URL"];
if (!subject || !publicKey || !privateKey) {
console.error("VAPID env vars not set");
process.exit(1);
}
if (!databaseUrl) {
console.error("DATABASE_URL not set");
process.exit(1);
}
webPush.setVapidDetails(subject, publicKey, privateKey);
const sql = postgres(databaseUrl);
const subs = await sql`SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions`;
console.log(`Found ${subs.length} push subscription(s)`);
if (subs.length === 0) {
console.log("No subscriptions — enable push notifications in Settings first.");
await sql.end();
process.exit(0);
}
for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: "famapp test", body: "Push notifications are working!", url: "/settings" }),
);
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
} catch (err: unknown) {
const e = err as { statusCode?: number; message?: string };
console.error(`Failed (${e.statusCode ?? "?"}): ${e.message}`);
}
}
await sql.end();
-1
View File
@@ -3,4 +3,3 @@ import webPush from "web-push";
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
+178 -9
View File
@@ -205,33 +205,117 @@
--sans: var(--sans-inter, "Inter", system-ui, sans-serif);
}
/* ── Palettes — the only thing that varies is the accent + soft +
household-calendar color. Everything else stays paper-and-ink. */
/* ── Palettes — each theme sets surfaces, accent, and household color. */
[data-theme="clay"] {
--paper: #fbf9f4;
--paper-2: #f4f0e8;
--card: #ffffff;
--hair: rgba(90, 40, 20, 0.09);
--hair-2: rgba(90, 40, 20, 0.16);
--shade: rgba(90, 40, 20, 0.04);
--accent: #b85c3c;
--accent-soft: #ebd8ce;
--c-household: #b85c3c;
}
[data-theme="indigo"] {
--paper: #f4f7fb;
--paper-2: #eaeff7;
--card: #ffffff;
--hair: rgba(22, 40, 74, 0.09);
--hair-2: rgba(22, 40, 74, 0.16);
--shade: rgba(22, 40, 74, 0.04);
--accent: #3e5b8a;
--accent-soft: #d5dde8;
--c-household: #3e5b8a;
}
[data-theme="sage"] {
--paper: #f5f8f2;
--paper-2: #eaf0e4;
--card: #ffffff;
--hair: rgba(30, 55, 22, 0.09);
--hair-2: rgba(30, 55, 22, 0.16);
--shade: rgba(30, 55, 22, 0.04);
--accent: #6f8b5e;
--accent-soft: #dbe3d2;
--c-household: #6f8b5e;
}
[data-theme="plum"] {
--paper: #faf5fb;
--paper-2: #f1e8f2;
--card: #ffffff;
--hair: rgba(60, 25, 65, 0.09);
--hair-2: rgba(60, 25, 65, 0.16);
--shade: rgba(60, 25, 65, 0.04);
--accent: #7b4f6e;
--accent-soft: #e2d5dd;
--c-household: #7b4f6e;
}
[data-theme="ink"] {
--paper: #f8f7f5;
--paper-2: #edecea;
--card: #ffffff;
--hair: rgba(31, 27, 22, 0.09);
--hair-2: rgba(31, 27, 22, 0.16);
--shade: rgba(31, 27, 22, 0.04);
--accent: #1f1b16;
--accent-soft: #e8e4dc;
--c-household: #5c5145;
}
[data-theme="rose"] {
--paper: #fdf5f7;
--paper-2: #f7e8ec;
--card: #ffffff;
--hair: rgba(100, 20, 40, 0.09);
--hair-2: rgba(100, 20, 40, 0.16);
--shade: rgba(100, 20, 40, 0.04);
--accent: #b5485e;
--accent-soft: #f2d4da;
--c-household: #b5485e;
}
[data-theme="amber"] {
--paper: #fdf9f0;
--paper-2: #f5eed8;
--card: #ffffff;
--hair: rgba(90, 60, 5, 0.09);
--hair-2: rgba(90, 60, 5, 0.16);
--shade: rgba(90, 60, 5, 0.04);
--accent: #b07d1e;
--accent-soft: #f0e2b8;
--c-household: #b07d1e;
}
[data-theme="ocean"] {
--paper: #f2f9fb;
--paper-2: #e4f2f5;
--card: #ffffff;
--hair: rgba(10, 65, 80, 0.09);
--hair-2: rgba(10, 65, 80, 0.16);
--shade: rgba(10, 65, 80, 0.04);
--accent: #2e7a8a;
--accent-soft: #cce8ed;
--c-household: #2e7a8a;
}
[data-theme="slate"] {
--paper: #f4f6f9;
--paper-2: #e8ecf0;
--card: #ffffff;
--hair: rgba(20, 40, 60, 0.09);
--hair-2: rgba(20, 40, 60, 0.16);
--shade: rgba(20, 40, 60, 0.04);
--accent: #4a657a;
--accent-soft: #d0dae2;
--c-household: #4a657a;
}
[data-theme="forest"] {
--paper: #f2f7f3;
--paper-2: #e4eee6;
--card: #ffffff;
--hair: rgba(15, 55, 25, 0.09);
--hair-2: rgba(15, 55, 25, 0.16);
--shade: rgba(15, 55, 25, 0.04);
--accent: #3a6645;
--accent-soft: #c8dece;
--c-household: #3a6645;
}
/* ── Dark variant — every palette switches to deep ink surfaces.
Functional accents lift slightly so they stay readable. ───── */
@@ -277,7 +361,7 @@
--border: var(--hair);
--input: var(--hair-2);
--ring: var(--ink-soft);
--sidebar: #1a1712;
--sidebar: var(--paper-2);
--sidebar-foreground: var(--ink-2);
--sidebar-primary: var(--ink);
--sidebar-primary-foreground: var(--paper);
@@ -288,30 +372,115 @@
}
[data-theme="clay"].dark {
--paper: #16140f;
--paper-2: #1e1b16;
--card: #24201a;
--hair: rgba(244, 210, 180, 0.08);
--hair-2: rgba(244, 210, 180, 0.16);
--shade: rgba(244, 210, 180, 0.04);
--accent: #d88468;
--accent-soft: #3a2e26;
--c-household: #d88468;
}
[data-theme="indigo"].dark {
--paper: #0f1319;
--paper-2: #15202e;
--card: #1c2a3a;
--hair: rgba(184, 212, 244, 0.08);
--hair-2: rgba(184, 212, 244, 0.16);
--shade: rgba(184, 212, 244, 0.04);
--accent: #7e9dd0;
--accent-soft: #232c3c;
--accent-soft: #1a2540;
--c-household: #7e9dd0;
}
[data-theme="sage"].dark {
--paper: #0f1710;
--paper-2: #162018;
--card: #1c2a1e;
--hair: rgba(196, 228, 186, 0.08);
--hair-2: rgba(196, 228, 186, 0.16);
--shade: rgba(196, 228, 186, 0.04);
--accent: #a0bc8e;
--accent-soft: #25302a;
--accent-soft: #1e2e1c;
--c-household: #a0bc8e;
}
[data-theme="plum"].dark {
--paper: #160f1a;
--paper-2: #1e1522;
--card: #261a2e;
--hair: rgba(230, 180, 240, 0.08);
--hair-2: rgba(230, 180, 240, 0.16);
--shade: rgba(230, 180, 240, 0.04);
--accent: #be8bad;
--accent-soft: #321f30;
--accent-soft: #2a1630;
--c-household: #be8bad;
}
[data-theme="ink"].dark {
--paper: #0e0e0e;
--paper-2: #181818;
--card: #222222;
--hair: rgba(240, 240, 240, 0.08);
--hair-2: rgba(240, 240, 240, 0.16);
--shade: rgba(240, 240, 240, 0.04);
--accent: #f4efe5;
--accent-soft: #2a2620;
--c-household: #c4bbaa;
}
[data-theme="rose"].dark {
--paper: #1a0e10;
--paper-2: #221418;
--card: #2c1a1e;
--hair: rgba(248, 188, 200, 0.08);
--hair-2: rgba(248, 188, 200, 0.16);
--shade: rgba(248, 188, 200, 0.04);
--accent: #d87f94;
--accent-soft: #36181e;
--c-household: #d87f94;
}
[data-theme="amber"].dark {
--paper: #18130a;
--paper-2: #201a0e;
--card: #2a2214;
--hair: rgba(240, 210, 140, 0.08);
--hair-2: rgba(240, 210, 140, 0.16);
--shade: rgba(240, 210, 140, 0.04);
--accent: #d4a84a;
--accent-soft: #342410;
--c-household: #d4a84a;
}
[data-theme="ocean"].dark {
--paper: #091518;
--paper-2: #0f2028;
--card: #142c38;
--hair: rgba(170, 230, 240, 0.08);
--hair-2: rgba(170, 230, 240, 0.16);
--shade: rgba(170, 230, 240, 0.04);
--accent: #5ab8c8;
--accent-soft: #0f2c36;
--c-household: #5ab8c8;
}
[data-theme="slate"].dark {
--paper: #0e1420;
--paper-2: #151e2e;
--card: #1c2840;
--hair: rgba(160, 190, 218, 0.08);
--hair-2: rgba(160, 190, 218, 0.16);
--shade: rgba(160, 190, 218, 0.04);
--accent: #7ea8c4;
--accent-soft: #182032;
--c-household: #7ea8c4;
}
[data-theme="forest"].dark {
--paper: #0c1710;
--paper-2: #122018;
--card: #162c20;
--hair: rgba(152, 210, 170, 0.08);
--hair-2: rgba(152, 210, 170, 0.16);
--shade: rgba(152, 210, 170, 0.04);
--accent: #74b888;
--accent-soft: #142618;
--c-household: #74b888;
}
@layer base {
* {
@@ -471,14 +640,14 @@
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
}
:where(html[data-nav="rail"]) .nav-item {
:where(html[data-nav="rail"]) .sidebar .nav-item {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
}
:where(html[data-nav="rail"]) .nav-label,
:where(html[data-nav="rail"]) .nav-count {
:where(html[data-nav="rail"]) .sidebar .nav-label,
:where(html[data-nav="rail"]) .sidebar .nav-count {
display: none;
}
.sidebar-top .nav-item {
+2 -1
View File
@@ -69,7 +69,7 @@ const prePaintScript = `(function(){
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
var dataNav = navStyle === 'compact-rail' ? 'rail'
: navStyle === 'top-nav' ? 'top'
: navStyle === 'fab-only' ? 'fab' : 'sidebar';
: 'sidebar';
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
}
@@ -149,6 +149,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
data-density={density}
data-nav={initialDataNav}
className={cn(fontVars, isDark ? "dark" : "")}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
+10 -6
View File
@@ -2,7 +2,7 @@ import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
@@ -37,13 +37,17 @@ export default function LoginPage() {
<form
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
expires,
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
+10 -3
View File
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { pushSubscriptions } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
import { sendPush } from "@/modules/_core/push";
import { sendPushToEndpoint } from "@/modules/_core/push";
type PushSubscriptionJSON = {
endpoint: string;
@@ -35,9 +35,16 @@ export async function unsubscribeFromPush(endpoint: string): Promise<void> {
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
}
export async function sendTestNotification(): Promise<void> {
export async function sendTestNotification(endpoint: string): Promise<void> {
const { user } = await getCurrentSession();
await sendPush(user.id, {
// Verify the endpoint belongs to the current user before sending.
const [owned] = await db
.select({ id: pushSubscriptions.id })
.from(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
.limit(1);
if (!owned) return;
await sendPushToEndpoint(endpoint, {
title: "famapp test",
body: "Push notifications are working!",
url: "/settings",
+26 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useState, useTransition, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
subscribeToPush,
@@ -23,6 +23,20 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
const [isPending, startTransition] = useTransition();
const [testSent, setTestSent] = useState(false);
// Hydrate subscription state from the browser on mount so the UI reflects
// reality even when the user navigates away and returns to Settings.
useEffect(() => {
if (!vapidKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
navigator.serviceWorker.ready.then((reg) =>
reg.pushManager.getSubscription().then((existing) => {
if (existing) {
setEndpoint(existing.endpoint);
setStatus("subscribed");
}
}),
);
}, [vapidKey]);
if (!vapidKey) return null;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return (
@@ -35,10 +49,15 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
async function subscribe() {
try {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
// Reuse an existing browser subscription rather than calling subscribe()
// again — avoids redundant prompts and inconsistent behaviour on iOS.
const existing = await registration.pushManager.getSubscription();
const sub =
existing ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
}));
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
startTransition(async () => {
await subscribeToPush(json, navigator.userAgent);
@@ -63,8 +82,9 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
}
function sendTest() {
if (!endpoint) return;
startTransition(async () => {
await sendTestNotification();
await sendTestNotification(endpoint);
setTestSent(true);
setTimeout(() => setTestSent(false), 3000);
});
+33 -22
View File
@@ -67,28 +67,39 @@ export function ThemePicker({
return (
<div className="space-y-5">
<div className="space-y-1.5">
<Label>Palette</Label>
<div className="flex gap-2">
{PALETTES.map((p) => (
<button
key={p.id}
type="button"
onClick={() => t.setPalette(p.id)}
aria-pressed={t.palette === p.id}
title={p.label}
className={cn(
"size-7 rounded-md border-[0.5px] cursor-pointer transition-transform",
t.palette === p.id ? "scale-110" : "hover:scale-105",
)}
style={{
background: p.hex,
borderColor: "var(--hair-2)",
outline: t.palette === p.id ? "2px solid var(--ink)" : "none",
outlineOffset: 2,
}}
/>
))}
</div>
<Label>Theme</Label>
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
<SelectTrigger className="w-56">
<div className="flex items-center gap-2 min-w-0">
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${
PALETTES.find((p) => p.id === t.palette)?.paper ?? "#fff"
} 55%, ${PALETTES.find((p) => p.id === t.palette)?.hex ?? "#888"} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
+6 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type {
Palette,
ThemeMode,
@@ -67,6 +68,7 @@ export function useTheme(
const [dashLayout, setDashLayoutState] = useState<DashLayout>(initial.dashLayout ?? "classic");
const [calView, setCalViewState] = useState<CalView>(initial.calView ?? "month");
const [navStyle, setNavStyleState] = useState<NavStyle>(initial.navStyle ?? "rail-desktop");
const router = useRouter();
// Re-apply data-nav whenever the viewport crosses the mobile breakpoint.
useEffect(() => {
@@ -158,9 +160,11 @@ export function useTheme(
(next: NavStyle) => {
setNavStyleState(next);
applyTheme({ palette, mode, fontPair, density, navStyle: next });
persist({ navStyle: next });
if (signedIn) {
void setUserTheme({ navStyle: next }).then(() => router.refresh());
}
},
[palette, mode, fontPair, density, persist],
[palette, mode, fontPair, density, signedIn, router],
);
return {
+10
View File
@@ -2,5 +2,15 @@ export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startReminderWorker } = await import("@/modules/_core/reminders");
startReminderWorker();
const vapidSubject = process.env["VAPID_SUBJECT"];
const vapidPublic = process.env["VAPID_PUBLIC_KEY"];
const vapidPrivate = process.env["VAPID_PRIVATE_KEY"];
if (!vapidSubject || !vapidPublic || !vapidPrivate) {
console.warn(
"[famapp] VAPID keys not configured — web push notifications are disabled. " +
"Run `pnpm vapid:generate` and add VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY to .env",
);
}
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from
export { logActivity, logShareActivity } from "./activity";
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
export { sendPush } from "./push";
export { sendPush, sendPushToEndpoint } from "./push";
export { notify } from "./notify";
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
+26
View File
@@ -14,6 +14,32 @@ function ensureVapidConfigured() {
webPush.setVapidDetails(subject, publicKey, privateKey);
}
export async function sendPushToEndpoint(
endpoint: string,
payload: { title: string; body: string; url?: string },
) {
ensureVapidConfigured();
const [sub] = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, endpoint))
.limit(1);
if (!sub) return;
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
} else {
logger.error({ err }, "push delivery failed");
}
}
}
export async function sendPush(
userId: string,
payload: { title: string; body: string; url?: string },
+23 -9
View File
@@ -1,18 +1,33 @@
export type ThemeMode = "light" | "dark" | "system";
export type Palette = "clay" | "indigo" | "sage" | "plum" | "ink";
export type Palette =
| "clay"
| "indigo"
| "sage"
| "plum"
| "ink"
| "rose"
| "amber"
| "ocean"
| "slate"
| "forest";
export type FontPair = "serif-sans" | "newsreader" | "fraunces" | "sans-only";
export type Density = "compact" | "regular" | "comfy";
export type DashLayout = "classic" | "split" | "glance";
export type CalView = "month" | "week" | "day";
export type NavStyle = "rail-desktop" | "compact-rail" | "top-nav" | "fab-only";
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string }> = [
{ id: "clay", label: "Clay", hex: "#B85C3C" },
{ id: "indigo", label: "Indigo ink", hex: "#3E5B8A" },
{ id: "sage", label: "Sage", hex: "#6F8B5E" },
{ id: "plum", label: "Plum", hex: "#7B4F6E" },
{ id: "ink", label: "Ink (mono)", hex: "#1F1B16" },
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string; paper: string }> = [
{ id: "clay", label: "Clay", hex: "#B85C3C", paper: "#fbf9f4" },
{ id: "indigo", label: "Indigo", hex: "#3E5B8A", paper: "#f4f7fb" },
{ id: "sage", label: "Sage", hex: "#6F8B5E", paper: "#f5f8f2" },
{ id: "plum", label: "Plum", hex: "#7B4F6E", paper: "#faf5fb" },
{ id: "ink", label: "Ink", hex: "#1F1B16", paper: "#f8f7f5" },
{ id: "rose", label: "Rose", hex: "#B5485E", paper: "#fdf5f7" },
{ id: "amber", label: "Amber", hex: "#B07D1E", paper: "#fdf9f0" },
{ id: "ocean", label: "Ocean", hex: "#2E7A8A", paper: "#f2f9fb" },
{ id: "slate", label: "Slate", hex: "#4A657A", paper: "#f4f6f9" },
{ id: "forest", label: "Forest", hex: "#3A6645", paper: "#f2f7f3" },
];
export const FONT_PAIRS: ReadonlyArray<{ id: FontPair; label: string }> = [
@@ -83,14 +98,13 @@ export const DEFAULT_THEME: ThemeState = {
// Map our nav style preference to the data-nav attribute we set on <html>.
// On mobile (handled by NavModeProvider) we override to "bottom" or "fab".
// fab-only has no desktop equivalent — falls back to sidebar so the nav doesn't disappear.
export function navStyleToDataNav(style: NavStyle): "sidebar" | "rail" | "top" | "fab" {
switch (style) {
case "compact-rail":
return "rail";
case "top-nav":
return "top";
case "fab-only":
return "fab";
default:
return "sidebar";
}