Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b935ff209c | ||
|
|
3fb5ddc5dd | ||
|
|
ca536b5c33 | ||
|
|
ec2f3930ad | ||
|
|
10cad3df17 | ||
|
|
77db935329 | ||
|
|
c6a34f7471 | ||
|
|
076e35aced | ||
|
|
8bf00c326d | ||
|
|
6a1ec8bb7b | ||
|
|
fe4673f1cd | ||
|
|
4ec1f34150 | ||
|
|
7938425850 | ||
|
|
13d94b3751 | ||
|
|
aae3e30843 | ||
|
|
23a14e1650 | ||
|
|
cce3bac5b2 | ||
|
|
31c5805b34 | ||
|
|
989bae8f1e | ||
|
|
090366422d | ||
|
|
4049a73c6b |
@@ -1,5 +1,5 @@
|
|||||||
# Public app URL (used for OIDC redirect URIs, share links, etc.)
|
# Public app URL (used for OIDC redirect URIs, share links, etc.)
|
||||||
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
|
NEXT_PUBLIC_APP_URL=https://fam.yourdomain.com
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
||||||
@@ -15,7 +15,7 @@ DEV_LOGIN_NAME=Dev User
|
|||||||
DEV_HOUSEHOLD_NAME=Home
|
DEV_HOUSEHOLD_NAME=Home
|
||||||
|
|
||||||
# OIDC (Authentik)
|
# OIDC (Authentik)
|
||||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
|
||||||
AUTH_OIDC_CLIENT_ID=replace-me
|
AUTH_OIDC_CLIENT_ID=replace-me
|
||||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,57 @@
|
|||||||
# ── famapp ────────────────────────────────────────────────────────────────────
|
# ── famapp ────────────────────────────────────────────────────────────────────
|
||||||
|
# These vars are consumed by the famapp service in docker-compose.yml.
|
||||||
|
|
||||||
# Image tag to deploy. Pin to a specific version after first deploy
|
# Image tag to deploy. Pin to a specific version after first deploy
|
||||||
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
|
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
|
||||||
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
|
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
|
||||||
# `always` pulls on every `up`; set `missing` if you want to skip pulls.
|
|
||||||
FAMAPP_PULL_POLICY=always
|
FAMAPP_PULL_POLICY=always
|
||||||
# Authentik image tag. Bump in lockstep with Authentik release notes.
|
|
||||||
AUTHENTIK_IMAGE_TAG=2024.12.3
|
AUTHENTIK_IMAGE_TAG=2024.12.3
|
||||||
# Run drizzle migrations on container start. Leave true.
|
|
||||||
RUN_MIGRATIONS=true
|
RUN_MIGRATIONS=true
|
||||||
|
|
||||||
# Public URL for the app — used by Auth.js for OIDC redirect URIs.
|
# Public URL for the app
|
||||||
AUTH_URL=https://fam.ginnoir.com
|
AUTH_URL=https://fam.yourdomain.com
|
||||||
|
|
||||||
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
|
# famapp Postgres
|
||||||
FAMAPP_DB_USER=famapp
|
FAMAPP_DB_USER=famapp
|
||||||
FAMAPP_DB_PASSWORD=replace-with-strong-password
|
FAMAPP_DB_PASSWORD=replace-with-strong-password
|
||||||
FAMAPP_DB_NAME=famapp
|
FAMAPP_DB_NAME=famapp
|
||||||
|
|
||||||
# Auth.js session secret — generate with: openssl rand -base64 32
|
# Auth.js session secret — generate with: openssl rand -base64 32
|
||||||
|
# NOTE: Reactive Resume uses RESUME_AUTH_SECRET; AUTH_SECRET is famapp-only.
|
||||||
|
|
||||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||||
|
|
||||||
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
|
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
|
||||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
|
||||||
AUTH_OIDC_CLIENT_ID=replace-me
|
AUTH_OIDC_CLIENT_ID=replace-me
|
||||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||||
|
|
||||||
# Web Push VAPID keys — generate with: pnpm vapid:generate (run from the repo)
|
# Web Push VAPID keys — generate with: pnpm vapid:generate
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
# Must be "mailto:<address>" or a URL
|
VAPID_SUBJECT=mailto:your-email@example.com
|
||||||
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
|
|
||||||
|
|
||||||
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
|
# ntfy push fallback
|
||||||
# Leave NTFY_URL blank to disable ntfy fanout.
|
NTFY_URL=https://ntfy.yourdomain.com
|
||||||
NTFY_URL=https://ntfy.ginnoir.com
|
|
||||||
NTFY_TOPIC=famapp
|
NTFY_TOPIC=famapp
|
||||||
|
|
||||||
# Log level: error | warn | info | debug
|
# Log level: error | warn | info | debug
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
# MinIO object storage (garden image uploads)
|
||||||
|
famapp_MINIO_ROOT_USER=famapp
|
||||||
|
famapp_MINIO_ROOT_PASSWORD=replace-with-strong-password
|
||||||
|
famapp_MINIO_BUCKET=garden
|
||||||
|
|
||||||
# Authentik Postgres credentials (separate DB per Matt's rule)
|
# OpenPlantBook API (optional — used for plant species lookup)
|
||||||
|
famapp_OPENPLANTBOOK_CLIENT_ID=
|
||||||
|
famapp_OPENPLANTBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||||
AUTHENTIK_DB_USER=authentik
|
AUTHENTIK_DB_USER=authentik
|
||||||
AUTHENTIK_DB_PASSWORD=replace-with-strong-password
|
AUTHENTIK_DB_PASSWORD=replace-with-strong-password
|
||||||
AUTHENTIK_DB_NAME=authentik
|
AUTHENTIK_DB_NAME=authentik
|
||||||
|
|
||||||
# Authentik secret key — generate with: openssl rand -base64 60
|
|
||||||
AUTHENTIK_SECRET_KEY=replace-with-openssl-rand-base64-60
|
AUTHENTIK_SECRET_KEY=replace-with-openssl-rand-base64-60
|
||||||
|
|
||||||
|
# ── Cloudflare (Caddy DNS-01 TLS) ─────────────────────────────────────────────
|
||||||
|
CF_API_TOKEN=replace-with-cloudflare-api-token
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -49,5 +49,13 @@ tests/.auth/
|
|||||||
# Backups
|
# Backups
|
||||||
deploy/backups/data/
|
deploy/backups/data/
|
||||||
|
|
||||||
|
# Production configs and deploy scripts — maintainer-specific, never commit
|
||||||
|
deploy/.prod/
|
||||||
|
deploy/compose.yaml
|
||||||
|
deploy/Caddyfile
|
||||||
|
deploy/Caddyfile.dev.snippet
|
||||||
|
scripts/apply-compose.ps1
|
||||||
|
scripts/sync-prod.ps1
|
||||||
|
|
||||||
# Design handoff bundle (reference only, not committed)
|
# Design handoff bundle (reference only, not committed)
|
||||||
.design-tmp/
|
.design-tmp/
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"release": true,
|
"release": true,
|
||||||
"releaseName": "v${version}"
|
"releaseName": "v${version}"
|
||||||
},
|
},
|
||||||
|
"hooks": {
|
||||||
|
"before:git:release": "echo 'Check: README.md reflects current modules and env vars before tagging'"
|
||||||
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"@release-it/conventional-changelog": {
|
"@release-it/conventional-changelog": {
|
||||||
"preset": {
|
"preset": {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
|||||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||||
|
|
||||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||||
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
|||||||
```yaml
|
```yaml
|
||||||
# compose.yaml (sketch — final lives in /deploy)
|
# compose.yaml (sketch — final lives in /deploy)
|
||||||
services:
|
services:
|
||||||
famapp: # node:22-alpine, Next.js
|
famapp: # node:24-alpine, Next.js
|
||||||
famapp-db: # postgres:16
|
famapp-db: # postgres:16
|
||||||
authentik-server:
|
authentik-server:
|
||||||
authentik-worker:
|
authentik-worker:
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
|
||||||
|
|
||||||
|
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **garden:** add share button to plant and container detail views ([989bae8](https://github.com/ginnoir/famapp/commit/989bae8f1e2d5577da3e8777b6a35320c6da274c))
|
||||||
|
- share links visible on plants/containers; container image gallery ([c6a34f7](https://github.com/ginnoir/famapp/commit/c6a34f74717505e39ac4a88077728efa9fc50478))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **deploy:** separate reactive resume auth_secret from famapp ([aae3e30](https://github.com/ginnoir/famapp/commit/aae3e30843f54406244b637073aaf5e9ccc6b1d3))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- add contributing guide, env reference, and operations runbook ([076e35a](https://github.com/ginnoir/famapp/commit/076e35aced9303973da71ddd0ebded13f6c98833))
|
||||||
|
- add professional readme with full feature coverage and release pipeline hook ([6a1ec8b](https://github.com/ginnoir/famapp/commit/6a1ec8bb7be7ce64e82e5a4cee6b9f4ef92c7af8))
|
||||||
|
- update decisions index and task index for garden module and adr backfill ([8bf00c3](https://github.com/ginnoir/famapp/commit/8bf00c326db591851bdf8eee2fcedbfbed53d076))
|
||||||
|
|
||||||
|
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **garden:** accept relative urls for plant image uploads ([6ca2e52](https://github.com/ginnoir/famapp/commit/6ca2e52e0a63d94826d9b1e208bcdded9c75fb07))
|
||||||
|
|
||||||
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
|
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
|||||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||||
|
|
||||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||||
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
|||||||
```yaml
|
```yaml
|
||||||
# compose.yaml (sketch — final lives in /deploy)
|
# compose.yaml (sketch — final lives in /deploy)
|
||||||
services:
|
services:
|
||||||
famapp: # node:22-alpine, Next.js
|
famapp: # node:24-alpine, Next.js
|
||||||
famapp-db: # postgres:16
|
famapp-db: # postgres:16
|
||||||
authentik-server:
|
authentik-server:
|
||||||
authentik-worker:
|
authentik-worker:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
|
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
|
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
@@ -20,7 +20,7 @@ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
|||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -1,8 +1,158 @@
|
|||||||
# famapp
|
# famapp
|
||||||
|
|
||||||
Self-hosted family coordination: shared calendar, lists, notes.
|
Self-hosted family coordination web app. Shared calendar, lists, notes, and garden tracker — installable as a PWA, protected by OIDC single sign-on.
|
||||||
|
|
||||||
- **Plan & architecture:** [`CLAUDE.md`](CLAUDE.md)
|
[](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
|
||||||
- **Current progress:** [`STATUS.md`](STATUS.md)
|
[](https://github.com/ginnoir/famapp/releases)
|
||||||
- **Task briefs (for sub-sessions):** [`docs/tasks/`](docs/tasks/)
|
[](https://github.com/ginnoir/famapp/pkgs/container/famapp)
|
||||||
- **Architecture decisions:** [`docs/decisions/`](docs/decisions/)
|
[](https://nodejs.org)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Modules
|
||||||
|
|
||||||
|
| Module | What it does |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------ |
|
||||||
|
| **Calendar** | Month / week / day views, drag-to-create, household and private calendars, event reminders |
|
||||||
|
| **Lists** | Shopping and task lists with real-time sync, reorder, keyboard-first item entry |
|
||||||
|
| **Notes** | Markdown notes with pinning, safe preview, and optional remind-at scheduling |
|
||||||
|
| **Garden** | Plant and container tracking, care schedules, care logs, species lookup via OpenPlantBook |
|
||||||
|
|
||||||
|
### Platform capabilities
|
||||||
|
|
||||||
|
| Capability | Detail |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| **Dashboard** | Per-user named dashboards, drag-resize widget grid, configurable widget instances |
|
||||||
|
| **Quick-add** | `Cmd/Ctrl+K` command palette and `+` FAB — every module registers its own actions |
|
||||||
|
| **Share links** | Any entity produces a temporary public link (`/s/<token>`), scoped read or read-write |
|
||||||
|
| **Activity log** | Household-scoped feed of every create / update / delete across all modules |
|
||||||
|
| **Reminders** | Generic reminder engine ticked every 30 s; notes and calendar events both use it |
|
||||||
|
| **Push notifications** | VAPID web push + in-app inbox + optional ntfy fallback |
|
||||||
|
| **Themes** | Two palettes (`default` / `warm`) × light / dark / system, zero flash on load |
|
||||||
|
| **PWA / offline** | Installable on iOS and Android, offline shell, stale-while-revalidate caching |
|
||||||
|
| **Structured logging** | JSON logs via pino in production, pretty-printed in dev, `LOG_LEVEL` configurable |
|
||||||
|
| **Nightly backups** | `pg_dump` cron for both databases, 14-day daily / 8-week weekly / 6-month monthly retention |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Docker** with the Compose plugin (v2)
|
||||||
|
- **Caddy** (or any reverse proxy that handles HTTPS)
|
||||||
|
- **Authentik** — famapp delegates all authentication to an Authentik OIDC provider; see the [bootstrap guide](deploy/authentik/README.md)
|
||||||
|
- A domain with DNS pointing to your host
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Full details are in [`deploy/README.md`](deploy/README.md). The short version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter the repo
|
||||||
|
git clone https://github.com/ginnoir/famapp.git /srv/famapp
|
||||||
|
cd /srv/famapp
|
||||||
|
|
||||||
|
# 2. Create your environment file
|
||||||
|
cp .env.production.example deploy/.env
|
||||||
|
# Edit deploy/.env — see Configuration below for required values
|
||||||
|
|
||||||
|
# 3. Configure your reverse proxy
|
||||||
|
# Add deploy/Caddyfile.snippet to your Caddyfile, then reload Caddy
|
||||||
|
|
||||||
|
# 4. Bootstrap Authentik and record the OIDC client id/secret in deploy/.env
|
||||||
|
# See deploy/authentik/README.md
|
||||||
|
|
||||||
|
# 5. Start the stack
|
||||||
|
docker compose -f deploy/compose.example.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The container runs database migrations automatically on start. The first user to sign in becomes the household owner; a second sign-in joins the same household as a member.
|
||||||
|
|
||||||
|
Pin `FAMAPP_IMAGE` in `deploy/.env` after the first deploy:
|
||||||
|
|
||||||
|
```
|
||||||
|
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.4.7
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
Requires Node ≥ 22 and pnpm 10.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ginnoir/famapp.git
|
||||||
|
cd famapp
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Copy the example env and fill in dev values (set ENABLE_DEV_LOGIN=true)
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Start the local database, run migrations, seed, and launch the dev server
|
||||||
|
pnpm dev:local
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000/login` and click **Dev login**.
|
||||||
|
|
||||||
|
Additional dev commands:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| --------------------- | ---------------------------------------------- |
|
||||||
|
| `pnpm dev:reset` | Drop and reseed the local database |
|
||||||
|
| `pnpm db:studio` | Open Drizzle Studio against the local database |
|
||||||
|
| `pnpm db:generate` | Generate a new migration after schema changes |
|
||||||
|
| `pnpm typecheck` | Run `tsc --noEmit` |
|
||||||
|
| `pnpm lint` | ESLint |
|
||||||
|
| `pnpm test:e2e` | Playwright E2E suite |
|
||||||
|
| `pnpm vapid:generate` | Print VAPID key env vars to stdout |
|
||||||
|
| `pnpm gen:icons` | Regenerate PWA icons from `public/icon.svg` |
|
||||||
|
|
||||||
|
See [`docs/dev-login.md`](docs/dev-login.md) for push notification and E2E testing setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.production.example` to `deploy/.env` and fill in values. Key variables:
|
||||||
|
|
||||||
|
| Variable | Required | How to generate |
|
||||||
|
| -------------------------------- | -------- | -------------------------------------------------------------------- |
|
||||||
|
| `AUTH_URL` | Yes | Public HTTPS URL for the app (e.g. `https://fam.yourdomain.com`) |
|
||||||
|
| `AUTH_SECRET` | Yes | `openssl rand -base64 32` |
|
||||||
|
| `AUTH_OIDC_ISSUER` | Yes | From Authentik — `https://auth.yourdomain.com/application/o/famapp/` |
|
||||||
|
| `AUTH_OIDC_CLIENT_ID` | Yes | From Authentik application |
|
||||||
|
| `AUTH_OIDC_CLIENT_SECRET` | Yes | From Authentik application |
|
||||||
|
| `FAMAPP_DB_PASSWORD` | Yes | Strong random password |
|
||||||
|
| `AUTHENTIK_DB_PASSWORD` | Yes | Strong random password |
|
||||||
|
| `AUTHENTIK_SECRET_KEY` | Yes | `openssl rand -base64 60` |
|
||||||
|
| `VAPID_PUBLIC_KEY` | Push | `pnpm vapid:generate` |
|
||||||
|
| `VAPID_PRIVATE_KEY` | Push | `pnpm vapid:generate` |
|
||||||
|
| `VAPID_SUBJECT` | Push | `mailto:your-email@example.com` |
|
||||||
|
| `NTFY_URL` / `NTFY_TOPIC` | Optional | ntfy push fallback |
|
||||||
|
| `famapp_OPENPLANTBOOK_CLIENT_ID` | Optional | OpenPlantBook API — plant species lookup |
|
||||||
|
| `LOG_LEVEL` | Optional | `error` / `warn` / `info` / `debug` (default: `info`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
famapp uses an extensibility-first module system: every feature lives under `src/modules/<name>/` and declares its tables, routes, dashboard widgets, quick-add actions, and share/reminder/search behaviors via a manifest. Core services (sharing, push, reminders, activity log, search) operate generically — adding a new module does not require touching core code.
|
||||||
|
|
||||||
|
See [`CLAUDE.md`](CLAUDE.md) for the full architecture brief and [`docs/decisions/`](docs/decisions/) for ADRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
| Resource | Link |
|
||||||
|
| -------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| Full host setup | [`deploy/README.md`](deploy/README.md) |
|
||||||
|
| Authentik bootstrap | [`deploy/authentik/README.md`](deploy/authentik/README.md) |
|
||||||
|
| Backup / restore | [`deploy/backups/README.md`](deploy/backups/README.md) |
|
||||||
|
| Pre-deploy checklist | [`docs/tasks/09-pre-deploy-checklist.md`](docs/tasks/09-pre-deploy-checklist.md) |
|
||||||
|
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
|
||||||
|
|
||||||
|
Cutting a release: tag `vX.Y.Z` on `main` and push — CI builds and pushes `ghcr.io/ginnoir/famapp:vX.Y.Z` automatically.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# famapp — paste into your existing Caddyfile.
|
# Add these blocks to your existing Caddyfile.
|
||||||
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
|
# Assumes famapp and authentik-server are reachable by Caddy
|
||||||
|
# (e.g. on a shared Docker network).
|
||||||
|
|
||||||
fam.ginnoir.com {
|
fam.yourdomain.com {
|
||||||
reverse_proxy 192.168.1.69:3010
|
reverse_proxy famapp:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.ginnoir.com {
|
auth.yourdomain.com {
|
||||||
reverse_proxy 192.168.1.69:9200
|
reverse_proxy authentik-server:9000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,30 @@
|
|||||||
|
|
||||||
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
|
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
|
||||||
|
|
||||||
|
## Files in this directory
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| `compose.example.yaml` | **Start here.** Standalone famapp + Authentik stack for new deployments. |
|
||||||
|
| `compose.yaml` | Maintainer's production compose (full homelab monolith — not a template). |
|
||||||
|
| `Caddyfile.snippet` | Reverse proxy blocks to add to your Caddyfile. |
|
||||||
|
| `Caddyfile.dev.snippet` | Dev machine proxy block (maintainer-specific). |
|
||||||
|
| `Caddyfile` | Maintainer's full production Caddyfile (not a template). |
|
||||||
|
| `authentik/README.md` | Authentik bootstrap guide. |
|
||||||
|
| `backups/` | Backup container scripts (used by `famapp-backup` in the example compose). |
|
||||||
|
|
||||||
## One-time host setup
|
## One-time host setup
|
||||||
|
|
||||||
1. Install Docker + Compose plugin on the host.
|
1. Install Docker + Compose plugin on the host.
|
||||||
2. `git clone` this repo to e.g. `/srv/famapp`.
|
2. `git clone` this repo to e.g. `/srv/famapp`.
|
||||||
3. Copy `.env.production.example` → `/srv/famapp/deploy/.env` and fill in real values.
|
3. Copy `deploy/compose.example.yaml` → `/srv/famapp/deploy/compose.yaml`.
|
||||||
|
4. Copy `.env.production.example` → `/srv/famapp/deploy/.env` and fill in real values.
|
||||||
- `openssl rand -base64 32` for `AUTH_SECRET`.
|
- `openssl rand -base64 32` for `AUTH_SECRET`.
|
||||||
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
|
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
|
||||||
- `pnpm vapid:generate` (locally) for the three VAPID lines.
|
- `openssl rand -hex 64` for the MinIO passwords.
|
||||||
4. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
|
- `pnpm vapid:generate` (locally, from the repo) for the three VAPID lines.
|
||||||
5. Wire Caddy with `deploy/Caddyfile.snippet`.
|
5. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
|
||||||
|
6. Wire Caddy (or any reverse proxy) using `deploy/Caddyfile.snippet`.
|
||||||
|
|
||||||
## Cutting a release
|
## Cutting a release
|
||||||
|
|
||||||
@@ -28,7 +42,7 @@ git push origin v0.1.0
|
|||||||
```bash
|
```bash
|
||||||
cd /srv/famapp/deploy
|
cd /srv/famapp/deploy
|
||||||
# pin to the tag you just cut
|
# pin to the tag you just cut
|
||||||
echo 'FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0' >> .env # or edit in place
|
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0|' .env
|
||||||
docker compose pull famapp
|
docker compose pull famapp
|
||||||
docker compose up -d famapp
|
docker compose up -d famapp
|
||||||
docker compose logs -f famapp # watch migrations + boot
|
docker compose logs -f famapp # watch migrations + boot
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ Run these steps once after the first `docker compose up -d` in the `deploy/` dir
|
|||||||
|
|
||||||
## 1. Set the admin password
|
## 1. Set the admin password
|
||||||
|
|
||||||
Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin** password.
|
Visit `https://auth.yourdomain.com/if/flow/initial-setup/` and set the **akadmin** password.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Create the OIDC provider
|
## 2. Create the OIDC provider
|
||||||
|
|
||||||
1. Log in to the Authentik Admin UI at `https://auth.ginnoir.com/if/admin/`.
|
1. Log in to the Authentik Admin UI at `https://auth.yourdomain.com/if/admin/`.
|
||||||
2. Go to **Applications → Providers → Create**.
|
2. Go to **Applications → Providers → Create**.
|
||||||
3. Choose **OAuth2/OpenID Provider**.
|
3. Choose **OAuth2/OpenID Provider**.
|
||||||
4. Configure:
|
4. Configure:
|
||||||
@@ -21,7 +21,7 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
|
|||||||
- **Client type:** `Confidential`
|
- **Client type:** `Confidential`
|
||||||
- **Client ID:** (auto-generated — copy this)
|
- **Client ID:** (auto-generated — copy this)
|
||||||
- **Client Secret:** (auto-generated — copy this)
|
- **Client Secret:** (auto-generated — copy this)
|
||||||
- **Redirect URIs:** `https://fam.ginnoir.com/api/auth/callback/authentik`
|
- **Redirect URIs:** `https://fam.yourdomain.com/api/auth/callback/authentik`
|
||||||
- **Signing Key:** `authentik Self-signed Certificate`
|
- **Signing Key:** `authentik Self-signed Certificate`
|
||||||
- **Token validity:** 24 hours (or your preference)
|
- **Token validity:** 24 hours (or your preference)
|
||||||
5. Save and note the **Issuer URL** shown on the provider detail page.
|
5. Save and note the **Issuer URL** shown on the provider detail page.
|
||||||
@@ -29,13 +29,13 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
|
|||||||
The issuer URL will look like:
|
The issuer URL will look like:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://auth.ginnoir.com/application/o/famapp/
|
https://auth.yourdomain.com/application/o/famapp/
|
||||||
```
|
```
|
||||||
|
|
||||||
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
|
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
|
||||||
AUTH_OIDC_CLIENT_ID=<client-id>
|
AUTH_OIDC_CLIENT_ID=<client-id>
|
||||||
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
```
|
```
|
||||||
@@ -49,7 +49,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
|||||||
- **Name:** `famapp`
|
- **Name:** `famapp`
|
||||||
- **Slug:** `famapp`
|
- **Slug:** `famapp`
|
||||||
- **Provider:** select the `famapp` provider created above
|
- **Provider:** select the `famapp` provider created above
|
||||||
- **Launch URL:** `https://fam.ginnoir.com`
|
- **Launch URL:** `https://fam.yourdomain.com`
|
||||||
3. Save.
|
3. Save.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,7 +68,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
|||||||
|
|
||||||
Each user can enroll a passkey from their Authentik profile:
|
Each user can enroll a passkey from their Authentik profile:
|
||||||
|
|
||||||
1. Sign in as the user at `https://auth.ginnoir.com`.
|
1. Sign in as the user at `https://auth.yourdomain.com`.
|
||||||
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
|
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
|
||||||
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
|
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Standalone famapp deployment template.
|
||||||
|
# Copy this to compose.yaml (or reference with -f), fill in .env from
|
||||||
|
# .env.production.example, then: docker compose up -d
|
||||||
|
#
|
||||||
|
# Caddy (or any reverse proxy) should sit in front — see Caddyfile.snippet.
|
||||||
|
# If Caddy runs in a separate compose stack, add famapp to that stack's
|
||||||
|
# external network instead of exposing ports directly.
|
||||||
|
|
||||||
|
name: famapp
|
||||||
|
|
||||||
|
networks:
|
||||||
|
famapp_net:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
famapp_db_data:
|
||||||
|
authentik_db_data:
|
||||||
|
authentik_redis_data:
|
||||||
|
garden_uploads:
|
||||||
|
backups:
|
||||||
|
|
||||||
|
services:
|
||||||
|
famapp:
|
||||||
|
image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest}
|
||||||
|
pull_policy: ${FAMAPP_PULL_POLICY:-always}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
AUTH_URL: ${AUTH_URL}
|
||||||
|
DATABASE_URL: postgres://${FAMAPP_DB_USER}:${FAMAPP_DB_PASSWORD}@famapp-db:5432/${FAMAPP_DB_NAME}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER}
|
||||||
|
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID}
|
||||||
|
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
|
NTFY_URL: ${NTFY_URL:-}
|
||||||
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||||
|
MINIO_ENDPOINT: http://famapp-minio:9000
|
||||||
|
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
|
||||||
|
MINIO_BUCKET: ${famapp_MINIO_BUCKET:-garden}
|
||||||
|
OPENPLANTBOOK_CLIENT_ID: ${famapp_OPENPLANTBOOK_CLIENT_ID:-}
|
||||||
|
OPENPLANTBOOK_CLIENT_SECRET: ${famapp_OPENPLANTBOOK_CLIENT_SECRET:-}
|
||||||
|
ports:
|
||||||
|
- "${FAMAPP_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
famapp-db:
|
||||||
|
condition: service_healthy
|
||||||
|
famapp-minio:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
famapp-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${FAMAPP_DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${FAMAPP_DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${FAMAPP_DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- famapp_db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${FAMAPP_DB_USER} -d ${FAMAPP_DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
famapp-minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- garden_uploads:/data
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
famapp-backup:
|
||||||
|
image: alpine:3.20
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
FAMAPP_DB_HOST: famapp-db
|
||||||
|
FAMAPP_DB_PORT: "5432"
|
||||||
|
FAMAPP_DB_USER: ${FAMAPP_DB_USER}
|
||||||
|
FAMAPP_DB_PASSWORD: ${FAMAPP_DB_PASSWORD}
|
||||||
|
FAMAPP_DB_NAME: ${FAMAPP_DB_NAME}
|
||||||
|
AUTHENTIK_DB_HOST: authentik-db
|
||||||
|
AUTHENTIK_DB_PORT: "5432"
|
||||||
|
AUTHENTIK_DB_USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||||
|
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
AUTHENTIK_DB_NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||||
|
volumes:
|
||||||
|
- backups:/backups
|
||||||
|
- ./backups:/scripts:ro
|
||||||
|
depends_on:
|
||||||
|
famapp-db:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
entrypoint: ["sh", "/scripts/entrypoint.sh"]
|
||||||
|
|
||||||
|
authentik-server:
|
||||||
|
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
ports:
|
||||||
|
- "9200:9000"
|
||||||
|
depends_on:
|
||||||
|
authentik-db:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
authentik-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
depends_on:
|
||||||
|
authentik-db:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
authentik-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||||
|
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${AUTHENTIK_DB_NAME:-authentik}
|
||||||
|
volumes:
|
||||||
|
- authentik_db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${AUTHENTIK_DB_USER:-authentik} -d ${AUTHENTIK_DB_NAME:-authentik}",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
|
|
||||||
|
authentik-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --save 60 1 --loglevel warning
|
||||||
|
volumes:
|
||||||
|
- authentik_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- famapp_net
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
|
||||||
echo "running migrations..."
|
|
||||||
node /app/scripts/migrate.mjs
|
|
||||||
else
|
|
||||||
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec node /app/server.js
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
|
||||||
|
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
|
||||||
|
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ginnoir/famapp.git
|
||||||
|
cd famapp
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — minimum required for local dev:
|
||||||
|
# NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
|
||||||
|
# DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
||||||
|
# ENABLE_DEV_LOGIN=true
|
||||||
|
# AUTH_SECRET=<any-32-char-string-for-local>
|
||||||
|
```
|
||||||
|
|
||||||
|
Start everything (database + migrations + seed + dev server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:local
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000/login` and click **Dev login**. See `docs/dev-login.md` for the full local setup including HTTPS/PWA testing.
|
||||||
|
|
||||||
|
## Available scripts
|
||||||
|
|
||||||
|
<!-- AUTO-GENERATED from package.json scripts -->
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `pnpm dev` | Next.js dev server (localhost only) |
|
||||||
|
| `pnpm dev:network` | Dev server bound to `0.0.0.0` (LAN access for phone testing) |
|
||||||
|
| `pnpm dev:local` | Full local stack — starts DB container, runs migrations, seeds, launches dev server |
|
||||||
|
| `pnpm dev:reset` | Tear down local DB and start fresh (destructive — deletes all local data) |
|
||||||
|
| `pnpm build` | Production Next.js build |
|
||||||
|
| `pnpm start` | Start the production build locally |
|
||||||
|
| `pnpm lint` | ESLint check |
|
||||||
|
| `pnpm lint:fix` | ESLint with auto-fix |
|
||||||
|
| `pnpm format` | Prettier — format all files |
|
||||||
|
| `pnpm format:check` | Prettier — check only (used in CI) |
|
||||||
|
| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) |
|
||||||
|
| `pnpm test:e2e` | Playwright end-to-end tests |
|
||||||
|
| `pnpm db:generate` | Generate a new Drizzle migration from schema changes |
|
||||||
|
| `pnpm db:migrate` | Apply pending Drizzle migrations |
|
||||||
|
| `pnpm db:seed` | Seed the database with dev fixtures |
|
||||||
|
| `pnpm db:studio` | Open Drizzle Studio (local DB browser) |
|
||||||
|
| `pnpm gen:icons` | Regenerate PWA icon set from source |
|
||||||
|
| `pnpm vapid:generate` | Generate VAPID key pair for Web Push |
|
||||||
|
| `pnpm release` | Interactive release (prompts for semver bump, tags, publishes changelog + GitHub Release) |
|
||||||
|
| `pnpm release:patch` | Non-interactive patch release |
|
||||||
|
| `pnpm release:minor` | Non-interactive minor release |
|
||||||
|
| `pnpm release:major` | Non-interactive major release |
|
||||||
|
| `pnpm release:dry` | Dry-run release — preview without writing |
|
||||||
|
|
||||||
|
<!-- END AUTO-GENERATED -->
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
### Type check + lint (CI equivalent)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
### End-to-end tests
|
||||||
|
|
||||||
|
The app must be running first (`pnpm dev:local`). Generate a Playwright auth state file, then run tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Generate auth state (run once after starting the app)
|
||||||
|
New-Item -ItemType Directory -Force tests\.auth | Out-Null
|
||||||
|
@'
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('http://127.0.0.1:3000/login');
|
||||||
|
await page.getByRole('button', { name: 'Dev login' }).click();
|
||||||
|
await page.waitForURL('http://127.0.0.1:3000/');
|
||||||
|
await page.context().storageState({ path: 'tests/.auth/dev-user.json' });
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
'@ | node -
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
$env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing tests
|
||||||
|
|
||||||
|
- Unit tests: Vitest under `tests/unit/` — only where it pays off (utilities, pure logic).
|
||||||
|
- E2E tests: Playwright under `tests/e2e/` — one happy-path test per module. Do not write brittle selector-heavy tests for trivial CRUD.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- **TypeScript strict** — no `any` without a written reason.
|
||||||
|
- **No comments** unless the _why_ is non-obvious. Names carry intent.
|
||||||
|
- **Immutable** — always return new objects; never mutate in place.
|
||||||
|
- **Module isolation** — a module imports from `_core` and `lib/` only; never from a sibling module.
|
||||||
|
- **File size** — 200–400 lines typical, 800 hard cap.
|
||||||
|
|
||||||
|
Formatting and lint run automatically on staged files via `lint-staged` at commit time. You can also run them manually with `pnpm lint:fix` and `pnpm format`.
|
||||||
|
|
||||||
|
## Commit format
|
||||||
|
|
||||||
|
Commits must follow [Conventional Commits](https://www.conventionalcommits.org/). `commitlint` enforces this at the `commit-msg` hook.
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <short description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add plant species search to garden module
|
||||||
|
fix: calendar event end time off by one day
|
||||||
|
chore: bump next to 15.6
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
Every feature lives under `src/modules/<name>/`. A new module needs:
|
||||||
|
|
||||||
|
- `schema.ts` — Drizzle tables
|
||||||
|
- `server/` — server actions and queries
|
||||||
|
- `components/` — React components
|
||||||
|
- `manifest.ts` — registers the module with the core registry (nav, entity types, dashboard widget, quick-add actions)
|
||||||
|
|
||||||
|
See `CLAUDE.md` for the full architectural brief. The module loader in `src/modules/_core/` discovers manifests automatically — no changes to core code required for a new module.
|
||||||
|
|
||||||
|
## Schema changes
|
||||||
|
|
||||||
|
1. Edit the relevant `schema.ts`.
|
||||||
|
2. Run `pnpm db:generate` to create a new migration file under `drizzle/`.
|
||||||
|
3. Commit the migration alongside the schema change.
|
||||||
|
4. Never edit a shipped migration — always add a new one.
|
||||||
|
|
||||||
|
## PR checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm typecheck` passes
|
||||||
|
- [ ] `pnpm lint` passes
|
||||||
|
- [ ] `pnpm build` succeeds
|
||||||
|
- [ ] New Drizzle migration committed if schema changed
|
||||||
|
- [ ] E2E test added or updated if a user-visible flow changed
|
||||||
|
- [ ] `docs/tasks/09-pre-deploy-checklist.md` reviewed if touching auth or env
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
<!-- AUTO-GENERATED from .env.example and deploy/compose.example.yaml -->
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in required values, then:
|
||||||
|
pnpm dev:local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core
|
||||||
|
|
||||||
|
| Variable | Required | Description | Example / Default |
|
||||||
|
| --------------------- | -------- | ------------------------------------------------------------------ | ------------------------------------------------ |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL of the app — used in OIDC redirect URIs and share links | `https://fam.yourdomain.com` |
|
||||||
|
| `DATABASE_URL` | Yes | Postgres connection string | `postgres://famapp:famapp@localhost:5432/famapp` |
|
||||||
|
| `AUTH_SECRET` | Yes | Auth.js signing secret — generate with `openssl rand -base64 32` | — |
|
||||||
|
|
||||||
|
## OIDC (Authentik)
|
||||||
|
|
||||||
|
Required in production. Obtain from the Authentik admin panel after bootstrapping the provider — see `deploy/authentik/README.md`.
|
||||||
|
|
||||||
|
| Variable | Required | Description | Example |
|
||||||
|
| ------------------------- | ---------- | --------------------------------- | --------------------------------------------------- |
|
||||||
|
| `AUTH_OIDC_ISSUER` | Yes (prod) | Authentik OIDC issuer URL | `https://auth.yourdomain.com/application/o/famapp/` |
|
||||||
|
| `AUTH_OIDC_CLIENT_ID` | Yes (prod) | OIDC client ID from Authentik | — |
|
||||||
|
| `AUTH_OIDC_CLIENT_SECRET` | Yes (prod) | OIDC client secret from Authentik | — |
|
||||||
|
|
||||||
|
## Dev login
|
||||||
|
|
||||||
|
Local development only. Never set `ENABLE_DEV_LOGIN=true` in production — a startup assertion in `src/lib/dev-login-config.ts` will crash the container if you do.
|
||||||
|
|
||||||
|
| Variable | Required | Description | Default |
|
||||||
|
| -------------------- | -------- | ------------------------------------------------- | ------------------ |
|
||||||
|
| `ENABLE_DEV_LOGIN` | No | Show a one-click **Dev login** button on `/login` | `false` |
|
||||||
|
| `DEV_LOGIN_EMAIL` | No | Email for the auto-created dev session | `dev@famapp.local` |
|
||||||
|
| `DEV_LOGIN_NAME` | No | Display name for the dev user | `Dev User` |
|
||||||
|
| `DEV_HOUSEHOLD_NAME` | No | Household created for the dev user | `Home` |
|
||||||
|
|
||||||
|
See `docs/dev-login.md` for the full local run procedure.
|
||||||
|
|
||||||
|
## Web Push (VAPID)
|
||||||
|
|
||||||
|
All three are required together. Generate them once with `pnpm vapid:generate` and copy all three lines into `.env`.
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ------------------- | -------- | --------------------------------------------- | ------------------------ |
|
||||||
|
| `VAPID_PUBLIC_KEY` | Yes | VAPID public key — also passed to the browser |
|
||||||
|
| `VAPID_PRIVATE_KEY` | Yes | VAPID private key — server only |
|
||||||
|
| `VAPID_SUBJECT` | Yes | Contact URI for push servers | `mailto:you@example.com` |
|
||||||
|
|
||||||
|
## ntfy (optional)
|
||||||
|
|
||||||
|
Leave both blank to disable the ntfy notification channel. Web Push is the primary channel.
|
||||||
|
|
||||||
|
| Variable | Required | Description | Example |
|
||||||
|
| ------------ | -------- | ------------------------ | ----------------- |
|
||||||
|
| `NTFY_URL` | No | ntfy server base URL | `https://ntfy.sh` |
|
||||||
|
| `NTFY_TOPIC` | No | ntfy topic to publish to | `famapp-alerts` |
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
| Variable | Required | Description | Values |
|
||||||
|
| ----------- | -------- | ------------------ | ----------------------------------------------------------- |
|
||||||
|
| `LOG_LEVEL` | No | Pino log verbosity | `trace`, `debug`, `info`, `warn`, `error` (default: `info`) |
|
||||||
|
|
||||||
|
## MinIO object storage
|
||||||
|
|
||||||
|
Required when the garden module is enabled. The dev compose stack starts a local MinIO instance automatically via `pnpm dev:local`.
|
||||||
|
|
||||||
|
| Variable | Required | Description | Default |
|
||||||
|
| --------------------- | -------- | ------------------------------------ | ----------------------- |
|
||||||
|
| `MINIO_ENDPOINT` | Yes | MinIO server base URL | `http://localhost:9000` |
|
||||||
|
| `MINIO_ROOT_USER` | Yes | MinIO root access key | `famapp` |
|
||||||
|
| `MINIO_ROOT_PASSWORD` | Yes | MinIO root secret key | `changeme` |
|
||||||
|
| `MINIO_BUCKET` | No | Bucket used for garden image uploads | `garden` |
|
||||||
|
|
||||||
|
## OpenPlantBook
|
||||||
|
|
||||||
|
Optional. Enables plant species lookup in the garden module. Free account at <https://open.plantbook.io>.
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ----------------------------- | -------- | -------------------- |
|
||||||
|
| `OPENPLANTBOOK_CLIENT_ID` | No | OAuth2 client ID |
|
||||||
|
| `OPENPLANTBOOK_CLIENT_SECRET` | No | OAuth2 client secret |
|
||||||
|
|
||||||
|
## Release tooling
|
||||||
|
|
||||||
|
Only needed on the machine that cuts releases (`pnpm release`).
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| -------------- | ------------- | ------------------------------------------------ |
|
||||||
|
| `GITHUB_TOKEN` | Yes (release) | Personal access token — creates a GitHub Release |
|
||||||
|
|
||||||
|
## Production-only compose variables
|
||||||
|
|
||||||
|
Used by `deploy/compose.example.yaml`. Set in `deploy/.env` on the server — not in the local `.env`.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `FAMAPP_IMAGE` | Docker image tag to deploy (e.g. `ghcr.io/ginnoir/famapp:v0.4.7`) |
|
||||||
|
| `FAMAPP_PORT` | Host port to bind (default: `3000`) |
|
||||||
|
| `FAMAPP_PULL_POLICY` | Docker pull policy (default: `always`) |
|
||||||
|
| `FAMAPP_DB_USER` / `FAMAPP_DB_PASSWORD` / `FAMAPP_DB_NAME` | Postgres credentials for the famapp database |
|
||||||
|
| `AUTHENTIK_DB_USER` / `AUTHENTIK_DB_PASSWORD` / `AUTHENTIK_DB_NAME` | Postgres credentials for the Authentik database |
|
||||||
|
| `AUTHENTIK_SECRET_KEY` | Authentik signing key — generate with `openssl rand -base64 60` |
|
||||||
|
| `AUTHENTIK_IMAGE_TAG` | Authentik server image tag (default: `2024.12.3`) |
|
||||||
|
| `RUN_MIGRATIONS` | Set `false` to skip auto-migration on start (default: `true`) |
|
||||||
|
|
||||||
|
<!-- END AUTO-GENERATED -->
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Runbook
|
||||||
|
|
||||||
|
Operational reference for famapp in production. See `deploy/README.md` for the one-time host setup and `docs/tasks/09-pre-deploy-checklist.md` for the pre-deploy checklist.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
From `main` on the dev machine, with a clean working tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm release:patch # or :minor / :major
|
||||||
|
# — bumps package.json version
|
||||||
|
# — prepends to CHANGELOG.md
|
||||||
|
# — creates and pushes a signed git tag (v0.x.y)
|
||||||
|
# — creates a GitHub Release with generated notes
|
||||||
|
# Requires GITHUB_TOKEN in .env
|
||||||
|
```
|
||||||
|
|
||||||
|
CI (`release.yml`) then builds and pushes the Docker image to GHCR:
|
||||||
|
|
||||||
|
- `ghcr.io/ginnoir/famapp:v0.x.y`
|
||||||
|
- `ghcr.io/ginnoir/famapp:0.x` (minor alias)
|
||||||
|
- `ghcr.io/ginnoir/famapp:latest`
|
||||||
|
|
||||||
|
## Deploying a release
|
||||||
|
|
||||||
|
On the home server, in `/srv/famapp/deploy/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pin the new tag
|
||||||
|
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.x.y|' .env
|
||||||
|
|
||||||
|
# Pull and restart only the app container
|
||||||
|
docker compose pull famapp
|
||||||
|
docker compose up -d famapp
|
||||||
|
|
||||||
|
# Watch the boot log — migrations run before the server starts
|
||||||
|
docker compose logs -f famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
The container entrypoint runs `node scripts/migrate.mjs` automatically. A healthy boot ends with a log line like `ready on http://0.0.0.0:3000`.
|
||||||
|
|
||||||
|
## Health check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# App response (200 = healthy)
|
||||||
|
curl -sf https://fam.yourdomain.com/ -o /dev/null -w "%{http_code}\n"
|
||||||
|
|
||||||
|
# Recent app logs
|
||||||
|
docker compose logs --tail=100 famapp
|
||||||
|
|
||||||
|
# Database connectivity
|
||||||
|
docker compose exec famapp-db pg_isready -U famapp -d famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. Find the previous working tag in `CHANGELOG.md` or `docker images`.
|
||||||
|
2. Pin it in `deploy/.env`:
|
||||||
|
```bash
|
||||||
|
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.x.y|' .env
|
||||||
|
```
|
||||||
|
3. Restart the container:
|
||||||
|
```bash
|
||||||
|
docker compose up -d famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
If the rollback target predates a migration that has already been applied to the database, restore from backup first — see **Backups** below. To skip auto-migration on a given start (rarely needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUN_MIGRATIONS=false docker compose up -d famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
The `famapp-backup` container runs nightly `pg_dump` for both `famapp` and `authentik` databases into the `backups` volume.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check last backup run
|
||||||
|
docker compose logs famapp-backup | tail -20
|
||||||
|
|
||||||
|
# List backup files
|
||||||
|
docker compose exec famapp-backup ls -lh /backups
|
||||||
|
|
||||||
|
# Manual backup now
|
||||||
|
docker compose exec famapp-backup sh /scripts/backup.sh
|
||||||
|
|
||||||
|
# Restore from a backup file
|
||||||
|
docker compose exec famapp-backup sh /scripts/restore.sh famapp_2026-06-01.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
See `deploy/backups/README.md` for retention policy and full restore details.
|
||||||
|
|
||||||
|
## Common issues
|
||||||
|
|
||||||
|
### App container exits immediately
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- **Missing required env var** — the app throws on startup if `AUTH_SECRET`, `DATABASE_URL`, or `AUTH_OIDC_*` are absent.
|
||||||
|
- **`ENABLE_DEV_LOGIN=true` in production** — `src/lib/dev-login-config.ts` throws an assertion error on import. Remove the variable from the production env.
|
||||||
|
- **Database not ready** — Postgres healthcheck should prevent this, but if the DB is slow to start, increase `start_period` in `compose.yaml`.
|
||||||
|
|
||||||
|
### Migrations fail on boot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs famapp | grep -i migration
|
||||||
|
```
|
||||||
|
|
||||||
|
- Schema is ahead of code: roll back the image or forward-migrate manually.
|
||||||
|
- Database unreachable: confirm `famapp-db` is healthy (`docker compose ps`).
|
||||||
|
|
||||||
|
### OIDC login fails
|
||||||
|
|
||||||
|
1. Confirm `AUTH_OIDC_ISSUER` matches the Authentik provider URL exactly (trailing slash matters).
|
||||||
|
2. Confirm the OIDC client redirect URI in Authentik includes `https://fam.yourdomain.com/api/auth/callback/oidc`.
|
||||||
|
3. Check Authentik logs: `docker compose logs authentik-server | tail -50`.
|
||||||
|
|
||||||
|
### Push notifications not arriving
|
||||||
|
|
||||||
|
1. Confirm `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` are all set in the production env.
|
||||||
|
2. Verify the browser's push subscription is still valid (Settings → notifications → re-enable).
|
||||||
|
3. If using ntfy as a secondary channel, confirm `NTFY_URL` and `NTFY_TOPIC` are set.
|
||||||
|
|
||||||
|
### MinIO / garden image uploads failing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs famapp-minio | tail -30
|
||||||
|
```
|
||||||
|
|
||||||
|
- Confirm `MINIO_ENDPOINT` is `http://famapp-minio:9000` in the compose env (not `localhost`).
|
||||||
|
- Confirm the `garden` bucket exists — create it manually via the MinIO console at port `9001` if it is missing.
|
||||||
|
|
||||||
|
### Out of disk space
|
||||||
|
|
||||||
|
```bash
|
||||||
|
df -h /var/lib/docker
|
||||||
|
docker system prune --volumes # removes stopped containers, dangling images, unused volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
Old backup files accumulate in the `backups` volume. The retention script (`deploy/backups/retain.sh`) runs nightly — check its logs if the volume keeps growing.
|
||||||
|
|
||||||
|
## Authentik admin access
|
||||||
|
|
||||||
|
The Authentik admin UI is at `https://auth.yourdomain.com`. Log in with the superuser credentials set during bootstrap — see `deploy/authentik/README.md`.
|
||||||
|
|
||||||
|
To reset the Authentik admin password from the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec authentik-server ak create_recovery_key 1 akadmin
|
||||||
|
# prints a one-time recovery URL
|
||||||
|
```
|
||||||
@@ -22,4 +22,8 @@ What this costs us, what it buys us.
|
|||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- (none yet — add as decisions are made)
|
<!-- AUTO-GENERATED -->
|
||||||
|
|
||||||
|
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
|
||||||
|
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
|
||||||
|
<!-- END AUTO-GENERATED -->
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ pnpm dev:local
|
|||||||
The script prints three URLs at startup:
|
The script prints three URLs at startup:
|
||||||
|
|
||||||
| URL | Use for |
|
| URL | Use for |
|
||||||
| -------------------------- | ---------------------------------------------------------- |
|
| ---------------------------- | ---------------------------------------------------------- |
|
||||||
| `http://localhost:3000` | Browser on this machine |
|
| `http://localhost:3000` | Browser on this machine |
|
||||||
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
|
| `http://192.168.x.y:3000` | Phone on the same WiFi (general UI testing) |
|
||||||
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
|
| `https://dev.yourdomain.com` | Push notifications + PWA install (needs Caddy — see below) |
|
||||||
|
|
||||||
Then open `/login` and click **Dev login**.
|
Then open `/login` and click **Dev login**.
|
||||||
|
|
||||||
@@ -74,11 +74,11 @@ Route through the existing Caddy server on the home server instead — no extra
|
|||||||
|
|
||||||
**Step 1 — DHCP reservation**
|
**Step 1 — DHCP reservation**
|
||||||
|
|
||||||
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
|
Set a reservation on the router so the dev machine always gets `192.168.x.y`.
|
||||||
|
|
||||||
**Step 2 — DNS record**
|
**Step 2 — DNS record**
|
||||||
|
|
||||||
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
|
Add a `dev.yourdomain.com` A record pointing to the same public IP as `fam.yourdomain.com`.
|
||||||
|
|
||||||
**Step 3 — Windows Firewall**
|
**Step 3 — Windows Firewall**
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
|
|||||||
caddy reload --config /path/to/Caddyfile
|
caddy reload --config /path/to/Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
|
After this, `https://dev.yourdomain.com` proxies to the dev machine with a real Let's Encrypt cert.
|
||||||
|
|
||||||
## Current Local E2E Procedure
|
## Current Local E2E Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
|
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
|
||||||
|
|
||||||
|
## README
|
||||||
|
|
||||||
|
- [ ] `README.md` reflects the current module list and any env vars added since the last release.
|
||||||
|
|
||||||
## Env hygiene
|
## Env hygiene
|
||||||
|
|
||||||
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
|
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Every task file has these sections:
|
|||||||
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
|
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
|
||||||
- [07 — Household seeding & session](07-household-seed.md)
|
- [07 — Household seeding & session](07-household-seed.md)
|
||||||
- [08 — Theming infrastructure (multi-theme + dark mode)](08-theming.md)
|
- [08 — Theming infrastructure (multi-theme + dark mode)](08-theming.md)
|
||||||
|
- [09 — Pre-deploy checklist (recurring)](09-pre-deploy-checklist.md)
|
||||||
|
|
||||||
### Phase 2 — Core modules
|
### Phase 2 — Core modules
|
||||||
|
|
||||||
@@ -69,3 +70,12 @@ Every task file has these sections:
|
|||||||
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
|
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
|
||||||
- [61 — Rate limiting on share links](61-rate-limit.md)
|
- [61 — Rate limiting on share links](61-rate-limit.md)
|
||||||
- [62 — Structured logging](62-logging.md)
|
- [62 — Structured logging](62-logging.md)
|
||||||
|
|
||||||
|
### Phase 8 — Garden module
|
||||||
|
|
||||||
|
- [70 — Garden infrastructure (MinIO + upload route + schema)](70-garden-infrastructure.md)
|
||||||
|
- [71 — Garden containers](71-garden-containers.md)
|
||||||
|
- [72 — Garden plants](72-garden-plants.md)
|
||||||
|
- [73 — Garden care tracking](73-garden-care-tracking.md)
|
||||||
|
- [74 — Garden integrations](74-garden-integrations.md)
|
||||||
|
- [75 — Garden dashboard](75-garden-dashboard.md)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||||
@@ -127,6 +127,13 @@
|
|||||||
"when": 1780391596552,
|
"when": 1780391596552,
|
||||||
"tag": "0017_bang_counter",
|
"tag": "0017_bang_counter",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748995200000,
|
||||||
|
"tag": "0018_container_images",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.4.5",
|
"version": "0.4.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@release-it/conventional-changelog": "^11.0.1",
|
"@release-it/conventional-changelog": "^11.0.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ importers:
|
|||||||
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3)
|
version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -89,7 +89,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
version: 21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
|
version: 21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
|
||||||
'@commitlint/config-conventional':
|
'@commitlint/config-conventional':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
version: 21.0.2
|
version: 21.0.2
|
||||||
@@ -101,7 +101,7 @@ importers:
|
|||||||
version: 1.59.1
|
version: 1.59.1
|
||||||
'@release-it/conventional-changelog':
|
'@release-it/conventional-changelog':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))
|
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -109,8 +109,8 @@ importers:
|
|||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0
|
version: 1.9.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.9.0
|
specifier: ^24.0.0
|
||||||
version: 22.19.17
|
version: 24.12.4
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.14
|
specifier: ^19.2.14
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -149,7 +149,7 @@ importers:
|
|||||||
version: 3.8.3
|
version: 3.8.3
|
||||||
release-it:
|
release-it:
|
||||||
specifier: ^20.2.0
|
specifier: ^20.2.0
|
||||||
version: 20.2.0(@types/node@22.19.17)
|
version: 20.2.0(@types/node@24.12.4)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -1862,8 +1862,8 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
'@types/node@24.12.4':
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
|
||||||
|
|
||||||
'@types/normalize-package-data@2.4.4':
|
'@types/normalize-package-data@2.4.4':
|
||||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||||
@@ -4895,8 +4895,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
undici@7.24.5:
|
undici@7.24.5:
|
||||||
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
||||||
@@ -5347,11 +5347,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@commitlint/cli@21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
|
'@commitlint/cli@21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@commitlint/format': 21.0.1
|
'@commitlint/format': 21.0.1
|
||||||
'@commitlint/lint': 21.0.2
|
'@commitlint/lint': 21.0.2
|
||||||
'@commitlint/load': 21.0.2(@types/node@22.19.17)(typescript@5.9.3)
|
'@commitlint/load': 21.0.2(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
@@ -5396,14 +5396,14 @@ snapshots:
|
|||||||
'@commitlint/rules': 21.0.2
|
'@commitlint/rules': 21.0.2
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
|
|
||||||
'@commitlint/load@21.0.2(@types/node@22.19.17)(typescript@5.9.3)':
|
'@commitlint/load@21.0.2(@types/node@24.12.4)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@commitlint/config-validator': 21.0.1
|
'@commitlint/config-validator': 21.0.1
|
||||||
'@commitlint/execute-rule': 21.0.1
|
'@commitlint/execute-rule': 21.0.1
|
||||||
'@commitlint/resolve-extends': 21.0.1
|
'@commitlint/resolve-extends': 21.0.1
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||||
cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
|
cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
|
||||||
es-toolkit: 1.47.0
|
es-toolkit: 1.47.0
|
||||||
is-plain-obj: 4.1.0
|
is-plain-obj: 4.1.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
@@ -5942,140 +5942,140 @@ snapshots:
|
|||||||
|
|
||||||
'@inquirer/ansi@2.0.7': {}
|
'@inquirer/ansi@2.0.7': {}
|
||||||
|
|
||||||
'@inquirer/checkbox@5.2.1(@types/node@22.19.17)':
|
'@inquirer/checkbox@5.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/confirm@6.0.12(@types/node@22.19.17)':
|
'@inquirer/confirm@6.0.12(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.1.9(@types/node@22.19.17)
|
'@inquirer/core': 11.1.9(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.5(@types/node@22.19.17)
|
'@inquirer/type': 4.0.5(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/core@11.1.9(@types/node@22.19.17)':
|
'@inquirer/core@11.1.9(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.5
|
'@inquirer/ansi': 2.0.5
|
||||||
'@inquirer/figures': 2.0.5
|
'@inquirer/figures': 2.0.5
|
||||||
'@inquirer/type': 4.0.5(@types/node@22.19.17)
|
'@inquirer/type': 4.0.5(@types/node@24.12.4)
|
||||||
cli-width: 4.1.0
|
cli-width: 4.1.0
|
||||||
fast-wrap-ansi: 0.2.0
|
fast-wrap-ansi: 0.2.0
|
||||||
mute-stream: 3.0.0
|
mute-stream: 3.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/core@11.2.1(@types/node@22.19.17)':
|
'@inquirer/core@11.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
cli-width: 4.1.0
|
cli-width: 4.1.0
|
||||||
fast-wrap-ansi: 0.2.0
|
fast-wrap-ansi: 0.2.0
|
||||||
mute-stream: 3.0.0
|
mute-stream: 3.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/editor@5.2.2(@types/node@22.19.17)':
|
'@inquirer/editor@5.2.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/external-editor': 3.0.3(@types/node@22.19.17)
|
'@inquirer/external-editor': 3.0.3(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/expand@5.1.1(@types/node@22.19.17)':
|
'@inquirer/expand@5.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/external-editor@3.0.3(@types/node@22.19.17)':
|
'@inquirer/external-editor@3.0.3(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
chardet: 2.1.1
|
chardet: 2.1.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/figures@2.0.5': {}
|
'@inquirer/figures@2.0.5': {}
|
||||||
|
|
||||||
'@inquirer/figures@2.0.7': {}
|
'@inquirer/figures@2.0.7': {}
|
||||||
|
|
||||||
'@inquirer/input@5.1.2(@types/node@22.19.17)':
|
'@inquirer/input@5.1.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/number@4.1.1(@types/node@22.19.17)':
|
'@inquirer/number@4.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/password@5.1.1(@types/node@22.19.17)':
|
'@inquirer/password@5.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/prompts@8.4.2(@types/node@22.19.17)':
|
'@inquirer/prompts@8.4.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/checkbox': 5.2.1(@types/node@22.19.17)
|
'@inquirer/checkbox': 5.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
|
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
|
||||||
'@inquirer/editor': 5.2.2(@types/node@22.19.17)
|
'@inquirer/editor': 5.2.2(@types/node@24.12.4)
|
||||||
'@inquirer/expand': 5.1.1(@types/node@22.19.17)
|
'@inquirer/expand': 5.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/input': 5.1.2(@types/node@22.19.17)
|
'@inquirer/input': 5.1.2(@types/node@24.12.4)
|
||||||
'@inquirer/number': 4.1.1(@types/node@22.19.17)
|
'@inquirer/number': 4.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/password': 5.1.1(@types/node@22.19.17)
|
'@inquirer/password': 5.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/rawlist': 5.3.1(@types/node@22.19.17)
|
'@inquirer/rawlist': 5.3.1(@types/node@24.12.4)
|
||||||
'@inquirer/search': 4.2.1(@types/node@22.19.17)
|
'@inquirer/search': 4.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/select': 5.2.1(@types/node@22.19.17)
|
'@inquirer/select': 5.2.1(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/rawlist@5.3.1(@types/node@22.19.17)':
|
'@inquirer/rawlist@5.3.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/search@4.2.1(@types/node@22.19.17)':
|
'@inquirer/search@4.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/select@5.2.1(@types/node@22.19.17)':
|
'@inquirer/select@5.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/type@4.0.5(@types/node@22.19.17)':
|
'@inquirer/type@4.0.5(@types/node@24.12.4)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/type@4.0.7(@types/node@22.19.17)':
|
'@inquirer/type@4.0.7(@types/node@24.12.4)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6431,7 +6431,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))':
|
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
||||||
concat-stream: 2.0.0
|
concat-stream: 2.0.0
|
||||||
@@ -6439,7 +6439,7 @@ snapshots:
|
|||||||
conventional-changelog-angular: 8.3.1
|
conventional-changelog-angular: 8.3.1
|
||||||
conventional-changelog-conventionalcommits: 9.3.1
|
conventional-changelog-conventionalcommits: 9.3.1
|
||||||
conventional-recommended-bump: 11.2.0
|
conventional-recommended-bump: 11.2.0
|
||||||
release-it: 20.2.0(@types/node@22.19.17)
|
release-it: 20.2.0(@types/node@24.12.4)
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- conventional-commits-filter
|
- conventional-commits-filter
|
||||||
@@ -6551,9 +6551,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
'@types/node@24.12.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 7.16.0
|
||||||
|
|
||||||
'@types/normalize-package-data@2.4.4': {}
|
'@types/normalize-package-data@2.4.4': {}
|
||||||
|
|
||||||
@@ -6571,7 +6571,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/set-cookie-parser@2.4.10':
|
'@types/set-cookie-parser@2.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
@@ -6579,7 +6579,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-push@3.6.4':
|
'@types/web-push@3.6.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7159,9 +7159,9 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
|
|
||||||
cosmiconfig-typescript-loader@6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
|
cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -8658,9 +8658,9 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
|
msw@2.14.3(@types/node@24.12.4)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
|
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
|
||||||
'@mswjs/interceptors': 0.41.8
|
'@mswjs/interceptors': 0.41.8
|
||||||
'@open-draft/deferred-promise': 3.0.0
|
'@open-draft/deferred-promise': 3.0.0
|
||||||
'@types/statuses': 2.0.6
|
'@types/statuses': 2.0.6
|
||||||
@@ -9233,9 +9233,9 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
release-it@20.2.0(@types/node@22.19.17):
|
release-it@20.2.0(@types/node@24.12.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/prompts': 8.4.2(@types/node@22.19.17)
|
'@inquirer/prompts': 8.4.2(@types/node@24.12.4)
|
||||||
'@octokit/rest': 22.0.1
|
'@octokit/rest': 22.0.1
|
||||||
'@phun-ky/typeof': 2.0.3
|
'@phun-ky/typeof': 2.0.3
|
||||||
async-retry: 1.3.3
|
async-retry: 1.3.3
|
||||||
@@ -9401,7 +9401,7 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shadcn@4.7.0(@types/node@22.19.17)(typescript@5.9.3):
|
shadcn@4.7.0(@types/node@24.12.4)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
@@ -9422,7 +9422,7 @@ snapshots:
|
|||||||
fuzzysort: 3.1.0
|
fuzzysort: 3.1.0
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
kleur: 4.1.5
|
kleur: 4.1.5
|
||||||
msw: 2.14.3(@types/node@22.19.17)(typescript@5.9.3)
|
msw: 2.14.3(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
node-fetch: 3.3.2
|
node-fetch: 3.3.2
|
||||||
open: 11.0.0
|
open: 11.0.0
|
||||||
ora: 8.2.0
|
ora: 8.2.0
|
||||||
@@ -9860,7 +9860,7 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@7.24.5: {}
|
undici@7.24.5: {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
|
||||||
|
</style>
|
||||||
|
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
|
||||||
|
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||||
|
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||||
|
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 547 B After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,12 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<!-- Background -->
|
<rect width="512" height="512" rx="96" fill="#4338CA"/>
|
||||||
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
|
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||||
<!-- House body -->
|
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||||
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
|
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||||
<!-- Door -->
|
|
||||||
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
|
|
||||||
<!-- Left window -->
|
|
||||||
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
|
||||||
<!-- Right window -->
|
|
||||||
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 345 B |
@@ -4,23 +4,26 @@
|
|||||||
"description": "Family coordination app",
|
"description": "Family coordination app",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#4338CA",
|
||||||
"theme_color": "#4F46E5",
|
"theme_color": "#4338CA",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-384.png",
|
"src": "/icon-384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512.png",
|
"src": "/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512-maskable.png",
|
"src": "/icon-512-maskable.png",
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function main() {
|
|||||||
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
console.log(` Local → http://localhost:3000`);
|
console.log(` Local → http://localhost:3000`);
|
||||||
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
|
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
|
||||||
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
|
console.log(` HTTPS → https://dev.<your-domain> (push/PWA — needs Caddyfile.dev.snippet)`);
|
||||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||||
|
|
||||||
// 6. Spawn Next.js dev server bound to all interfaces.
|
// 6. Spawn Next.js dev server bound to all interfaces.
|
||||||
|
|||||||
@@ -1,100 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Generates placeholder PNG icons for the famapp PWA.
|
* Rasterizes public/icon.svg into all required PNG icon sizes.
|
||||||
*
|
*
|
||||||
* Usage: node scripts/generate-icons.mjs
|
* Usage: node scripts/generate-icons.mjs
|
||||||
*
|
*
|
||||||
* Produces:
|
* Produces:
|
||||||
|
* public/icon-16.png
|
||||||
|
* public/icon-32.png
|
||||||
|
* public/icon-180.png (apple-touch-icon)
|
||||||
* public/icon-192.png
|
* public/icon-192.png
|
||||||
* public/icon-384.png
|
* public/icon-384.png
|
||||||
* public/icon-512.png
|
* public/icon-512.png
|
||||||
* public/icon-512-maskable.png (same image; OS applies its own mask)
|
* public/icon-512-maskable.png (square bg, content in safe zone)
|
||||||
* public/icon-180.png (apple-touch-icon)
|
|
||||||
*
|
|
||||||
* All images are solid indigo (#4F46E5) squares — replace with a real
|
|
||||||
* branded export from icon.svg when assets are finalised.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { deflateSync } from "zlib";
|
import sharp from "sharp";
|
||||||
import { writeFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { resolve, dirname } from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const publicDir = resolve(__dir, "../public");
|
const pub = join(__dirname, "..", "public");
|
||||||
|
|
||||||
// Build CRC-32 lookup table once.
|
const svg = readFileSync(join(pub, "icon.svg"));
|
||||||
const CRC_TABLE = new Uint32Array(256);
|
|
||||||
for (let i = 0; i < 256; i++) {
|
|
||||||
let c = i;
|
|
||||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
CRC_TABLE[i] = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function crc32(buf) {
|
const sizes = [
|
||||||
let crc = 0xffffffff;
|
{ name: "icon-16.png", size: 16 },
|
||||||
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
{ name: "icon-32.png", size: 32 },
|
||||||
return (crc ^ 0xffffffff) >>> 0;
|
{ name: "icon-180.png", size: 180 },
|
||||||
}
|
|
||||||
|
|
||||||
function u32(n) {
|
|
||||||
const b = Buffer.alloc(4);
|
|
||||||
b.writeUInt32BE(n, 0);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pngChunk(type, data) {
|
|
||||||
const typeBytes = Buffer.from(type, "ascii");
|
|
||||||
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
|
|
||||||
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
|
|
||||||
*/
|
|
||||||
function solidPNG(size, r, g, b) {
|
|
||||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
||||||
|
|
||||||
const ihdr = pngChunk(
|
|
||||||
"IHDR",
|
|
||||||
Buffer.concat([
|
|
||||||
u32(size),
|
|
||||||
u32(size),
|
|
||||||
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
|
|
||||||
const rowLen = 1 + size * 3;
|
|
||||||
const raw = Buffer.alloc(size * rowLen);
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
const base = y * rowLen;
|
|
||||||
raw[base] = 0;
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
raw[base + 1 + x * 3] = r;
|
|
||||||
raw[base + 2 + x * 3] = g;
|
|
||||||
raw[base + 3 + x * 3] = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const idat = pngChunk("IDAT", deflateSync(raw));
|
|
||||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
|
||||||
|
|
||||||
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indigo-600 (#4F46E5)
|
|
||||||
const [R, G, B] = [0x4f, 0x46, 0xe5];
|
|
||||||
|
|
||||||
const icons = [
|
|
||||||
{ name: "icon-192.png", size: 192 },
|
{ name: "icon-192.png", size: 192 },
|
||||||
{ name: "icon-384.png", size: 384 },
|
{ name: "icon-384.png", size: 384 },
|
||||||
{ name: "icon-512.png", size: 512 },
|
{ name: "icon-512.png", size: 512 },
|
||||||
{ name: "icon-512-maskable.png", size: 512 },
|
|
||||||
{ name: "icon-180.png", size: 180 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, size } of icons) {
|
for (const { name, size } of sizes) {
|
||||||
const out = resolve(publicDir, name);
|
await sharp(svg).resize(size, size).png().toFile(join(pub, name));
|
||||||
writeFileSync(out, solidPNG(size, R, G, B));
|
|
||||||
console.log(` ✓ public/${name} (${size}×${size})`);
|
console.log(` ✓ public/${name} (${size}×${size})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maskable: remove rounded corners so the background fills the full canvas,
|
||||||
|
// allowing the OS to apply any mask shape without clipping the icon corners.
|
||||||
|
const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', "");
|
||||||
|
|
||||||
|
await sharp(Buffer.from(maskableSvg))
|
||||||
|
.resize(512, 512)
|
||||||
|
.png()
|
||||||
|
.toFile(join(pub, "icon-512-maskable.png"));
|
||||||
|
console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`);
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import postgres from "postgres";
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
|
||||||
import { households } from "@/modules/_core/schema";
|
|
||||||
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
|
|
||||||
import { ensureDefaultLists } from "@/modules/lists/server/defaults";
|
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
|
||||||
const db = drizzle(client);
|
|
||||||
|
|
||||||
async function seed() {
|
|
||||||
const [existing] = await db.select().from(households).limit(1);
|
|
||||||
if (existing) {
|
|
||||||
console.log(`Household already exists ("${existing.name}"), skipping.`);
|
|
||||||
} else {
|
|
||||||
await db.insert(households).values({ name: "Home" });
|
|
||||||
console.log('Seeded household "Home".');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureDefaultCalendars();
|
|
||||||
console.log("Ensured default calendars.");
|
|
||||||
await ensureDefaultLists();
|
|
||||||
console.log("Ensured default lists.");
|
|
||||||
await client.end();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
seed().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getContainer } from "@/modules/garden/server/queries";
|
import { getContainer } from "@/modules/garden/server/queries";
|
||||||
import { ContainerDetail } from "@/modules/garden/components/container-detail";
|
import { ContainerDetail } from "@/modules/garden/components/container-detail";
|
||||||
|
import { getShareLinksForEntity } from "@/modules/_core/share";
|
||||||
|
|
||||||
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const container = await getContainer(id);
|
const [container, shareLinks] = await Promise.all([
|
||||||
|
getContainer(id),
|
||||||
|
getShareLinksForEntity("garden.container", id),
|
||||||
|
]);
|
||||||
if (!container) notFound();
|
if (!container) notFound();
|
||||||
return (
|
return (
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<ContainerDetail container={container} />
|
<ContainerDetail container={container} shareLinks={shareLinks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { notFound } from "next/navigation";
|
|||||||
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
|
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
|
||||||
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
|
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
|
||||||
import { PlantDetail } from "@/modules/garden/components/plant-detail";
|
import { PlantDetail } from "@/modules/garden/components/plant-detail";
|
||||||
|
import { getShareLinksForEntity } from "@/modules/_core/share";
|
||||||
|
|
||||||
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
|
const [plant, careLogs, careSchedules, calendars, shareLinks] = await Promise.all([
|
||||||
getPlant(id),
|
getPlant(id),
|
||||||
getCareLogs(id),
|
getCareLogs(id),
|
||||||
getCareSchedules(id),
|
getCareSchedules(id),
|
||||||
listCalendars(),
|
listCalendars(),
|
||||||
|
getShareLinksForEntity("garden.plant", id),
|
||||||
]);
|
]);
|
||||||
if (!plant) notFound();
|
if (!plant) notFound();
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
|
|||||||
careLogs={careLogs}
|
careLogs={careLogs}
|
||||||
careSchedules={careSchedules}
|
careSchedules={careSchedules}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
|
shareLinks={shareLinks}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ export const metadata: Metadata = {
|
|||||||
title: "famapp",
|
title: "famapp",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/icon-32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
{ url: "/icon-16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
],
|
||||||
apple: "/icon-180.png",
|
apple: "/icon-180.png",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
|
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
|
||||||
|
const iconSize = size === "sm" ? 14 : 17;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
f
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="12,2 1,12 23,12" />
|
||||||
|
<rect x="3.5" y="11" width="17" height="12" />
|
||||||
|
<rect x="9.5" y="16" width="5" height="7" rx="1" style={{ fill: "var(--ink)" }} />
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,21 @@ export function ShareButton({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
const result = await createShareLink(entityType, entityId, {
|
const result = await createShareLink(entityType, entityId, {
|
||||||
capabilities: { read: true, write: canWrite },
|
capabilities: { read: true, write: canWrite },
|
||||||
});
|
});
|
||||||
setShareUrl(result.url);
|
setShareUrl(result.url);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create share link");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +47,13 @@ export function ShareButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
<Button variant="outline" onClick={share} disabled={isPending}>
|
<Button variant="outline" onClick={share} disabled={isPending}>
|
||||||
<Link />
|
<Link />
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { revokeShareLink } from "@/modules/_core/share";
|
||||||
|
import type { EntityShareLink } from "@/modules/_core/share";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
links: EntityShareLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShareLinkList({ links }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (links.length === 0) return null;
|
||||||
|
|
||||||
|
function handleRevoke(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await revokeShareLink(id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 pt-1 border-t border-[var(--ink-faint)]">
|
||||||
|
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] tracking-wide">
|
||||||
|
Active share links ({links.length})
|
||||||
|
</p>
|
||||||
|
{links.map((link) => (
|
||||||
|
<div key={link.id} className="flex items-center justify-between gap-2 text-sm py-0.5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[var(--ink-mute)]">
|
||||||
|
Created {new Date(link.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{link.expiresAt && (
|
||||||
|
<span className="text-xs text-[var(--ink-mute)]">
|
||||||
|
Expires {new Date(link.expiresAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => handleRevoke(link.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { householdMembers, households, users } from "@/modules/_core/schema";
|
import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||||
@@ -13,7 +14,7 @@ export interface CurrentSession {
|
|||||||
|
|
||||||
export async function getCurrentSession(): Promise<CurrentSession> {
|
export async function getCurrentSession(): Promise<CurrentSession> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Not authenticated");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { consume } from "@/lib/rate-limit";
|
import { consume } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
|
const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"];
|
||||||
const PUBLIC_PATHS = new Set(["/login"]);
|
const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]);
|
||||||
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
||||||
|
|
||||||
// Token-prefix length used as part of the rate-limit bucket key.
|
// Token-prefix length used as part of the rate-limit bucket key.
|
||||||
|
|||||||
@@ -100,6 +100,48 @@ export async function revokeShareLink(id: string): Promise<void> {
|
|||||||
|
|
||||||
export type ActiveShareLink = typeof shareLinks.$inferSelect;
|
export type ActiveShareLink = typeof shareLinks.$inferSelect;
|
||||||
|
|
||||||
|
export type EntityShareLink = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
capabilities: ShareLinkCapabilities;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getShareLinksForEntity(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
): Promise<EntityShareLink[]> {
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: shareLinks.id,
|
||||||
|
createdAt: shareLinks.createdAt,
|
||||||
|
expiresAt: shareLinks.expiresAt,
|
||||||
|
capabilities: shareLinks.capabilities,
|
||||||
|
})
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shareLinks.householdId, household.id),
|
||||||
|
eq(shareLinks.entityType, entityType),
|
||||||
|
eq(shareLinks.entityId, entityId),
|
||||||
|
isNull(shareLinks.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(shareLinks.createdAt);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((r) => !r.expiresAt || r.expiresAt > now)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
expiresAt: r.expiresAt?.toISOString() ?? null,
|
||||||
|
capabilities: r.capabilities,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
|
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
|
||||||
const { household } = await getCurrentSession();
|
const { household } = await getCurrentSession();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -2,16 +2,32 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { deleteContainer } from "../server/actions";
|
import { ShareButton } from "@/components/share-button";
|
||||||
|
import { ShareLinkList } from "@/components/share-link-list";
|
||||||
|
import type { EntityShareLink } from "@/modules/_core/share";
|
||||||
|
import {
|
||||||
|
addContainerImage,
|
||||||
|
deleteContainer,
|
||||||
|
removeContainerImage,
|
||||||
|
setContainerPrimaryImage,
|
||||||
|
} from "../server/actions";
|
||||||
import { ContainerForm } from "./container-form";
|
import { ContainerForm } from "./container-form";
|
||||||
import type { ContainerDetailDto } from "../server/queries";
|
import type { ContainerDetailDto } from "../server/queries";
|
||||||
|
|
||||||
type Props = { container: ContainerDetailDto };
|
type Tab = "info" | "gallery";
|
||||||
|
|
||||||
export function ContainerDetail({ container }: Props) {
|
type Props = {
|
||||||
|
container: ContainerDetailDto;
|
||||||
|
shareLinks: EntityShareLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContainerDetail({ container, shareLinks }: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>("info");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [galleryError, setGalleryError] = useState<string | null>(null);
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@@ -22,6 +38,41 @@ export function ContainerDetail({ container }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setGalleryError(null);
|
||||||
|
setUploadingImage(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error("Upload failed");
|
||||||
|
const data = (await res.json()) as { url: string };
|
||||||
|
await addContainerImage({ id: container.id, url: data.url });
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setGalleryError("Image upload failed.");
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveImage(url: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeContainerImage({ id: container.id, url });
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetPrimary(url: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setContainerPrimaryImage({ id: container.id, url });
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{container.coverImageUrl && (
|
{container.coverImageUrl && (
|
||||||
@@ -38,13 +89,19 @@ export function ContainerDetail({ container }: Props) {
|
|||||||
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
|
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
|
||||||
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
|
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>
|
<div className="flex gap-2">
|
||||||
|
<ShareButton entityType="garden.container" entityId={container.id} />
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setEditing((v) => !v)}>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -61,6 +118,8 @@ export function ContainerDetail({ container }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ShareLinkList links={shareLinks} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -77,6 +136,25 @@ export function ContainerDetail({ container }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
|
||||||
|
{(["info", "gallery"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`pb-2 text-sm font-medium capitalize transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
|
||||||
|
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
{tab === "info" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
|
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
|
||||||
@@ -122,6 +200,67 @@ export function ContainerDetail({ container }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
{tab === "gallery" && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{container.images.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{container.images.map((url) => (
|
||||||
|
<div key={url} className="relative group">
|
||||||
|
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetPrimary(url)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Set as cover"
|
||||||
|
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveImage(url)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Remove"
|
||||||
|
className="text-white text-lg leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{url === container.coverImageUrl && (
|
||||||
|
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
|
||||||
|
Cover
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{container.images.length < 10 && (
|
||||||
|
<label className="btn btn-ghost btn-sm cursor-pointer">
|
||||||
|
{uploadingImage ? "Uploading…" : "Upload photo"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
disabled={uploadingImage}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-[var(--ink-mute)]">
|
||||||
|
{container.images.length}/10 photos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { useRouter } from "next/navigation";
|
|||||||
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
|
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
|
||||||
import type { CalendarDto } from "../server/calendar-bridge";
|
import type { CalendarDto } from "../server/calendar-bridge";
|
||||||
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
|
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
|
||||||
|
import { ShareButton } from "@/components/share-button";
|
||||||
|
import { ShareLinkList } from "@/components/share-link-list";
|
||||||
|
import type { EntityShareLink } from "@/modules/_core/share";
|
||||||
import { CareHistoryList } from "./care-history-list";
|
import { CareHistoryList } from "./care-history-list";
|
||||||
import { CareLogForm } from "./care-log-form";
|
import { CareLogForm } from "./care-log-form";
|
||||||
import { CareScheduleEditor } from "./care-schedule-editor";
|
import { CareScheduleEditor } from "./care-schedule-editor";
|
||||||
@@ -17,6 +20,7 @@ type Props = {
|
|||||||
careLogs: CareLogDto[];
|
careLogs: CareLogDto[];
|
||||||
careSchedules: CareScheduleDto[];
|
careSchedules: CareScheduleDto[];
|
||||||
calendars: CalendarDto[];
|
calendars: CalendarDto[];
|
||||||
|
shareLinks: EntityShareLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({
|
||||||
@@ -43,7 +47,7 @@ function healthBadgeClass(status: string): string {
|
|||||||
return "badge-warning";
|
return "badge-warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props) {
|
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>("info");
|
const [tab, setTab] = useState<Tab>("info");
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -122,13 +126,19 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<ShareButton entityType="garden.plant" entityId={plant.id} />
|
||||||
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
|
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -145,6 +155,8 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ShareLinkList links={shareLinks} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|||||||
@@ -114,9 +114,28 @@ const gardenManifest: ModuleManifest = {
|
|||||||
const d = data as ContainerShareData;
|
const d = data as ContainerShareData;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{d.coverImageUrl && (
|
||||||
|
<img
|
||||||
|
src={d.coverImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-h-48 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<h2 className="text-xl font-bold">{d.name}</h2>
|
<h2 className="text-xl font-bold">{d.name}</h2>
|
||||||
<p className="text-sm capitalize text-[var(--ink-mute)]">{d.type}</p>
|
<p className="text-sm capitalize text-[var(--ink-mute)]">{d.type}</p>
|
||||||
{d.locationNotes && <p className="text-sm">{d.locationNotes}</p>}
|
{d.locationNotes && <p className="text-sm">{d.locationNotes}</p>}
|
||||||
|
{d.images.length > 1 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||||
|
{d.images.slice(1).map((url) => (
|
||||||
|
<img
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="w-full aspect-square object-cover rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{d.plants.length > 0 && (
|
{d.plants.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-1">Plants</p>
|
<p className="text-sm font-medium mb-1">Plants</p>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const gardenContainers = pgTable(
|
|||||||
type: text("type").notNull().default("other"),
|
type: text("type").notNull().default("other"),
|
||||||
locationNotes: text("location_notes"),
|
locationNotes: text("location_notes"),
|
||||||
coverImageUrl: text("cover_image_url"),
|
coverImageUrl: text("cover_image_url"),
|
||||||
|
images: jsonb("images").notNull().default([]).$type<string[]>(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,6 +93,80 @@ export async function deleteContainer(input: { id: string }) {
|
|||||||
revalidatePath("/garden");
|
revalidatePath("/garden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addContainerImage(input: { id: string; url: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), url: z.string().min(1) }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessContainer(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ images: gardenContainers.images, coverImageUrl: gardenContainers.coverImageUrl })
|
||||||
|
.from(gardenContainers)
|
||||||
|
.where(eq(gardenContainers.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) throw new Error("Container not found");
|
||||||
|
if (row.images.length >= 10) throw new Error("Maximum 10 images allowed");
|
||||||
|
|
||||||
|
const newImages = [...row.images, parsed.url];
|
||||||
|
await db
|
||||||
|
.update(gardenContainers)
|
||||||
|
.set({
|
||||||
|
images: newImages,
|
||||||
|
coverImageUrl: row.images.length === 0 ? parsed.url : row.coverImageUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(gardenContainers.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath(`/garden/containers/${parsed.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeContainerImage(input: { id: string; url: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessContainer(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ images: gardenContainers.images, coverImageUrl: gardenContainers.coverImageUrl })
|
||||||
|
.from(gardenContainers)
|
||||||
|
.where(eq(gardenContainers.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) throw new Error("Container not found");
|
||||||
|
|
||||||
|
const newImages = row.images.filter((u) => u !== parsed.url);
|
||||||
|
const wasPrimary = row.coverImageUrl === parsed.url;
|
||||||
|
const newCover = wasPrimary ? (newImages[0] ?? null) : row.coverImageUrl;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(gardenContainers)
|
||||||
|
.set({ images: newImages, coverImageUrl: newCover, updatedAt: new Date() })
|
||||||
|
.where(eq(gardenContainers.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath(`/garden/containers/${parsed.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContainerPrimaryImage(input: { id: string; url: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessContainer(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ images: gardenContainers.images })
|
||||||
|
.from(gardenContainers)
|
||||||
|
.where(eq(gardenContainers.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) throw new Error("Container not found");
|
||||||
|
if (!row.images.includes(parsed.url)) throw new Error("Image not in container gallery");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(gardenContainers)
|
||||||
|
.set({ coverImageUrl: parsed.url, updatedAt: new Date() })
|
||||||
|
.where(eq(gardenContainers.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath(`/garden/containers/${parsed.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function assertCanAccessContainer(id: string, householdId: string) {
|
async function assertCanAccessContainer(id: string, householdId: string) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ id: gardenContainers.id })
|
.select({ id: gardenContainers.id })
|
||||||
@@ -118,8 +192,8 @@ const plantInput = z.object({
|
|||||||
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
|
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
|
||||||
notes: z.string().trim().max(2000).nullable().optional(),
|
notes: z.string().trim().max(2000).nullable().optional(),
|
||||||
acquisitionDate: z.string().nullable().optional(),
|
acquisitionDate: z.string().nullable().optional(),
|
||||||
images: z.array(z.string().url()).max(10).default([]),
|
images: z.array(z.string().min(1)).max(10).default([]),
|
||||||
primaryImageUrl: z.string().url().nullable().optional(),
|
primaryImageUrl: z.string().min(1).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createPlant(input: z.input<typeof plantInput>) {
|
export async function createPlant(input: z.input<typeof plantInput>) {
|
||||||
@@ -230,7 +304,7 @@ export async function deletePlant(input: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function addPlantImage(input: { id: string; url: string }) {
|
export async function addPlantImage(input: { id: string; url: string }) {
|
||||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().url() }).parse(input);
|
const parsed = z.object({ id: z.string().uuid(), url: z.string().min(1) }).parse(input);
|
||||||
const { household } = await getCurrentSession();
|
const { household } = await getCurrentSession();
|
||||||
await assertCanAccessPlant(parsed.id, household.id);
|
await assertCanAccessPlant(parsed.id, household.id);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ContainerDto = {
|
|||||||
type: string;
|
type: string;
|
||||||
locationNotes: string | null;
|
locationNotes: string | null;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
|
images: string[];
|
||||||
plantCount: number;
|
plantCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -39,6 +40,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
|
|||||||
type: gardenContainers.type,
|
type: gardenContainers.type,
|
||||||
locationNotes: gardenContainers.locationNotes,
|
locationNotes: gardenContainers.locationNotes,
|
||||||
coverImageUrl: gardenContainers.coverImageUrl,
|
coverImageUrl: gardenContainers.coverImageUrl,
|
||||||
|
images: gardenContainers.images,
|
||||||
createdAt: gardenContainers.createdAt,
|
createdAt: gardenContainers.createdAt,
|
||||||
updatedAt: gardenContainers.updatedAt,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
plantCount: sql<number>`count(${gardenPlants.id})::int`,
|
plantCount: sql<number>`count(${gardenPlants.id})::int`,
|
||||||
@@ -51,6 +53,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
|
|||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
images: r.images ?? [],
|
||||||
plantCount: r.plantCount ?? 0,
|
plantCount: r.plantCount ?? 0,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
@@ -68,6 +71,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
|
|||||||
type: gardenContainers.type,
|
type: gardenContainers.type,
|
||||||
locationNotes: gardenContainers.locationNotes,
|
locationNotes: gardenContainers.locationNotes,
|
||||||
coverImageUrl: gardenContainers.coverImageUrl,
|
coverImageUrl: gardenContainers.coverImageUrl,
|
||||||
|
images: gardenContainers.images,
|
||||||
createdAt: gardenContainers.createdAt,
|
createdAt: gardenContainers.createdAt,
|
||||||
updatedAt: gardenContainers.updatedAt,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
|
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
|
||||||
@@ -98,6 +102,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
|
|||||||
type: container.type,
|
type: container.type,
|
||||||
locationNotes: container.locationNotes,
|
locationNotes: container.locationNotes,
|
||||||
coverImageUrl: container.coverImageUrl,
|
coverImageUrl: container.coverImageUrl,
|
||||||
|
images: container.images ?? [],
|
||||||
plantCount: container.plantCount ?? 0,
|
plantCount: container.plantCount ?? 0,
|
||||||
createdAt: container.createdAt.toISOString(),
|
createdAt: container.createdAt.toISOString(),
|
||||||
updatedAt: container.updatedAt.toISOString(),
|
updatedAt: container.updatedAt.toISOString(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ContainerShareData = {
|
|||||||
type: string;
|
type: string;
|
||||||
locationNotes: string | null;
|
locationNotes: string | null;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
|
images: string[];
|
||||||
plants: { id: string; name: string; scientificName: string | null }[];
|
plants: { id: string; name: string; scientificName: string | null }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
|
|||||||
type: container.type,
|
type: container.type,
|
||||||
locationNotes: container.locationNotes,
|
locationNotes: container.locationNotes,
|
||||||
coverImageUrl: container.coverImageUrl,
|
coverImageUrl: container.coverImageUrl,
|
||||||
|
images: container.images ?? [],
|
||||||
plants,
|
plants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||