Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298e6b868d | ||
|
|
6d45b7e836 | ||
|
|
7f2c5a44dd | ||
|
|
dd980c6932 | ||
|
|
6b25d67537 | ||
|
|
080d26816e | ||
|
|
0b0deff700 | ||
|
|
eabcebdb00 | ||
|
|
0fef3d4ab6 | ||
|
|
fe282ba470 | ||
|
|
f3e38c576c | ||
|
|
a86f5471ce | ||
|
|
5f6b756342 | ||
|
|
2fd0677c5f | ||
|
|
5dcd49d4c9 | ||
|
|
29795e0d51 | ||
|
|
8921cb0444 | ||
|
|
e3d2b5a364 | ||
|
|
ed310d042f | ||
|
|
30af9f63bf | ||
|
|
e9bb2a5444 | ||
|
|
a95f10fcde | ||
|
|
b255aaeac1 | ||
|
|
19308be768 | ||
|
|
9612a54e52 |
+12
-1
@@ -22,7 +22,6 @@ AUTH_OIDC_CLIENT_SECRET=replace-me
|
|||||||
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
|
||||||
VAPID_SUBJECT=mailto:you@example.com
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
# ntfy (optional fallback channel; leave blank to disable)
|
# ntfy (optional fallback channel; leave blank to disable)
|
||||||
@@ -31,3 +30,15 @@ NTFY_TOPIC=
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# GitHub (required for pnpm release — creates a GitHub Release)
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
|
||||||
|
# MinIO object storage (used for plant/container image uploads)
|
||||||
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
MINIO_ROOT_USER=famapp
|
||||||
|
MINIO_ROOT_PASSWORD=changeme
|
||||||
|
MINIO_BUCKET=garden
|
||||||
|
|
||||||
|
# Perenual plant species API (https://perenual.com — free tier available)
|
||||||
|
PERENUAL_API_KEY=
|
||||||
|
|||||||
@@ -48,3 +48,6 @@ tests/.auth/
|
|||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
deploy/backups/data/
|
deploy/backups/data/
|
||||||
|
|
||||||
|
# Design handoff bundle (reference only, not committed)
|
||||||
|
.design-tmp/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm commitlint --edit "$1"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm lint-staged
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"npm": {
|
||||||
|
"publish": false
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"commitMessage": "chore: release v${version}",
|
||||||
|
"tagName": "v${version}",
|
||||||
|
"requireBranch": "main"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"releaseName": "v${version}"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits",
|
||||||
|
"types": [
|
||||||
|
{ "type": "feat", "section": "Features" },
|
||||||
|
{ "type": "fix", "section": "Bug Fixes" },
|
||||||
|
{ "type": "perf", "section": "Performance" },
|
||||||
|
{ "type": "refactor", "section": "Refactoring" },
|
||||||
|
{ "type": "docs", "section": "Documentation" },
|
||||||
|
{ "type": "chore", "section": "Maintenance", "hidden": true },
|
||||||
|
{ "type": "ci", "section": "CI/CD", "hidden": true },
|
||||||
|
{ "type": "test", "section": "Tests", "hidden": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"infile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-3
@@ -1,10 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
One line per release. "What's in prod" = the highest tag listed under a date that's been deployed.
|
All notable changes to famapp are documented here.
|
||||||
|
|
||||||
Format: `## vX.Y.Z — YYYY-MM-DD`
|
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
|
||||||
|
|
||||||
## Unreleased
|
### Features
|
||||||
|
|
||||||
|
- add dev startup script, fix push notifications, and scope test sends to device ([0c12b05](https://github.com/ginnoir/famapp/commit/0c12b05d0989630b3ec1445f65048bd766d5f8d1))
|
||||||
|
- expand theme system to 10 full-skin palettes with light and dark modes ([c3c8ae3](https://github.com/ginnoir/famapp/commit/c3c8ae37a280206186dfde82ecb270aea5e0c512))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- refresh server components after nav style change to fix broken layout ([f55d685](https://github.com/ginnoir/famapp/commit/f55d685a794a539d2d6256d97c1a9c4e5fa3b8bf))
|
||||||
|
- scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop ([671b5fa](https://github.com/ginnoir/famapp/commit/671b5fae469b3cfcafe08b20ee2ce8ac15eef994))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
|
||||||
|
|
||||||
|
## Pre-release history
|
||||||
|
|
||||||
|
Changes from before the release workflow was established (2026-06-01):
|
||||||
|
|
||||||
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
||||||
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
rules: {
|
||||||
|
"type-enum": [
|
||||||
|
2,
|
||||||
|
"always",
|
||||||
|
["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "revert"],
|
||||||
|
],
|
||||||
|
"subject-case": [2, "always", "lower-case"],
|
||||||
|
"subject-max-length": [2, "always", 100],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,5 +11,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- famapp-db-data:/var/lib/postgresql/data
|
- famapp-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: famapp
|
||||||
|
MINIO_ROOT_PASSWORD: changeme
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- famapp-minio-data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
famapp-db-data:
|
famapp-db-data:
|
||||||
|
famapp-minio-data:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 0003 — Release workflow
|
||||||
|
|
||||||
|
Date: 2026-06-01
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project needed a consistent, enforced standard for commit messages, changelog generation, versioning, and GitHub releases — especially important since both humans and AI agents (Claude Code, Codex) commit to the repo.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **commitlint** (`@commitlint/config-conventional`) enforces conventional commit format at the `commit-msg` git hook
|
||||||
|
- **husky** v9 wires the hooks; `prepare` installs them on `pnpm install`
|
||||||
|
- **lint-staged** runs prettier + eslint on staged files at the `pre-commit` hook
|
||||||
|
- **release-it** + `@release-it/conventional-changelog` manages the full release cycle:
|
||||||
|
- bumps `package.json` version (semver)
|
||||||
|
- generates/prepends to `CHANGELOG.md`
|
||||||
|
- creates a signed git tag (`v{version}`)
|
||||||
|
- creates a GitHub Release with the generated notes
|
||||||
|
- pushes the tag and commit
|
||||||
|
|
||||||
|
Release commands:
|
||||||
|
|
||||||
|
- `pnpm release` — interactive (prompts for increment type)
|
||||||
|
- `pnpm release:patch` / `:minor` / `:major` — non-interactive
|
||||||
|
- `pnpm release:dry` — preview without writing anything
|
||||||
|
- Requires `GITHUB_TOKEN` in env for GitHub Release creation
|
||||||
|
|
||||||
|
Conventional commit types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every commit is validated; bad format is rejected immediately
|
||||||
|
- CHANGELOG.md is auto-generated from commit history — no manual upkeep
|
||||||
|
- Releases are reproducible: one command, idempotent output
|
||||||
|
- `chore`, `ci`, `test` commits are hidden in the changelog; `feat`, `fix`, `perf`, `refactor`, `docs` are surfaced
|
||||||
+58
-8
@@ -34,20 +34,70 @@ This note tracks the development-only work added to make the app easy to run and
|
|||||||
|
|
||||||
## Current Local Run Procedure
|
## Current Local Run Procedure
|
||||||
|
|
||||||
|
One command starts the database, runs migrations, seeds, and launches the dev server:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
pnpm dev:local
|
||||||
pnpm db:migrate
|
|
||||||
pnpm db:seed
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open:
|
The script prints three URLs at startup:
|
||||||
|
|
||||||
```text
|
| URL | Use for |
|
||||||
http://127.0.0.1:3000/login
|
| -------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `http://localhost:3000` | Browser on this machine |
|
||||||
|
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
|
||||||
|
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
|
||||||
|
|
||||||
|
Then open `/login` and click **Dev login**.
|
||||||
|
|
||||||
|
### HMR and restarts
|
||||||
|
|
||||||
|
`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart.
|
||||||
|
|
||||||
|
A full restart (`Ctrl+C` → `pnpm dev:local`) is needed for:
|
||||||
|
|
||||||
|
- `.env` changes
|
||||||
|
- `next.config.ts` changes
|
||||||
|
|
||||||
|
### Clean slate
|
||||||
|
|
||||||
|
To delete all local data and start fresh (e.g. after a destructive schema change):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm dev:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
Click **Dev login**.
|
### HTTPS for push notification and PWA testing (one-time setup)
|
||||||
|
|
||||||
|
Service workers and Web Push require HTTPS. The plain LAN address won't work for these.
|
||||||
|
Route through the existing Caddy server on the home server instead — no extra tooling needed.
|
||||||
|
|
||||||
|
**Step 1 — DHCP reservation**
|
||||||
|
|
||||||
|
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
|
||||||
|
|
||||||
|
**Step 2 — DNS record**
|
||||||
|
|
||||||
|
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
|
||||||
|
|
||||||
|
**Step 3 — Windows Firewall**
|
||||||
|
|
||||||
|
Allow inbound TCP 3000 on the dev machine (run once in an elevated PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "famapp dev" -Direction Inbound `
|
||||||
|
-Protocol TCP -LocalPort 3000 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 — Caddy snippet**
|
||||||
|
|
||||||
|
Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy reload --config /path/to/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
|
||||||
|
|
||||||
## Current Local E2E Procedure
|
## Current Local E2E Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 70 — Garden infrastructure (MinIO + upload route + schema)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration.
|
||||||
|
|
||||||
|
No UI or business logic — just the plumbing that every subsequent garden task depends on.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 03 (Drizzle + Postgres), 07 (household seed)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### compose.yaml additions (`deploy/compose.yaml`)
|
||||||
|
|
||||||
|
Add a `minio` service and a `garden-uploads` named volume:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- garden-uploads:/data
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API
|
||||||
|
- "9001:9001" # Console (dev only — restrict in prod)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
garden-uploads:
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`.
|
||||||
|
|
||||||
|
### MinIO client (`src/lib/minio.ts`)
|
||||||
|
|
||||||
|
Thin singleton using the `minio` npm package:
|
||||||
|
|
||||||
|
- Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`.
|
||||||
|
- Export `minioClient` and `MINIO_BUCKET` constant.
|
||||||
|
- On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`).
|
||||||
|
|
||||||
|
### Upload API route (`src/app/api/uploads/route.ts`)
|
||||||
|
|
||||||
|
- `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field.
|
||||||
|
- Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not).
|
||||||
|
- Generates a storage key: `garden/<householdId>/<randomUUID>.<ext>`.
|
||||||
|
- Streams to MinIO via `putObject`.
|
||||||
|
- Returns `{ url: "/api/uploads/<key>" }`.
|
||||||
|
- `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`.
|
||||||
|
|
||||||
|
This route is generic — not garden-specific. Other modules can reuse it.
|
||||||
|
|
||||||
|
### Schema (`src/modules/garden/schema.ts`)
|
||||||
|
|
||||||
|
Four tables, all `household_id`-scoped:
|
||||||
|
|
||||||
|
**`garden_containers`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `name` text not null
|
||||||
|
- `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'`
|
||||||
|
- `location_notes` text nullable
|
||||||
|
- `cover_image_url` text nullable
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Index on `household_id`.
|
||||||
|
|
||||||
|
**`garden_plants`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `container_id` uuid nullable fk `garden_containers` set-null on delete
|
||||||
|
- `name` text not null
|
||||||
|
- `scientific_name` text nullable
|
||||||
|
- `species_id` text nullable — external Perenual species ID, stored as string
|
||||||
|
- `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'`
|
||||||
|
- `notes` text nullable
|
||||||
|
- `acquisition_date` date nullable
|
||||||
|
- `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'`
|
||||||
|
- `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'`
|
||||||
|
- `sunlight` text nullable
|
||||||
|
- `watering_notes` text nullable
|
||||||
|
- `fertilizing_notes` text nullable
|
||||||
|
- `primary_image_url` text nullable
|
||||||
|
- `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Indexes on `(household_id)`, `(household_id, container_id)`.
|
||||||
|
|
||||||
|
**`garden_care_logs`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `plant_id` uuid fk `garden_plants` cascade
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'`
|
||||||
|
- `performed_by` uuid fk users set-null on delete
|
||||||
|
- `notes` text nullable
|
||||||
|
- `performed_at` timestamptz not null default now()
|
||||||
|
- `created_at` timestamptz
|
||||||
|
|
||||||
|
Indexes on `(plant_id, performed_at desc)`, `(household_id)`.
|
||||||
|
|
||||||
|
**`garden_care_schedules`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `plant_id` uuid fk `garden_plants` cascade
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `care_type` text not null
|
||||||
|
- `interval_days` int not null
|
||||||
|
- `last_performed_at` timestamptz nullable
|
||||||
|
- `next_due_at` timestamptz nullable — recomputed after every care log entry
|
||||||
|
- `enabled` boolean not null default true
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Unique index on `(plant_id, care_type)` — one schedule per plant per care type.
|
||||||
|
Index on `(household_id, next_due_at)` for dashboard queries.
|
||||||
|
|
||||||
|
### Drizzle migration
|
||||||
|
|
||||||
|
Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply.
|
||||||
|
|
||||||
|
### Module scaffold (`src/modules/garden/`)
|
||||||
|
|
||||||
|
Create the directory with stubs:
|
||||||
|
|
||||||
|
- `schema.ts` (complete, from above)
|
||||||
|
- `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`)
|
||||||
|
- `server/actions.ts` (empty stub)
|
||||||
|
- `server/queries.ts` (empty stub)
|
||||||
|
|
||||||
|
Register in `src/modules/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import gardenManifest from "./garden/manifest";
|
||||||
|
registerModule(gardenManifest);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Add to `.env.example`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
MINIO_ROOT_USER=famapp
|
||||||
|
MINIO_ROOT_PASSWORD=changeme
|
||||||
|
MINIO_BUCKET=garden
|
||||||
|
PERENUAL_API_KEY= # filled in task 72
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Any garden-specific UI.
|
||||||
|
- Perenual API integration (task 72).
|
||||||
|
- Care logic (tasks 73–74).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `docker compose up minio` starts and passes health-check.
|
||||||
|
- [ ] `POST /api/uploads` rejects unauthenticated requests with 401.
|
||||||
|
- [ ] `POST /api/uploads` rejects files > 5 MB with 413.
|
||||||
|
- [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes.
|
||||||
|
- [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns.
|
||||||
|
- [ ] `garden` module appears in `/debug/registry`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 71 — Garden containers
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (garden infrastructure — schema, MinIO, upload route)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Server (`src/modules/garden/server/`)
|
||||||
|
|
||||||
|
Add to `actions.ts`:
|
||||||
|
|
||||||
|
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||||
|
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
|
||||||
|
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
|
||||||
|
|
||||||
|
Add to `queries.ts`:
|
||||||
|
|
||||||
|
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
|
||||||
|
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`container-list.tsx`** (server component)
|
||||||
|
|
||||||
|
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
|
||||||
|
- "New container" button → sheet/dialog.
|
||||||
|
|
||||||
|
**`container-detail.tsx`** (server component)
|
||||||
|
|
||||||
|
- Header: cover photo, name, type, location notes, edit/delete buttons.
|
||||||
|
- Plant grid (placeholder for now — task 72 fills in the plant cards).
|
||||||
|
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
|
||||||
|
|
||||||
|
**`container-form.tsx`** (client component)
|
||||||
|
|
||||||
|
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
|
||||||
|
- Used for both create and edit via an optional `existing` prop.
|
||||||
|
|
||||||
|
### Pages (`src/app/garden/`)
|
||||||
|
|
||||||
|
- `page.tsx` — `/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
|
||||||
|
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
|
||||||
|
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
|
||||||
|
|
||||||
|
### Nav
|
||||||
|
|
||||||
|
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
nav: { href: "/garden", label: "Garden", icon: "sprout" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activity log
|
||||||
|
|
||||||
|
`renderActivity` for `garden.container` entity type in the manifest:
|
||||||
|
|
||||||
|
- `create` → `Created container "${name}"`
|
||||||
|
- `update` → `Updated container "${name}"`
|
||||||
|
- `delete` → `Deleted container`
|
||||||
|
|
||||||
|
### Share links
|
||||||
|
|
||||||
|
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Plant cards inside the container detail (task 72 fills those in).
|
||||||
|
- Care tracking (tasks 73–74).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Create / edit / delete containers works end-to-end.
|
||||||
|
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
|
||||||
|
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
|
||||||
|
- [ ] `/garden` page renders with a Containers tab.
|
||||||
|
- [ ] `garden.container` entity is registered with share support in the manifest.
|
||||||
|
- [ ] Activity log entries appear for create / update / delete.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 72 — Garden plants
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
|
||||||
|
|
||||||
|
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type SpeciesSuggestion = {
|
||||||
|
id: string;
|
||||||
|
common_name: string;
|
||||||
|
scientific_name: string;
|
||||||
|
watering: string; // e.g. "frequent", "average", "minimum"
|
||||||
|
sunlight: string[]; // e.g. ["full sun", "part shade"]
|
||||||
|
cycle: string; // e.g. "Perennial"
|
||||||
|
default_image_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
|
||||||
|
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
|
||||||
|
|
||||||
|
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
|
||||||
|
|
||||||
|
### Server (`src/modules/garden/server/`)
|
||||||
|
|
||||||
|
Add to `actions.ts`:
|
||||||
|
|
||||||
|
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||||
|
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
|
||||||
|
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
|
||||||
|
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
|
||||||
|
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
|
||||||
|
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
|
||||||
|
|
||||||
|
Add to `queries.ts`:
|
||||||
|
|
||||||
|
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
|
||||||
|
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`plant-list.tsx`** (server component)
|
||||||
|
|
||||||
|
- Grouped by container first; "Unassigned" group for plants with no container.
|
||||||
|
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
|
||||||
|
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
|
||||||
|
|
||||||
|
**`plant-detail.tsx`** (server component)
|
||||||
|
|
||||||
|
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
|
||||||
|
- Info tab: all fields, container link, health/stage badges, species info.
|
||||||
|
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
|
||||||
|
|
||||||
|
**`plant-form.tsx`** (client component)
|
||||||
|
|
||||||
|
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
|
||||||
|
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
|
||||||
|
- Care notes textareas: watering notes, fertilizing notes, general notes.
|
||||||
|
- Acquisition date picker.
|
||||||
|
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
|
||||||
|
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
|
||||||
|
|
||||||
|
**`species-search.tsx`** (client component)
|
||||||
|
|
||||||
|
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
|
||||||
|
|
||||||
|
### Pages (`src/app/garden/`)
|
||||||
|
|
||||||
|
- `plants/new/page.tsx` — `<PlantForm />` in create mode.
|
||||||
|
- `plants/[id]/page.tsx` — `<PlantDetail />` with edit/delete controls.
|
||||||
|
- `plants/[id]/edit/page.tsx` — `<PlantForm />` in edit mode.
|
||||||
|
|
||||||
|
### Activity log
|
||||||
|
|
||||||
|
`renderActivity` for `garden.plant`:
|
||||||
|
|
||||||
|
- `create` → `Added plant "${name}"`
|
||||||
|
- `update` → `Updated "${name}"`
|
||||||
|
- `delete` → `Removed plant "${name}"`
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
|
||||||
|
|
||||||
|
### Share links
|
||||||
|
|
||||||
|
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Care log UI and schedules (task 73).
|
||||||
|
- Dashboard widgets (task 75).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Create / edit / delete plants works end-to-end.
|
||||||
|
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
|
||||||
|
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
|
||||||
|
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
|
||||||
|
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
|
||||||
|
- [ ] `garden.plant` entity is registered with search and share support.
|
||||||
|
- [ ] Activity log entries appear for create / update / delete.
|
||||||
|
- [ ] Plant share link renders the read-only view correctly.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 73 — Garden care tracking
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (schema), 72 (plants exist to attach care records to)
|
||||||
|
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Care log actions (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
|
||||||
|
- `deleteCareLog(input: { id })` — household check, delete.
|
||||||
|
|
||||||
|
### Care schedule actions (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
|
||||||
|
- `deleteCareSchedule(input: { id })` — `cancelReminder("garden.schedule", id)`, delete.
|
||||||
|
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
|
||||||
|
|
||||||
|
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
|
||||||
|
|
||||||
|
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
|
||||||
|
|
||||||
|
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
|
||||||
|
|
||||||
|
### Queries (`src/modules/garden/server/queries.ts`)
|
||||||
|
|
||||||
|
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
|
||||||
|
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
|
||||||
|
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
|
||||||
|
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
|
||||||
|
|
||||||
|
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
|
||||||
|
|
||||||
|
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
|
||||||
|
|
||||||
|
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
|
||||||
|
|
||||||
|
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
|
||||||
|
|
||||||
|
### Quick-add (update manifest)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "garden.log-care",
|
||||||
|
label: "Log plant care",
|
||||||
|
icon: "droplets",
|
||||||
|
url: "/garden",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Calendar/lists integration (task 74).
|
||||||
|
- Dashboard widget (task 75).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
|
||||||
|
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
|
||||||
|
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
|
||||||
|
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
|
||||||
|
- [ ] Deleting a schedule cancels its reminder.
|
||||||
|
- [ ] `getOverduePlants` returns correctly sorted results.
|
||||||
|
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
|
||||||
|
- [ ] "Log plant care" appears in the quick-add sheet.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 74 — Garden integrations (calendar + lists)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Wire the garden module into the two existing coordination modules:
|
||||||
|
|
||||||
|
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
|
||||||
|
2. **Lists**: push all overdue care tasks to the household task list in one tap.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 73 (care schedules + overdue queries)
|
||||||
|
- 10 (calendar module — `createEvent`)
|
||||||
|
- 11 (lists module — `addItem`)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Calendar integration
|
||||||
|
|
||||||
|
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
|
||||||
|
|
||||||
|
- Look up the schedule + plant; household check.
|
||||||
|
- `startAt` = `next_due_at` (reject with user-facing error if null).
|
||||||
|
- `endAt` = `startAt + 30 min`; `allDay = false`.
|
||||||
|
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
|
||||||
|
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
|
||||||
|
- Call `createCalendarEvent(...)` from the bridge below.
|
||||||
|
|
||||||
|
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
|
||||||
|
export { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the cross-module dependency explicit and swappable without touching `_core`.
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lists integration
|
||||||
|
|
||||||
|
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
`pushOverdueToTaskList(): Promise<{ added: number }>`:
|
||||||
|
|
||||||
|
- Call `getOverduePlants(householdId)`.
|
||||||
|
- Find the household default task list via the bridge below.
|
||||||
|
- For each overdue `(plant, schedule)` pair insert a list item:
|
||||||
|
- `text`: same title convention as the calendar integration.
|
||||||
|
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
|
||||||
|
- `dueAt`: `schedule.next_due_at`.
|
||||||
|
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
|
||||||
|
- Return `{ added: N }`.
|
||||||
|
|
||||||
|
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { addItem as addListItem } from "@/modules/lists/server/actions";
|
||||||
|
export { listLists } from "@/modules/lists/server/queries";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
|
||||||
|
- Recurring calendar events (garden creates single events only).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
|
||||||
|
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
|
||||||
|
- [ ] Items appear in the task list at `/lists`.
|
||||||
|
- [ ] Both actions enforce household scope.
|
||||||
|
- [ ] Both are graceful no-ops when nothing is overdue / due.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 75 — Garden dashboard, widgets, and manifest completion
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70–74 (all previous garden tasks)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Manifest completion (`src/modules/garden/manifest.tsx`)
|
||||||
|
|
||||||
|
Replace the task-70 stub with the full manifest.
|
||||||
|
|
||||||
|
**Entities:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
type: "garden.container",
|
||||||
|
label: { singular: "Container", plural: "Containers" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
search: { search: searchContainers },
|
||||||
|
resolveUrl: (id) => `/garden/containers/${id}`,
|
||||||
|
loadForShare: loadContainerForShare,
|
||||||
|
renderSharedView: ...,
|
||||||
|
renderActivity: ...,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "garden.plant",
|
||||||
|
label: { singular: "Plant", plural: "Plants" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
search: { search: searchPlants },
|
||||||
|
resolveUrl: (id) => `/garden/plants/${id}`,
|
||||||
|
loadForShare: loadPlantForShare,
|
||||||
|
renderSharedView: ...,
|
||||||
|
renderActivity: ...,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
|
||||||
|
|
||||||
|
**Quick adds** (consolidate from tasks 71–73):
|
||||||
|
|
||||||
|
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
|
||||||
|
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
|
||||||
|
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
|
||||||
|
|
||||||
|
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget 1: `garden.care-due`
|
||||||
|
|
||||||
|
**Title**: "Plants needing care" | **Category**: "Garden"
|
||||||
|
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
|
||||||
|
|
||||||
|
Config schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
z.object({
|
||||||
|
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||||
|
daysAhead: z.number().int().min(0).max(30).default(0),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Default config: `{ containerIds: "all", daysAhead: 0 }`.
|
||||||
|
|
||||||
|
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
|
||||||
|
|
||||||
|
Render:
|
||||||
|
|
||||||
|
- Empty state if nothing is due: "All plants are on schedule."
|
||||||
|
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
|
||||||
|
- Truncate at 10 rows; "View all" link to `/garden`.
|
||||||
|
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget 2: `garden.overview`
|
||||||
|
|
||||||
|
**Title**: "Garden overview" | **Category**: "Garden"
|
||||||
|
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
|
||||||
|
|
||||||
|
Config schema: `z.object({})`.
|
||||||
|
|
||||||
|
Render:
|
||||||
|
|
||||||
|
- Stat row: total plants, total containers, overdue care count.
|
||||||
|
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
|
||||||
|
- Link to `/garden`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
|
||||||
|
|
||||||
|
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Search adapters
|
||||||
|
|
||||||
|
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notification body improvement (optional)
|
||||||
|
|
||||||
|
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Playwright E2E test (`tests/garden.spec.ts`)
|
||||||
|
|
||||||
|
Happy path:
|
||||||
|
|
||||||
|
1. Log in as the seeded user.
|
||||||
|
2. Create container "Living Room Shelf".
|
||||||
|
3. Create plant "Pothos" in that container (manual entry, no species lookup).
|
||||||
|
4. Add watering schedule: every 7 days.
|
||||||
|
5. Log care: watered.
|
||||||
|
6. Verify schedule `next_due_at` updates to ~7 days from now.
|
||||||
|
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
|
||||||
|
8. Add `garden.care-due` widget to the default dashboard.
|
||||||
|
9. Confirm no overdue items (just watered).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Health trend charts.
|
||||||
|
- Weather-based watering adjustments.
|
||||||
|
- Social/community features.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Both widgets register and appear in the widget picker.
|
||||||
|
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
|
||||||
|
- [ ] `garden.overview` shows correct counts.
|
||||||
|
- [ ] Both entities appear in command palette search.
|
||||||
|
- [ ] All three quick-adds appear in the `+` sheet.
|
||||||
|
- [ ] Garden nav link appears in sidebar and bottom nav.
|
||||||
|
- [ ] Playwright happy-path test passes.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "theme_palette" text NOT NULL DEFAULT 'clay';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_font_pair" text NOT NULL DEFAULT 'serif-sans';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_density" text NOT NULL DEFAULT 'regular';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_dash_layout" text NOT NULL DEFAULT 'classic';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_cal_view" text NOT NULL DEFAULT 'month';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_nav_style" text NOT NULL DEFAULT 'rail-desktop';--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" DROP COLUMN IF EXISTS "theme";
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Task 70: Garden module — containers, plants, care logs, care schedules, species cache
|
||||||
|
|
||||||
|
CREATE TABLE "garden_containers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"type" text NOT NULL DEFAULT 'other',
|
||||||
|
"location_notes" text,
|
||||||
|
"cover_image_url" text,
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "garden_containers_household_idx" ON "garden_containers" ("household_id");
|
||||||
|
|
||||||
|
CREATE TABLE "garden_plants" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||||
|
"container_id" uuid REFERENCES "garden_containers"("id") ON DELETE SET NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"scientific_name" text,
|
||||||
|
"species_id" text,
|
||||||
|
"category" text NOT NULL DEFAULT 'other',
|
||||||
|
"notes" text,
|
||||||
|
"acquisition_date" date,
|
||||||
|
"growth_stage" text,
|
||||||
|
"health_status" text NOT NULL DEFAULT 'healthy',
|
||||||
|
"sunlight" text,
|
||||||
|
"watering_notes" text,
|
||||||
|
"fertilizing_notes" text,
|
||||||
|
"primary_image_url" text,
|
||||||
|
"images" jsonb NOT NULL DEFAULT '[]',
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "garden_plants_household_idx" ON "garden_plants" ("household_id");
|
||||||
|
CREATE INDEX "garden_plants_household_container_idx" ON "garden_plants" ("household_id", "container_id");
|
||||||
|
|
||||||
|
CREATE TABLE "garden_care_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
|
||||||
|
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||||
|
"care_type" text NOT NULL,
|
||||||
|
"performed_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||||
|
"notes" text,
|
||||||
|
"performed_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "garden_care_logs_plant_performed_idx" ON "garden_care_logs" ("plant_id", "performed_at");
|
||||||
|
CREATE INDEX "garden_care_logs_household_idx" ON "garden_care_logs" ("household_id");
|
||||||
|
|
||||||
|
CREATE TABLE "garden_care_schedules" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
|
||||||
|
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||||
|
"care_type" text NOT NULL,
|
||||||
|
"interval_days" integer NOT NULL,
|
||||||
|
"last_performed_at" timestamp with time zone,
|
||||||
|
"next_due_at" timestamp with time zone,
|
||||||
|
"enabled" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "garden_care_schedules_plant_type_uq" ON "garden_care_schedules" ("plant_id", "care_type");
|
||||||
|
CREATE INDEX "garden_care_schedules_household_due_idx" ON "garden_care_schedules" ("household_id", "next_due_at");
|
||||||
|
|
||||||
|
CREATE TABLE "garden_species_cache" (
|
||||||
|
"species_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"data" jsonb NOT NULL,
|
||||||
|
"cached_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add title/body to reminders for rich notification bodies
|
||||||
|
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS title text;
|
||||||
|
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS body text;
|
||||||
|
|
||||||
|
-- Add metadata to list_items for garden task linking
|
||||||
|
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS metadata jsonb;
|
||||||
@@ -99,6 +99,27 @@
|
|||||||
"when": 1778400000000,
|
"when": 1778400000000,
|
||||||
"tag": "0013_push_notify_reminders",
|
"tag": "0013_push_notify_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778600000000,
|
||||||
|
"tag": "0014_paper_ink_theme",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748736000000,
|
||||||
|
"tag": "0015_garden_schema",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748822400000,
|
||||||
|
"tag": "0016_garden_improvements",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+9
-1
@@ -5,7 +5,15 @@ import nextConfig from "eslint-config-next/core-web-vitals";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ["node_modules/**", ".next/**", ".claude/**", "dist/**", "drizzle/**", "public/sw.js"],
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
".next/**",
|
||||||
|
".claude/**",
|
||||||
|
".design-tmp/**",
|
||||||
|
"dist/**",
|
||||||
|
"drizzle/**",
|
||||||
|
"public/sw.js",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
|||||||
+31
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.0.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:network": "next dev --hostname 0.0.0.0",
|
||||||
|
"dev:local": "node scripts/dev.mjs",
|
||||||
|
"dev:reset": "node scripts/dev-reset.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -22,24 +25,49 @@
|
|||||||
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"gen:icons": "node scripts/generate-icons.mjs",
|
"gen:icons": "node scripts/generate-icons.mjs",
|
||||||
"vapid:generate": "node scripts/vapid-generate.mjs"
|
"vapid:generate": "node scripts/vapid-generate.mjs",
|
||||||
|
"prepare": "husky",
|
||||||
|
"release": "dotenv -e .env -- release-it",
|
||||||
|
"release:patch": "dotenv -e .env -- release-it patch",
|
||||||
|
"release:minor": "dotenv -e .env -- release-it minor",
|
||||||
|
"release:major": "dotenv -e .env -- release-it major",
|
||||||
|
"release:dry": "dotenv -e .env -- release-it --dry-run"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{js,mjs,cjs}": [
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,css,yaml,yml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^21.0.2",
|
||||||
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@release-it/conventional-changelog": "^11.0.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-grid-layout": "^2.1.0",
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-config-next": "^16.2.4",
|
"eslint-config-next": "^16.2.4",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.0.7",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"release-it": "^20.2.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -58,6 +86,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
|||||||
Generated
+1935
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Dev environment reset script.
|
||||||
|
*
|
||||||
|
* Tears down the local Postgres container and deletes its volume (all local
|
||||||
|
* data is lost), then performs a fresh startup: container up, migrate, seed,
|
||||||
|
* Next.js dev server.
|
||||||
|
*
|
||||||
|
* Use this when you want a completely clean local database — e.g. after a
|
||||||
|
* destructive schema change or to reproduce a fresh-install scenario.
|
||||||
|
*
|
||||||
|
* Usage: pnpm dev:reset
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { main } from "./dev.mjs";
|
||||||
|
|
||||||
|
console.log("\n⚠ Resetting dev environment — all local data will be deleted.");
|
||||||
|
execSync("docker compose -f docker-compose.dev.yaml down -v", {
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n▸ Restarting from scratch...");
|
||||||
|
await main();
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Dev environment startup script.
|
||||||
|
*
|
||||||
|
* Starts the local Postgres container, waits for it to be ready, runs
|
||||||
|
* migrations + seed, then spawns `next dev` bound to 0.0.0.0 so the app
|
||||||
|
* is reachable from other devices on the LAN.
|
||||||
|
*
|
||||||
|
* Usage: pnpm dev:local
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync, spawn } from "child_process";
|
||||||
|
import net from "net";
|
||||||
|
import os from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
/** Return the first LAN IPv4 address (prefers 192.168.x / 10.x ranges). */
|
||||||
|
function getLanIp() {
|
||||||
|
const all = Object.values(os.networkInterfaces())
|
||||||
|
.flat()
|
||||||
|
.filter((n) => n.family === "IPv4" && !n.internal)
|
||||||
|
.map((n) => n.address);
|
||||||
|
return all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ?? all[0] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
|
||||||
|
function waitForTcp(host, port, timeoutMs = 30_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
function attempt() {
|
||||||
|
const sock = net.createConnection({ host, port });
|
||||||
|
sock.on("connect", () => {
|
||||||
|
sock.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
sock.on("error", () => {
|
||||||
|
sock.destroy();
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` +
|
||||||
|
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTimeout(attempt, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a shell command synchronously, streaming output to the terminal. */
|
||||||
|
function run(cmd) {
|
||||||
|
execSync(cmd, { stdio: "inherit", shell: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main() {
|
||||||
|
// 1. Start DB container (idempotent — safe to call when already running)
|
||||||
|
console.log("\n▸ Starting database container...");
|
||||||
|
run("docker compose -f docker-compose.dev.yaml up -d");
|
||||||
|
|
||||||
|
// 2. Wait for Postgres to accept connections
|
||||||
|
process.stdout.write("▸ Waiting for Postgres");
|
||||||
|
const tick = setInterval(() => process.stdout.write("."), 500);
|
||||||
|
try {
|
||||||
|
await waitForTcp("127.0.0.1", 5432);
|
||||||
|
} finally {
|
||||||
|
clearInterval(tick);
|
||||||
|
process.stdout.write(" ready\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply any pending migrations (no-op if already current)
|
||||||
|
console.log("▸ Running migrations...");
|
||||||
|
run("pnpm db:migrate");
|
||||||
|
|
||||||
|
// 4. Seed default data (idempotent)
|
||||||
|
console.log("▸ Seeding...");
|
||||||
|
run("pnpm db:seed");
|
||||||
|
|
||||||
|
// 5. Print access URLs before the Next.js banner appears
|
||||||
|
const lanIp = getLanIp();
|
||||||
|
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
console.log(` Local → http://localhost:3000`);
|
||||||
|
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
|
||||||
|
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
|
||||||
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||||
|
|
||||||
|
// 6. Spawn Next.js dev server bound to all interfaces.
|
||||||
|
// HMR handles most code changes without a restart.
|
||||||
|
// Restart needed for: .env changes, next.config.ts changes.
|
||||||
|
// Pass command as a single string with no args array — shell handles .cmd
|
||||||
|
// resolution on Windows and the empty args avoids DEP0190.
|
||||||
|
const next = spawn("pnpm run dev:network", [], { stdio: "inherit", shell: true });
|
||||||
|
|
||||||
|
// Forward Ctrl+C / SIGTERM to the child so it shuts down cleanly.
|
||||||
|
const forward = (sig) => () => next.kill(sig);
|
||||||
|
process.on("SIGINT", forward("SIGINT"));
|
||||||
|
process.on("SIGTERM", forward("SIGTERM"));
|
||||||
|
|
||||||
|
await new Promise((resolve) => next.on("exit", (code) => resolve(code)));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run when invoked directly (not imported by dev-reset.mjs)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] === __filename) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("\ndev startup failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Dev script: send a test push notification to all subscriptions for the dev user.
|
||||||
|
* Usage: tsx --env-file=.env scripts/send-test-push.mts
|
||||||
|
*/
|
||||||
|
import webPush from "web-push";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const subject = process.env["VAPID_SUBJECT"];
|
||||||
|
const publicKey = process.env["VAPID_PUBLIC_KEY"];
|
||||||
|
const privateKey = process.env["VAPID_PRIVATE_KEY"];
|
||||||
|
const databaseUrl = process.env["DATABASE_URL"];
|
||||||
|
|
||||||
|
if (!subject || !publicKey || !privateKey) {
|
||||||
|
console.error("VAPID env vars not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("DATABASE_URL not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
webPush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl);
|
||||||
|
|
||||||
|
const subs = await sql`SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions`;
|
||||||
|
console.log(`Found ${subs.length} push subscription(s)`);
|
||||||
|
|
||||||
|
if (subs.length === 0) {
|
||||||
|
console.log("No subscriptions — enable push notifications in Settings first.");
|
||||||
|
await sql.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await webPush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify({
|
||||||
|
title: "famapp test",
|
||||||
|
body: "Push notifications are working!",
|
||||||
|
url: "/settings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { statusCode?: number; message?: string };
|
||||||
|
console.error(`Failed (${e.statusCode ?? "?"}): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
@@ -3,4 +3,3 @@ import webPush from "web-push";
|
|||||||
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
||||||
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
||||||
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
||||||
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { searchSpecies } from "@/modules/garden/server/species-lookup";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await getCurrentSession();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
|
const results = await searchSpecies(q);
|
||||||
|
return NextResponse.json(results);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
|
||||||
|
const { key } = await params;
|
||||||
|
const objectKey = key.join("/");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
|
||||||
|
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
const metaData = stat.metaData as Record<string, string> | undefined;
|
||||||
|
const contentType =
|
||||||
|
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Content-Length": String(buffer.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: { bodyParser: { sizeLimit: "100mb" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let session: Awaited<ReturnType<typeof getCurrentSession>>;
|
||||||
|
try {
|
||||||
|
session = await getCurrentSession();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: "File exceeds 100 MB limit" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawExt = file.name.split(".").pop() ?? "bin";
|
||||||
|
const safeExt = rawExt
|
||||||
|
.replace(/[^a-z0-9]/gi, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureBucket();
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Upload failed", err);
|
||||||
|
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ url: `/api/uploads/${key}` });
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
|
|
||||||
export default async function CalendarPage() {
|
export default async function CalendarPage() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -8,10 +10,17 @@ export default async function CalendarPage() {
|
|||||||
const to = new Date(now);
|
const to = new Date(now);
|
||||||
to.setMonth(to.getMonth() + 10);
|
to.setMonth(to.getMonth() + 10);
|
||||||
|
|
||||||
const [calendars, events] = await Promise.all([
|
const [{ user }, calendars, events] = await Promise.all([
|
||||||
|
getCurrentSession(),
|
||||||
listCalendars(),
|
listCalendars(),
|
||||||
listEvents({ from, to, calendarIds: "all" }),
|
listEvents({ from, to, calendarIds: "all" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <CalendarShell calendars={calendars} events={events} />;
|
return (
|
||||||
|
<CalendarShell
|
||||||
|
calendars={calendars}
|
||||||
|
events={events}
|
||||||
|
defaultView={user.themeCalView as CalView}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-10
@@ -1,13 +1,20 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
|
import { parseDashboardLayout } from "@/lib/dashboard";
|
||||||
|
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||||
import { getDashboardBySlug } from "@/app/d/actions";
|
import { getDashboardBySlug } from "@/app/d/actions";
|
||||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||||
import { QuickAddFab } from "@/components/quick-add-fab";
|
|
||||||
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||||
|
import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
||||||
|
import { DashboardTab } from "@/components/dashboard-tab";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { dashboards } from "@/modules/_core/schema";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { DashLayout } from "@/modules/_core/themes";
|
||||||
|
|
||||||
const smColSpan: Record<number, string> = {
|
const smColSpan: Record<number, string> = {
|
||||||
1: "sm:col-span-1",
|
1: "sm:col-span-1",
|
||||||
@@ -24,6 +31,9 @@ const smColSpan: Record<number, string> = {
|
|||||||
12: "sm:col-span-12",
|
12: "sm:col-span-12",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const greetingForHour = (h: number) =>
|
||||||
|
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
|
||||||
|
|
||||||
export default async function DashboardPage({
|
export default async function DashboardPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -52,17 +62,55 @@ export default async function DashboardPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For dashboard tabs sub-header, pull the user's dashboards (small list).
|
||||||
|
const session = await auth();
|
||||||
|
const userDashboards = session?.user?.id
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: dashboards.id,
|
||||||
|
name: dashboards.name,
|
||||||
|
slug: dashboards.slug,
|
||||||
|
isDefault: dashboards.isDefault,
|
||||||
|
position: dashboards.position,
|
||||||
|
})
|
||||||
|
.from(dashboards)
|
||||||
|
.where(eq(dashboards.userId, session.user.id))
|
||||||
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||||
|
: [];
|
||||||
|
|
||||||
const ctx = { userId: user.id, householdId: household.id };
|
const ctx = { userId: user.id, householdId: household.id };
|
||||||
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
|
||||||
|
const containerCls =
|
||||||
|
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
|
||||||
|
const greeting = greetingForHour(new Date().getHours());
|
||||||
|
const today = new Date().toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
const firstName = (user.name ?? "").split(" ")[0] ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div className={containerCls}>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
{userDashboards.length > 1 && (
|
||||||
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
|
||||||
<div className="flex items-center gap-2">
|
{userDashboards.map((d) => (
|
||||||
<QuickAddFab />
|
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
|
||||||
<EditDashboardButton />
|
))}
|
||||||
|
<DashboardSwitcher dashboards={userDashboards} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
|
||||||
|
{greeting}
|
||||||
|
{firstName && `, ${firstName}`}.
|
||||||
|
</h1>
|
||||||
|
<p className="muted mt-1 text-[13px]">{today}</p>
|
||||||
|
</div>
|
||||||
|
<EditDashboardButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||||
@@ -73,8 +121,8 @@ export default async function DashboardPage({
|
|||||||
return (
|
return (
|
||||||
<div key={i} className={`col-span-1 ${colClass}`}>
|
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{widget.title}</CardTitle>
|
<CardTitle>{widget.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { db } from "@/lib/db";
|
|||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { getWidget } from "@/modules/_core";
|
import { getWidget } from "@/modules/_core";
|
||||||
import { dashboards } from "@/modules/_core/schema";
|
import { dashboards } from "@/modules/_core/schema";
|
||||||
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
|
import { type DashboardLayout } from "@/lib/dashboard";
|
||||||
|
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||||
|
|
||||||
export type DashboardMeta = {
|
export type DashboardMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getContainer } from "@/modules/garden/server/queries";
|
||||||
|
import { ContainerDetail } from "@/modules/garden/components/container-detail";
|
||||||
|
|
||||||
|
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const container = await getContainer(id);
|
||||||
|
if (!container) notFound();
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<ContainerDetail container={container} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createContainer } from "@/modules/garden/server/actions";
|
||||||
|
|
||||||
|
export default function NewContainerPage() {
|
||||||
|
async function handleCreate(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
await createContainer({
|
||||||
|
name: formData.get("name") as string,
|
||||||
|
type: (formData.get("type") as string) || "other",
|
||||||
|
locationNotes: (formData.get("locationNotes") as string) || null,
|
||||||
|
});
|
||||||
|
redirect("/garden");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content max-w-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 className="page-title">New container</h1>
|
||||||
|
</div>
|
||||||
|
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input id="name" name="name" required maxLength={120} className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="type" className="text-sm font-medium">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select id="type" name="type" defaultValue="other">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["shelf", "Shelf"],
|
||||||
|
["terrarium", "Terrarium"],
|
||||||
|
["raised-bed", "Raised bed"],
|
||||||
|
["window-box", "Window box"],
|
||||||
|
["single-pot", "Single pot"],
|
||||||
|
["outdoor", "Outdoor"],
|
||||||
|
["other", "Other"],
|
||||||
|
] as const
|
||||||
|
).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="locationNotes" className="text-sm font-medium">
|
||||||
|
Location notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="locationNotes"
|
||||||
|
name="locationNotes"
|
||||||
|
maxLength={500}
|
||||||
|
rows={2}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<a href="/garden" className="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listContainers, listPlants } from "@/modules/garden/server/queries";
|
||||||
|
import { ContainerList } from "@/modules/garden/components/container-list";
|
||||||
|
import { PlantList } from "@/modules/garden/components/plant-list";
|
||||||
|
import { PushOverdueButton } from "@/modules/garden/components/push-overdue-button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
searchParams: Promise<{ tab?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function GardenPage({ searchParams }: Props) {
|
||||||
|
const { tab = "containers" } = await searchParams;
|
||||||
|
const isPlants = tab === "plants";
|
||||||
|
|
||||||
|
const [containers, plants] = await Promise.all([
|
||||||
|
listContainers(),
|
||||||
|
isPlants ? listPlants() : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h1 className="page-title mb-0">Garden</h1>
|
||||||
|
<PushOverdueButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6 border-b border-[var(--ink-faint)] mb-6">
|
||||||
|
<Link
|
||||||
|
href="/garden?tab=containers"
|
||||||
|
className={`pb-2 text-sm font-medium transition-colors ${
|
||||||
|
!isPlants
|
||||||
|
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
|
||||||
|
: "text-[var(--ink-mute)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Containers
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/garden?tab=plants"
|
||||||
|
className={`pb-2 text-sm font-medium transition-colors ${
|
||||||
|
isPlants ? "border-b-2 border-[var(--ink)] text-[var(--ink)]" : "text-[var(--ink-mute)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Plants
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPlants ? <PlantList plants={plants} /> : <ContainerList containers={containers} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getPlant, listContainers } from "@/modules/garden/server/queries";
|
||||||
|
import { PlantForm } from "@/modules/garden/components/plant-form";
|
||||||
|
|
||||||
|
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [plant, containers] = await Promise.all([getPlant(id), listContainers()]);
|
||||||
|
if (!plant) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content max-w-xl">
|
||||||
|
<h1 className="page-title">Edit Plant</h1>
|
||||||
|
<PlantForm existingPlant={plant} containers={containers} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
|
||||||
|
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
|
||||||
|
import { PlantDetail } from "@/modules/garden/components/plant-detail";
|
||||||
|
|
||||||
|
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
|
||||||
|
getPlant(id),
|
||||||
|
getCareLogs(id),
|
||||||
|
getCareSchedules(id),
|
||||||
|
listCalendars(),
|
||||||
|
]);
|
||||||
|
if (!plant) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<PlantDetail
|
||||||
|
plant={plant}
|
||||||
|
careLogs={careLogs}
|
||||||
|
careSchedules={careSchedules}
|
||||||
|
calendars={calendars}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { listContainers } from "@/modules/garden/server/queries";
|
||||||
|
import { PlantForm } from "@/modules/garden/components/plant-form";
|
||||||
|
|
||||||
|
export default async function NewPlantPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ containerId?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const containers = await listContainers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content max-w-xl">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 className="page-title">Add Plant</h1>
|
||||||
|
</div>
|
||||||
|
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1279
-168
File diff suppressed because it is too large
Load Diff
+87
-25
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Geist } from "next/font/google";
|
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppNav } from "@/components/app-nav";
|
|
||||||
import "@/modules"; // registers all module manifests
|
import "@/modules"; // registers all module manifests
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -15,11 +14,34 @@ import { QuickAddSheet } from "@/components/quick-add-sheet";
|
|||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
import { InstallPrompt } from "@/components/install-prompt";
|
import { InstallPrompt } from "@/components/install-prompt";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||||
|
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||||
|
|
||||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
|
||||||
|
const sourceSerif = Source_Serif_4({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--serif-source",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
const newsreader = Newsreader({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--serif-newsreader",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--serif-fraunces",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
const jetbrains = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--mono-jb",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#4F46E5",
|
themeColor: "#1F1B16",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -36,34 +58,60 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
|
||||||
// data-theme + dark class to <html> so signed-out pages also get the right theme.
|
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
|
||||||
const prePaintScript = `(function(){
|
const prePaintScript = `(function(){
|
||||||
try {
|
try {
|
||||||
var t = localStorage.getItem('theme') || 'default';
|
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
|
||||||
var m = localStorage.getItem('themeMode') || 'system';
|
var mode = localStorage.getItem('themeMode') || 'system';
|
||||||
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
|
||||||
document.documentElement.setAttribute('data-theme', t);
|
var density = localStorage.getItem('themeDensity') || 'regular';
|
||||||
if (dark) document.documentElement.classList.add('dark');
|
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
|
||||||
else document.documentElement.classList.remove('dark');
|
var dataNav = navStyle === 'compact-rail' ? 'rail'
|
||||||
|
: navStyle === 'top-nav' ? 'top'
|
||||||
|
: 'sidebar';
|
||||||
|
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
|
||||||
|
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
|
||||||
|
}
|
||||||
|
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var html = document.documentElement;
|
||||||
|
html.setAttribute('data-theme', palette);
|
||||||
|
html.setAttribute('data-font-pair', fontPair);
|
||||||
|
html.setAttribute('data-density', density);
|
||||||
|
html.setAttribute('data-nav', dataNav);
|
||||||
|
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();`;
|
})();`;
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
let theme = "default";
|
let palette: Palette = DEFAULT_THEME.palette;
|
||||||
let themeMode = "system";
|
let mode: ThemeMode = DEFAULT_THEME.mode;
|
||||||
|
let fontPair: FontPair = DEFAULT_THEME.fontPair;
|
||||||
|
let density: Density = DEFAULT_THEME.density;
|
||||||
|
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
|
||||||
let userDashboards: DashboardMeta[] = [];
|
let userDashboards: DashboardMeta[] = [];
|
||||||
|
let signedIn = false;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
|
signedIn = true;
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ theme: users.theme, themeMode: users.themeMode })
|
.select({
|
||||||
|
themePalette: users.themePalette,
|
||||||
|
themeMode: users.themeMode,
|
||||||
|
themeFontPair: users.themeFontPair,
|
||||||
|
themeDensity: users.themeDensity,
|
||||||
|
themeNavStyle: users.themeNavStyle,
|
||||||
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, session.user.id))
|
.where(eq(users.id, session.user.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (row) {
|
if (row) {
|
||||||
theme = row.theme;
|
palette = row.themePalette as Palette;
|
||||||
themeMode = row.themeMode;
|
mode = row.themeMode as ThemeMode;
|
||||||
|
fontPair = row.themeFontPair as FontPair;
|
||||||
|
density = row.themeDensity as Density;
|
||||||
|
navStyle = row.themeNavStyle as NavStyle;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -78,25 +126,39 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
// For system mode we can't know the preference on the server — the inline
|
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
|
||||||
// script will correct it before paint. We optimistically render light here.
|
// before paint by the inline script). Avoids a flash on signed-in users.
|
||||||
const isDark = themeMode === "dark";
|
const isDark = mode === "dark";
|
||||||
|
const initialDataNav = navStyleToDataNav(navStyle);
|
||||||
|
|
||||||
const quickAdds = getQuickAdds();
|
const quickAdds = getQuickAdds();
|
||||||
|
|
||||||
|
const fontVars = cn(
|
||||||
|
inter.variable,
|
||||||
|
sourceSerif.variable,
|
||||||
|
newsreader.variable,
|
||||||
|
fraunces.variable,
|
||||||
|
jetbrains.variable,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
data-theme={theme}
|
data-theme={palette}
|
||||||
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
|
data-font-pair={fontPair}
|
||||||
|
data-density={density}
|
||||||
|
data-nav={initialDataNav}
|
||||||
|
className={cn(fontVars, isDark ? "dark" : "")}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-screen">
|
<body>
|
||||||
<QuickAddProvider actions={quickAdds}>
|
<QuickAddProvider actions={quickAdds}>
|
||||||
<AppNav dashboards={userDashboards} />
|
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
|
||||||
<main>{children}</main>
|
{children}
|
||||||
|
</AppShell>
|
||||||
<QuickAddSheet />
|
<QuickAddSheet />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|||||||
+27
-11
@@ -2,23 +2,34 @@ import { signIn } from "@/lib/auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
|
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||||
import { createDevSession } from "@/lib/dev-login";
|
import { createDevSession } from "@/lib/dev-login";
|
||||||
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const devLoginEnabled = isDevLoginEnabled();
|
const devLoginEnabled = isDevLoginEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
<main
|
||||||
<div className="flex flex-col items-center gap-6">
|
className="flex min-h-screen flex-col items-center justify-center p-8"
|
||||||
<h1 className="text-4xl font-bold">famapp</h1>
|
style={{ background: "var(--paper)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--r-lg)] shadow-[var(--shadow-2)] p-10 max-w-sm w-full text-center"
|
||||||
|
style={{ background: "var(--card)", border: "0.5px solid var(--hair)" }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<BrandMark />
|
||||||
|
</div>
|
||||||
|
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
||||||
|
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
await signIn("authentik", { redirectTo: "/" });
|
await signIn("authentik", { redirectTo: "/" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="submit" size="lg">
|
<Button type="submit" size="lg" className="w-full">
|
||||||
Sign in with SSO
|
Sign in with SSO
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -26,18 +37,23 @@ export default function LoginPage() {
|
|||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
|
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
|
||||||
|
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
|
||||||
|
// trustHost, and proxy headers. Set both so the session is found
|
||||||
|
// regardless — this is dev-only code, correctness > elegance.
|
||||||
const { sessionToken, expires } = await createDevSession();
|
const { sessionToken, expires } = await createDevSession();
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
|
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||||
httpOnly: true,
|
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||||
sameSite: "lax",
|
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||||
path: "/",
|
...base,
|
||||||
expires,
|
secure: true,
|
||||||
});
|
});
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}}
|
}}
|
||||||
|
className="mt-3"
|
||||||
>
|
>
|
||||||
<Button type="submit" variant="outline" size="lg">
|
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||||
Dev login
|
Dev login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+24
-20
@@ -1,14 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { resolveShareToken } from "@/modules/_core/share";
|
import { resolveShareToken } from "@/modules/_core/share";
|
||||||
import { getEntityType } from "@/modules/_core/registry";
|
import { getEntityType } from "@/modules/_core/registry";
|
||||||
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
import { ShareFrame } from "@/components/share/share-frame";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rate-limit prefix length — must match the value used in middleware.
|
|
||||||
const RL_PREFIX_LEN = 8;
|
const RL_PREFIX_LEN = 8;
|
||||||
|
|
||||||
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
@@ -20,19 +23,12 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
|
|||||||
"0.0.0.0";
|
"0.0.0.0";
|
||||||
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||||
|
|
||||||
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
|
|
||||||
// The primary 429 enforcement lives in src/middleware.ts which counts all
|
|
||||||
// requests in the Edge runtime. This page tracks only failed token lookups,
|
|
||||||
// providing accurate per-failure accounting. The two buckets are independent
|
|
||||||
// (separate module instances across runtimes); a shared Redis store would
|
|
||||||
// unify them for multi-replica deployments.
|
|
||||||
if (isRateLimited(rlKey)) {
|
if (isRateLimited(rlKey)) {
|
||||||
return <ShareRateLimitError />;
|
return <ShareRateLimitError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = await resolveShareToken(token);
|
const resolved = await resolveShareToken(token);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
// Only failed lookups increment the failure bucket.
|
|
||||||
recordFailure(rlKey);
|
recordFailure(rlKey);
|
||||||
return <ShareError />;
|
return <ShareError />;
|
||||||
}
|
}
|
||||||
@@ -48,24 +44,32 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
|
|||||||
return <ShareError />;
|
return <ShareError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort lookup of the creator's display name. Doesn't reveal email.
|
||||||
|
const [creator] = resolved.createdBy
|
||||||
|
? await db
|
||||||
|
.select({ name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, resolved.createdBy))
|
||||||
|
.limit(1)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<ShareFrame
|
||||||
<div className="border-b bg-background px-4 py-3">
|
expiresAt={resolved.expiresAt}
|
||||||
<p className="text-xs text-muted-foreground">
|
capabilities={resolved.capabilities}
|
||||||
Shared via famapp
|
token={token}
|
||||||
{resolved.capabilities.write ? " · You can edit this" : " · View only"}
|
sharedByName={creator?.name ?? null}
|
||||||
</p>
|
>
|
||||||
</div>
|
|
||||||
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
||||||
</div>
|
</ShareFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareRateLimitError() {
|
function ShareRateLimitError() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
<h1 className="text-xl font-semibold">Too many requests</h1>
|
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
|
||||||
<p className="max-w-sm text-sm text-muted-foreground">
|
<p className="max-w-sm text-[13.5px] muted">
|
||||||
You have made too many requests in a short period. Please wait a minute and try again.
|
You have made too many requests in a short period. Please wait a minute and try again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +79,8 @@ function ShareRateLimitError() {
|
|||||||
function ShareError({ message }: { message?: string }) {
|
function ShareError({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
<h1 className="text-xl font-semibold">Link not found</h1>
|
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
|
||||||
<p className="max-w-sm text-sm text-muted-foreground">
|
<p className="max-w-sm text-[13.5px] muted">
|
||||||
{message ??
|
{message ??
|
||||||
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,17 +4,73 @@ import { eq } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
import {
|
||||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
VALID_PALETTES,
|
||||||
|
VALID_THEME_MODES,
|
||||||
|
VALID_FONT_PAIRS,
|
||||||
|
VALID_DENSITIES,
|
||||||
|
VALID_DASH_LAYOUTS,
|
||||||
|
VALID_CAL_VIEWS,
|
||||||
|
VALID_NAV_STYLES,
|
||||||
|
} from "@/modules/_core/themes";
|
||||||
|
import type {
|
||||||
|
Palette,
|
||||||
|
ThemeMode,
|
||||||
|
FontPair,
|
||||||
|
Density,
|
||||||
|
DashLayout,
|
||||||
|
CalView,
|
||||||
|
NavStyle,
|
||||||
|
} from "@/modules/_core/themes";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { revokeShareLink } from "@/modules/_core/share";
|
import { revokeShareLink } from "@/modules/_core/share";
|
||||||
|
|
||||||
export async function setUserTheme({ theme, mode }: { theme: ThemeId; mode: ThemeMode }) {
|
export interface ThemePatch {
|
||||||
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
palette?: Palette;
|
||||||
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
mode?: ThemeMode;
|
||||||
|
fontPair?: FontPair;
|
||||||
|
density?: Density;
|
||||||
|
dashLayout?: DashLayout;
|
||||||
|
calView?: CalView;
|
||||||
|
navStyle?: NavStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserTheme(patch: ThemePatch) {
|
||||||
|
const update: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (patch.palette !== undefined) {
|
||||||
|
if (!VALID_PALETTES.has(patch.palette)) throw new Error("Invalid palette");
|
||||||
|
update["themePalette"] = patch.palette;
|
||||||
|
}
|
||||||
|
if (patch.mode !== undefined) {
|
||||||
|
if (!VALID_THEME_MODES.has(patch.mode)) throw new Error("Invalid theme mode");
|
||||||
|
update["themeMode"] = patch.mode;
|
||||||
|
}
|
||||||
|
if (patch.fontPair !== undefined) {
|
||||||
|
if (!VALID_FONT_PAIRS.has(patch.fontPair)) throw new Error("Invalid font pair");
|
||||||
|
update["themeFontPair"] = patch.fontPair;
|
||||||
|
}
|
||||||
|
if (patch.density !== undefined) {
|
||||||
|
if (!VALID_DENSITIES.has(patch.density)) throw new Error("Invalid density");
|
||||||
|
update["themeDensity"] = patch.density;
|
||||||
|
}
|
||||||
|
if (patch.dashLayout !== undefined) {
|
||||||
|
if (!VALID_DASH_LAYOUTS.has(patch.dashLayout)) throw new Error("Invalid dashboard layout");
|
||||||
|
update["themeDashLayout"] = patch.dashLayout;
|
||||||
|
}
|
||||||
|
if (patch.calView !== undefined) {
|
||||||
|
if (!VALID_CAL_VIEWS.has(patch.calView)) throw new Error("Invalid calendar view");
|
||||||
|
update["themeCalView"] = patch.calView;
|
||||||
|
}
|
||||||
|
if (patch.navStyle !== undefined) {
|
||||||
|
if (!VALID_NAV_STYLES.has(patch.navStyle)) throw new Error("Invalid nav style");
|
||||||
|
update["themeNavStyle"] = patch.navStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) return;
|
||||||
|
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
await db.update(users).set({ theme, themeMode: mode }).where(eq(users.id, user.id));
|
await db.update(users).set(update).where(eq(users.id, user.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
||||||
|
|||||||
+290
-54
@@ -7,44 +7,183 @@ import { ThemePicker } from "@/components/theme-picker";
|
|||||||
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
|
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
|
||||||
import { PushOptIn } from "@/components/push-opt-in";
|
import { PushOptIn } from "@/components/push-opt-in";
|
||||||
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
||||||
|
import { ResponsiveSidebar } from "@/components/settings-section";
|
||||||
|
import type { SectionId } from "@/components/settings-section";
|
||||||
import { revokeShareLinkAction } from "./actions";
|
import { revokeShareLinkAction } from "./actions";
|
||||||
|
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
const VALID_SECTIONS = new Set<SectionId>([
|
||||||
const { user } = await getCurrentSession();
|
"household",
|
||||||
const shareLinks = await getActiveShareLinks();
|
"sharing",
|
||||||
|
"notifications",
|
||||||
|
"calendars",
|
||||||
|
"appearance",
|
||||||
|
"data",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default async function SettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ s?: string }>;
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const section: SectionId = VALID_SECTIONS.has(sp.s as SectionId)
|
||||||
|
? (sp.s as SectionId)
|
||||||
|
: "household";
|
||||||
|
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||||
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-2xl py-8 space-y-6">
|
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
<ResponsiveSidebar active={section} />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
|
||||||
|
{section === "sharing" && <SharingSection />}
|
||||||
|
{section === "notifications" && (
|
||||||
|
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
||||||
|
)}
|
||||||
|
{section === "calendars" && <CalendarsAndListsSection />}
|
||||||
|
{section === "appearance" && <AppearanceSection user={user} />}
|
||||||
|
{section === "data" && <DataSection />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseholdSection({
|
||||||
|
household,
|
||||||
|
userName,
|
||||||
|
}: {
|
||||||
|
household: { id: string; name: string };
|
||||||
|
userName: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>{household.name}</CardTitle>
|
||||||
|
<span className="meta">Self-hosted</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ThemePicker
|
<p className="muted text-[13px] mb-3">
|
||||||
initialTheme={user.theme as "default" | "warm"}
|
Household name shown on share links and the iOS PWA.
|
||||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
</p>
|
||||||
signedIn
|
<Link
|
||||||
/>
|
href="/settings/household"
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-[13px] font-medium border-[0.5px] hover:bg-[var(--shade)]"
|
||||||
|
style={{ borderColor: "var(--hair-2)" }}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Edit household & members
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Lists</CardTitle>
|
<CardTitle>You</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
|
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
|
||||||
|
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
|
||||||
|
{(userName ?? "?").trim()[0]?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">{userName ?? "Anonymous"}</div>
|
||||||
|
<div className="d">Signed in via Authentik</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function SharingSection() {
|
||||||
|
const shareLinks = await getActiveShareLinks();
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Public share links</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="muted text-[12.5px] mb-3">
|
||||||
|
Share any calendar, list, note, or event with people outside the household. Links expire
|
||||||
|
on their own — no logins needed.
|
||||||
|
</p>
|
||||||
|
{shareLinks.length === 0 ? (
|
||||||
|
<p className="muted text-[13px]">No active share links.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{shareLinks.map((link) => {
|
||||||
|
const registration = getEntityType(link.entityType);
|
||||||
|
const label = registration?.label.singular ?? link.entityType;
|
||||||
|
const iconName = link.entityType.startsWith("calendar.")
|
||||||
|
? "calendar"
|
||||||
|
: link.entityType.startsWith("notes.")
|
||||||
|
? "note"
|
||||||
|
: link.entityType.startsWith("lists.")
|
||||||
|
? "list"
|
||||||
|
: "link";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className="flex items-center gap-3 rounded-[var(--r-md)] border-[0.5px] p-3"
|
||||||
|
style={{ borderColor: "var(--hair)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="size-9 rounded-md flex items-center justify-center shrink-0"
|
||||||
|
style={{ background: "var(--paper-2)" }}
|
||||||
|
>
|
||||||
|
<NavIcon name={iconName} className="size-4 text-[var(--ink-soft)]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13.5px] font-medium">{label}</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-[11.5px] muted mt-0.5">
|
||||||
|
<code style={{ fontFamily: "var(--mono)" }}>
|
||||||
|
{link.capabilities.write ? "edit" : "view-only"}
|
||||||
|
</code>
|
||||||
|
{link.expiresAt && (
|
||||||
|
<span>· expires {link.expiresAt.toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={revokeShareLinkAction}>
|
||||||
|
<input type="hidden" name="id" value={link.id} />
|
||||||
|
<Button variant="destructive" size="sm" type="submit">
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsSection({
|
||||||
|
user,
|
||||||
|
vapidKey,
|
||||||
|
ntfyConfigured,
|
||||||
|
}: {
|
||||||
|
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||||
|
vapidKey: string;
|
||||||
|
ntfyConfigured: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Push Notifications</CardTitle>
|
<CardTitle>Push notifications</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<PushOptIn vapidKey={vapidKey} />
|
<PushOptIn vapidKey={vapidKey} />
|
||||||
@@ -53,7 +192,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notification Channels</CardTitle>
|
<CardTitle>Notification channels</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<NotifyChannelToggles
|
<NotifyChannelToggles
|
||||||
@@ -64,52 +203,149 @@ export default async function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function CalendarsAndListsSection() {
|
||||||
|
const [calendars, lists] = await Promise.all([listCalendars(), listLists()]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Calendars</CardTitle>
|
||||||
|
<Link
|
||||||
|
href="/calendar"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[12.5px] hover:bg-[var(--shade)]"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<div>
|
||||||
|
{calendars.length === 0 ? (
|
||||||
|
<div className="muted px-[14px] py-4 text-[13px]">No calendars yet.</div>
|
||||||
|
) : (
|
||||||
|
calendars.map((c) => (
|
||||||
|
<div key={c.id} className="set-row">
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: c.color ?? "var(--c-household)", width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">{c.name}</div>
|
||||||
|
<div className="d">
|
||||||
|
{c.visibility === "private" ? "Private" : "Household · everyone sees it"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.visibility === "private" && <Lock className="size-3.5 text-[var(--ink-mute)]" />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Active Share Links</CardTitle>
|
<CardTitle>Lists</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{shareLinks.length === 0 ? (
|
<CompletionDelaySetting initialHours={24} />
|
||||||
<p className="text-sm text-muted-foreground">No active share links.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{shareLinks.map((link) => {
|
|
||||||
const registration = getEntityType(link.entityType);
|
|
||||||
const label = registration?.label.singular ?? link.entityType;
|
|
||||||
return (
|
|
||||||
<li key={link.id} className="flex items-center justify-between gap-4 text-sm">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="font-medium">{label}</span>
|
|
||||||
<span className="text-muted-foreground ml-2">
|
|
||||||
{link.capabilities.write ? "read + write" : "read-only"}
|
|
||||||
</span>
|
|
||||||
{link.expiresAt && (
|
|
||||||
<span className="text-muted-foreground ml-2">
|
|
||||||
· expires {link.expiresAt.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<form action={revokeShareLinkAction}>
|
|
||||||
<input type="hidden" name="id" value={link.id} />
|
|
||||||
<Button variant="destructive" size="sm" type="submit">
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<div>
|
||||||
|
{lists.length === 0 ? (
|
||||||
|
<div className="muted px-[14px] py-4 text-[13px]">No lists yet.</div>
|
||||||
|
) : (
|
||||||
|
lists.map((l) => (
|
||||||
|
<div key={l.id} className="set-row">
|
||||||
|
<NavIcon
|
||||||
|
name={l.type === "shopping" ? "cart" : "check-square"}
|
||||||
|
className="size-4 text-[var(--ink-soft)]"
|
||||||
|
/>
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">{l.name}</div>
|
||||||
|
<div className="d">
|
||||||
|
{l.type} · {l.openCount} open
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Link
|
function AppearanceSection({
|
||||||
href="/settings/household"
|
user,
|
||||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
}: {
|
||||||
>
|
user: {
|
||||||
Household Settings
|
themePalette: string;
|
||||||
</Link>
|
themeMode: string;
|
||||||
</div>
|
themeFontPair: string;
|
||||||
|
themeDensity: string;
|
||||||
|
themeDashLayout: string;
|
||||||
|
themeCalView: string;
|
||||||
|
themeNavStyle: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ThemePicker
|
||||||
|
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||||
|
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||||
|
initialFontPair={
|
||||||
|
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||||
|
}
|
||||||
|
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||||
|
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||||
|
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||||
|
initialNavStyle={
|
||||||
|
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||||
|
}
|
||||||
|
signedIn
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataSection() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data & backups</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<div>
|
||||||
|
<div className="set-row">
|
||||||
|
<History className="size-4 text-[var(--ink-soft)]" />
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">Auto-backup</div>
|
||||||
|
<div className="d">Daily 03:00 → /var/backups/famapp/. Configured via host cron.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="set-row">
|
||||||
|
<Globe className="size-4 text-[var(--ink-soft)]" />
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">Server</div>
|
||||||
|
<div className="d">Self-hosted via Docker Compose</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="set-row" style={{ borderBottom: "0" }}>
|
||||||
|
<Mail className="size-4 text-[var(--ink-soft)]" />
|
||||||
|
<div className="label">
|
||||||
|
<div className="t">Export</div>
|
||||||
|
<div className="d">Not yet implemented — coming in v0.5</div>
|
||||||
|
</div>
|
||||||
|
<Bell className="size-4 text-[var(--ink-faint)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { pushSubscriptions } from "@/modules/_core/schema";
|
import { pushSubscriptions } from "@/modules/_core/schema";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { sendPush } from "@/modules/_core/push";
|
import { sendPushToEndpoint } from "@/modules/_core/push";
|
||||||
|
|
||||||
type PushSubscriptionJSON = {
|
type PushSubscriptionJSON = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -35,9 +35,16 @@ export async function unsubscribeFromPush(endpoint: string): Promise<void> {
|
|||||||
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTestNotification(): Promise<void> {
|
export async function sendTestNotification(endpoint: string): Promise<void> {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
await sendPush(user.id, {
|
// Verify the endpoint belongs to the current user before sending.
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: pushSubscriptions.id })
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
|
||||||
|
.limit(1);
|
||||||
|
if (!owned) return;
|
||||||
|
await sendPushToEndpoint(endpoint, {
|
||||||
title: "famapp test",
|
title: "famapp test",
|
||||||
body: "Push notifications are working!",
|
body: "Push notifications are working!",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Settings } from "lucide-react";
|
|
||||||
import { desc, eq } from "drizzle-orm";
|
|
||||||
import { getRegistry } from "@/modules/_core/registry";
|
|
||||||
import type { DashboardMeta } from "@/app/d/actions";
|
|
||||||
import { notifications } from "@/modules/_core/schema";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { DashboardSwitcher } from "./dashboard-switcher";
|
|
||||||
import { DashboardTab } from "./dashboard-tab";
|
|
||||||
import { NotificationBell } from "./notification-bell";
|
|
||||||
|
|
||||||
async function getNotifications(userId: string) {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(notifications)
|
|
||||||
.where(eq(notifications.userId, userId))
|
|
||||||
.orderBy(desc(notifications.createdAt))
|
|
||||||
.limit(20);
|
|
||||||
const unread = rows.filter((n) => !n.readAt).length;
|
|
||||||
return { rows, unread };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) {
|
|
||||||
const { modules } = getRegistry();
|
|
||||||
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
const { rows: notifRows, unread } = userId
|
|
||||||
? await getNotifications(userId)
|
|
||||||
: { rows: [], unread: 0 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="border-b">
|
|
||||||
<nav className="px-4 py-3 flex items-center gap-6">
|
|
||||||
<Link href="/" className="font-semibold text-sm shrink-0">
|
|
||||||
famapp
|
|
||||||
</Link>
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
{userId && (
|
|
||||||
<NotificationBell
|
|
||||||
initialUnread={unread}
|
|
||||||
initialItems={notifRows.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
title: n.title,
|
|
||||||
body: n.body,
|
|
||||||
url: n.url ?? null,
|
|
||||||
createdAt: n.createdAt,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Settings"
|
|
||||||
>
|
|
||||||
<Settings className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{dashboards.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1 border-t px-4 overflow-x-auto">
|
|
||||||
{dashboards.map((d) => (
|
|
||||||
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
|
|
||||||
))}
|
|
||||||
<DashboardSwitcher dashboards={dashboards} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import type { NavStyle } from "@/modules/_core/themes";
|
||||||
|
import type { DashboardMeta } from "@/app/d/actions";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
import { Topbar } from "@/components/topbar";
|
||||||
|
import { BottomNav } from "@/components/bottom-nav";
|
||||||
|
import { Fab } from "@/components/fab";
|
||||||
|
import { NavModeProvider } from "@/components/nav-mode-provider";
|
||||||
|
|
||||||
|
const BARE_PREFIXES = ["/s/", "/login"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
signedIn: boolean;
|
||||||
|
navStyle: NavStyle;
|
||||||
|
dashboards: DashboardMeta[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function AppShell({ signedIn, navStyle, children }: Props) {
|
||||||
|
const h = await headers();
|
||||||
|
const pathname = h.get("x-pathname") ?? "";
|
||||||
|
const bare = BARE_PREFIXES.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(p + "/") || pathname === p.replace(/\/$/, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bare || !signedIn) {
|
||||||
|
// No shell — share viewer and signed-out pages render bare.
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTopVariant = navStyle === "top-nav";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{useTopVariant ? <Sidebar variant="top" /> : <Sidebar variant="side" />}
|
||||||
|
<main className="main">
|
||||||
|
<Topbar />
|
||||||
|
<div className="scroll-area">{children}</div>
|
||||||
|
</main>
|
||||||
|
<BottomNav />
|
||||||
|
<Fab />
|
||||||
|
<NavModeProvider navStyle={navStyle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NavLink } from "@/components/nav-link";
|
||||||
|
|
||||||
|
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: "home" },
|
||||||
|
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||||
|
{ href: "/lists", label: "Lists", icon: "list" },
|
||||||
|
{ href: "/notes", label: "Notes", icon: "note" },
|
||||||
|
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
return (
|
||||||
|
<nav className="bottom-nav" aria-label="Primary navigation">
|
||||||
|
{ITEMS.map((it) => (
|
||||||
|
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
f
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandWordmark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("brand", className)}>
|
||||||
|
<BrandMark />
|
||||||
|
<span className="brand-name">famapp</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import { useEffect, useState, useTransition } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { GridLayout } from "react-grid-layout";
|
import { GridLayout } from "react-grid-layout";
|
||||||
import type { Layout } from "react-grid-layout";
|
import type { Layout } from "react-grid-layout";
|
||||||
import { GripVertical, Settings2, Trash2, RotateCcw, Plus } from "lucide-react";
|
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
|
||||||
import type { DashboardLayout, WidgetPlacement } from "@/lib/dashboard";
|
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
|
||||||
|
import { computePresetLayoutFromMetas } from "@/lib/dashboard";
|
||||||
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
|
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -34,6 +35,7 @@ export function DashboardEditor({
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [configuringIndex, setConfiguringIndex] = useState<number | null>(null);
|
const [configuringIndex, setConfiguringIndex] = useState<number | null>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(1200);
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
const [presetMenuOpen, setPresetMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function measure() {
|
function measure() {
|
||||||
@@ -107,6 +109,13 @@ export function DashboardEditor({
|
|||||||
setConfiguringIndex(null);
|
setConfiguringIndex(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: PresetId) {
|
||||||
|
const next = computePresetLayoutFromMetas(preset, widgetMetas);
|
||||||
|
setPlacements(next.widgets);
|
||||||
|
setIsDirty(true);
|
||||||
|
setPresetMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
const gridItems: Layout = placements.map((p, i) => ({
|
const gridItems: Layout = placements.map((p, i) => ({
|
||||||
i: placementKey(p, i),
|
i: placementKey(p, i),
|
||||||
x: p.x,
|
x: p.x,
|
||||||
@@ -119,10 +128,50 @@ export function DashboardEditor({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
|
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
<h1 className="serif text-[26px] tracking-tight">{dashboard.name}</h1>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPresetMenuOpen((o) => !o)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-4 mr-1" />
|
||||||
|
Preset
|
||||||
|
</Button>
|
||||||
|
{presetMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-9 z-50 min-w-[180px] rounded-md border-[0.5px] bg-card shadow-[var(--shadow-pop)]"
|
||||||
|
style={{ borderColor: "var(--hair-2)" }}
|
||||||
|
onMouseLeave={() => setPresetMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset("classic")}
|
||||||
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
|
||||||
|
>
|
||||||
|
Classic — main + side rail
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset("split")}
|
||||||
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
|
||||||
|
>
|
||||||
|
Split — two even columns
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset("glance")}
|
||||||
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
|
||||||
|
>
|
||||||
|
Glance — single column
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
|
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
|
||||||
<RotateCcw className="size-4 mr-1" />
|
<RotateCcw className="size-4 mr-1" />
|
||||||
Reset
|
Reset
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ export function EditDashboardButton() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" onClick={() => router.push(`${pathname}?edit=1`)} className="btn btn-sm">
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`${pathname}?edit=1`)}
|
|
||||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Edit dashboard
|
Edit dashboard
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuickAdd } from "@/components/quick-add-provider";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
|
||||||
|
export function Fab() {
|
||||||
|
const { openSheet } = useQuickAdd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="fab" aria-label="Quick add" onClick={openSheet}>
|
||||||
|
<NavIcon name="plus" className="size-6" strokeWidth={2.4} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,36 +70,51 @@ export function InstallPrompt() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="banner"
|
role="banner"
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 flex items-start gap-3 border-t bg-background px-4 py-3 shadow-lg sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:rounded-xl sm:border sm:px-5 sm:py-4 sm:shadow-xl"
|
className="fixed bottom-[80px] left-0 right-0 z-50 flex items-start gap-3 px-4 py-3 sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:px-5 sm:py-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--hair-2)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-pop)",
|
||||||
|
margin: "0 14px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* App icon */}
|
<div
|
||||||
<div className="mt-0.5 h-10 w-10 shrink-0 overflow-hidden rounded-xl bg-[#4F46E5]">
|
className="mt-0.5 size-10 shrink-0 overflow-hidden flex items-center justify-center"
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
style={{
|
||||||
<img src="/icon-192.png" alt="" className="h-full w-full object-cover" />
|
borderRadius: 8,
|
||||||
|
background: "var(--ink)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
fontFamily: "var(--serif)",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
f
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 text-sm">
|
<div className="flex-1 text-[13.5px]">
|
||||||
<p className="font-semibold leading-snug">Add famapp to your home screen</p>
|
<p className="font-medium leading-snug text-[var(--ink)] m-0">
|
||||||
|
Add famapp to your home screen
|
||||||
|
</p>
|
||||||
|
|
||||||
{prompt === "android" && (
|
{prompt === "android" && (
|
||||||
<>
|
<>
|
||||||
<p className="mt-0.5 text-muted-foreground">
|
<p className="mt-1 muted text-[12.5px] m-0">
|
||||||
Install for a faster, app-like experience.
|
Install for a faster, app-like experience.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button onClick={install} className="btn btn-sm btn-primary mt-2">
|
||||||
onClick={install}
|
|
||||||
className="mt-2 rounded-md bg-[#4F46E5] px-3 py-1.5 text-xs font-semibold text-white"
|
|
||||||
>
|
|
||||||
Install
|
Install
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{prompt === "ios" && (
|
{prompt === "ios" && (
|
||||||
<p className="mt-0.5 text-muted-foreground">
|
<p className="mt-1 muted text-[12.5px] m-0">
|
||||||
Tap <Share className="inline-block h-4 w-4 align-text-bottom" aria-label="Share" /> then{" "}
|
Tap <Share className="inline-block size-3.5 align-text-bottom" aria-label="Share" />{" "}
|
||||||
|
then{" "}
|
||||||
<strong className="font-medium">
|
<strong className="font-medium">
|
||||||
<Plus className="inline-block h-3.5 w-3.5 align-text-bottom" />
|
<Plus className="inline-block size-3 align-text-bottom" />
|
||||||
Add to Home Screen
|
Add to Home Screen
|
||||||
</strong>
|
</strong>
|
||||||
.
|
.
|
||||||
@@ -110,9 +125,9 @@ export function InstallPrompt() {
|
|||||||
<button
|
<button
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
className="mt-0.5 shrink-0 rounded p-1 text-muted-foreground hover:bg-muted"
|
className="mt-0.5 shrink-0 rounded p-1 text-[var(--ink-mute)] hover:bg-[var(--shade)]"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Calendar,
|
||||||
|
Sprout,
|
||||||
|
CalendarDays,
|
||||||
|
CheckSquare,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Circle,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Filter,
|
||||||
|
Globe,
|
||||||
|
History,
|
||||||
|
Home,
|
||||||
|
Link as LinkIcon,
|
||||||
|
ListChecks,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Menu,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Phone,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
ShoppingCart,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideProps } from "lucide-react";
|
||||||
|
|
||||||
|
const ICONS: Record<string, React.ComponentType<LucideProps>> = {
|
||||||
|
home: Home,
|
||||||
|
calendar: Calendar,
|
||||||
|
"calendar-days": CalendarDays,
|
||||||
|
list: ListChecks,
|
||||||
|
"check-square": CheckSquare,
|
||||||
|
"file-text": FileText,
|
||||||
|
note: FileText,
|
||||||
|
settings: Settings,
|
||||||
|
history: History,
|
||||||
|
link: LinkIcon,
|
||||||
|
people: Users,
|
||||||
|
users: Users,
|
||||||
|
plus: Plus,
|
||||||
|
search: Search,
|
||||||
|
bell: Bell,
|
||||||
|
pin: Pin,
|
||||||
|
"pin-off": PinOff,
|
||||||
|
cart: ShoppingCart,
|
||||||
|
"shopping-cart": ShoppingCart,
|
||||||
|
share: Share2,
|
||||||
|
lock: Lock,
|
||||||
|
eye: Eye,
|
||||||
|
clock: Clock,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
pencil: Pencil,
|
||||||
|
trash: Trash2,
|
||||||
|
globe: Globe,
|
||||||
|
phone: Phone,
|
||||||
|
filter: Filter,
|
||||||
|
sun: Sun,
|
||||||
|
sprout: Sprout,
|
||||||
|
mail: Mail,
|
||||||
|
menu: Menu,
|
||||||
|
more: MoreHorizontal,
|
||||||
|
x: X,
|
||||||
|
"chevron-left": ChevronLeft,
|
||||||
|
"chevron-right": ChevronRight,
|
||||||
|
"chevron-down": ChevronDown,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavIcon({ name, ...rest }: { name: string } & LucideProps) {
|
||||||
|
const Icon = ICONS[name] ?? Circle;
|
||||||
|
return <Icon strokeWidth={1.6} {...rest} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function isActive(pathname: string, href: string): boolean {
|
||||||
|
// Strip query/fragment from href before matching.
|
||||||
|
const cleaned = href.split("#")[0]?.split("?")[0] ?? href;
|
||||||
|
if (cleaned === "/") return pathname === "/" || pathname.startsWith("/d/");
|
||||||
|
return pathname === cleaned || pathname.startsWith(cleaned + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
variant?: "sidebar" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavLink({
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
iconClassName,
|
||||||
|
variant = "sidebar",
|
||||||
|
}: Props) {
|
||||||
|
const pathname = usePathname() ?? "";
|
||||||
|
const active = isActive(pathname, href);
|
||||||
|
|
||||||
|
if (variant === "bottom") {
|
||||||
|
return (
|
||||||
|
<Link href={href} aria-current={active ? "page" : undefined} className={className}>
|
||||||
|
<NavIcon name={icon} className={cn("size-5", iconClassName)} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn("nav-item", className)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<NavIcon name={icon} className={cn("size-4", iconClassName)} />
|
||||||
|
<span className="nav-label">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { NavStyle } from "@/modules/_core/themes";
|
||||||
|
import { navStyleToDataNav } from "@/modules/_core/themes";
|
||||||
|
|
||||||
|
const MOBILE_QUERY = "(max-width: 759px)";
|
||||||
|
|
||||||
|
/** Listens to the mobile breakpoint and overrides data-nav on <html>.
|
||||||
|
* The pre-paint script in layout.tsx already applies the correct value
|
||||||
|
* on first paint; this picks up subsequent resizes. Runs once and stays
|
||||||
|
* mounted as long as the shell is mounted. */
|
||||||
|
export function NavModeProvider({ navStyle }: { navStyle: NavStyle }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const desktopNav = navStyleToDataNav(navStyle);
|
||||||
|
const apply = () => {
|
||||||
|
const isMobile = window.matchMedia(MOBILE_QUERY).matches;
|
||||||
|
const next = isMobile ? (navStyle === "fab-only" ? "fab" : "bottom") : desktopNav;
|
||||||
|
html.setAttribute("data-nav", next);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const mq = window.matchMedia(MOBILE_QUERY);
|
||||||
|
mq.addEventListener("change", apply);
|
||||||
|
return () => mq.removeEventListener("change", apply);
|
||||||
|
}, [navStyle]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
subscribeToPush,
|
subscribeToPush,
|
||||||
@@ -23,6 +23,20 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [testSent, setTestSent] = useState(false);
|
const [testSent, setTestSent] = useState(false);
|
||||||
|
|
||||||
|
// Hydrate subscription state from the browser on mount so the UI reflects
|
||||||
|
// reality even when the user navigates away and returns to Settings.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vapidKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||||
|
navigator.serviceWorker.ready.then((reg) =>
|
||||||
|
reg.pushManager.getSubscription().then((existing) => {
|
||||||
|
if (existing) {
|
||||||
|
setEndpoint(existing.endpoint);
|
||||||
|
setStatus("subscribed");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [vapidKey]);
|
||||||
|
|
||||||
if (!vapidKey) return null;
|
if (!vapidKey) return null;
|
||||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
return (
|
return (
|
||||||
@@ -35,10 +49,15 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const sub = await registration.pushManager.subscribe({
|
// Reuse an existing browser subscription rather than calling subscribe()
|
||||||
userVisibleOnly: true,
|
// again — avoids redundant prompts and inconsistent behaviour on iOS.
|
||||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
const existing = await registration.pushManager.getSubscription();
|
||||||
});
|
const sub =
|
||||||
|
existing ??
|
||||||
|
(await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
|
}));
|
||||||
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await subscribeToPush(json, navigator.userAgent);
|
await subscribeToPush(json, navigator.userAgent);
|
||||||
@@ -63,8 +82,9 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendTest() {
|
function sendTest() {
|
||||||
|
if (!endpoint) return;
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await sendTestNotification();
|
await sendTestNotification(endpoint);
|
||||||
setTestSent(true);
|
setTestSent(true);
|
||||||
setTimeout(() => setTestSent(false), 3000);
|
setTimeout(() => setTestSent(false), 3000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuickAdd } from "./quick-add-provider";
|
|
||||||
|
|
||||||
export function QuickAddFab() {
|
|
||||||
const { openSheet } = useQuickAdd();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label="Quick add"
|
|
||||||
onClick={openSheet}
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md transition-opacity hover:opacity-90"
|
|
||||||
>
|
|
||||||
<span className="text-xl leading-none">+</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Sparkles, X } from "lucide-react";
|
||||||
import { useQuickAdd } from "./quick-add-provider";
|
import { useQuickAdd } from "./quick-add-provider";
|
||||||
|
import { NavIcon } from "./nav-icon";
|
||||||
import type { SerializedQuickAddItem } from "@/modules/_core";
|
import type { SerializedQuickAddItem } from "@/modules/_core";
|
||||||
|
|
||||||
function groupByModule(
|
function groupByModule(
|
||||||
@@ -18,6 +20,15 @@ function groupByModule(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUICK_ADD_ICON: Record<string, string> = {
|
||||||
|
"calendar-plus": "calendar",
|
||||||
|
"calendar-days": "calendar",
|
||||||
|
"shopping-cart": "cart",
|
||||||
|
"list-checks": "check-square",
|
||||||
|
"list-plus": "list",
|
||||||
|
"file-plus": "note",
|
||||||
|
};
|
||||||
|
|
||||||
export function QuickAddSheet() {
|
export function QuickAddSheet() {
|
||||||
const { sheetOpen, closeSheet, actions } = useQuickAdd();
|
const { sheetOpen, closeSheet, actions } = useQuickAdd();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -43,64 +54,107 @@ export function QuickAddSheet() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
className="fixed inset-0 z-40 bg-black/40"
|
className="fixed inset-0 z-40"
|
||||||
|
style={{ background: "rgba(31,27,22,.32)", backdropFilter: "blur(2px)" }}
|
||||||
onClick={closeSheet}
|
onClick={closeSheet}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sheet panel — bottom on mobile, right-anchored popover on sm+ */}
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Quick add"
|
aria-label="Quick add"
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background shadow-xl sm:bottom-auto sm:left-auto sm:right-6 sm:top-16 sm:w-72 sm:rounded-xl"
|
className="fixed bottom-0 left-0 right-0 z-50 sm:bottom-auto sm:left-1/2 sm:top-24 sm:-translate-x-1/2 sm:w-[480px] sm:max-w-[calc(100vw-32px)]"
|
||||||
|
style={{
|
||||||
|
background: "var(--paper)",
|
||||||
|
borderRadius: "18px 18px 0 0",
|
||||||
|
boxShadow: "0 -8px 32px rgba(31,27,22,.16)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
<div
|
||||||
<span className="text-sm font-semibold">Quick add</span>
|
style={{
|
||||||
|
padding: "16px 18px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "0.5px solid var(--hair)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="serif"
|
||||||
|
style={{ fontSize: 18, fontWeight: 500, margin: 0, color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
Quick add
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={closeSheet}
|
onClick={closeSheet}
|
||||||
aria-label="Close quick add"
|
aria-label="Close quick add"
|
||||||
className="rounded p-1 text-muted-foreground hover:bg-muted"
|
className="btn btn-icon btn-ghost btn-sm"
|
||||||
>
|
>
|
||||||
✕
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto p-2 sm:max-h-96">
|
<div style={{ padding: "14px 18px" }}>
|
||||||
{[...groups.entries()].map(([moduleId, group]) => (
|
<div
|
||||||
<div key={moduleId} className="mb-2">
|
className="muted"
|
||||||
<p className="px-2 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
style={{
|
||||||
{group.name}
|
fontSize: 12,
|
||||||
</p>
|
marginBottom: 14,
|
||||||
{group.items.map((action) => (
|
display: "flex",
|
||||||
<button
|
alignItems: "center",
|
||||||
key={action.id}
|
gap: 6,
|
||||||
onClick={() => handleAction(action.url)}
|
}}
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
>
|
||||||
>
|
<Sparkles className="size-3" />
|
||||||
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
|
<span>Pick what to add — or use ⌘K to search.</span>
|
||||||
{action.label}
|
</div>
|
||||||
</button>
|
|
||||||
))}
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
</div>
|
{[...groups.entries()].map(([moduleId, group], i) => (
|
||||||
))}
|
<div key={moduleId} className="mb-2.5">
|
||||||
|
<div className="eyebrow mb-2" style={{ marginTop: i === 0 ? 0 : 8 }}>
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{group.items.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => handleAction(action.url)}
|
||||||
|
className="btn btn-sm justify-start"
|
||||||
|
>
|
||||||
|
<NavIcon
|
||||||
|
name={QUICK_ADD_ICON[action.icon ?? ""] ?? "plus"}
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{action.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="dialog-foot"
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
borderTop: "0.5px solid var(--hair)",
|
||||||
|
background: "var(--paper-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn btn-sm btn-ghost" onClick={closeSheet}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconEmoji(icon?: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"calendar-plus": "📅",
|
|
||||||
"calendar-days": "🗓️",
|
|
||||||
"shopping-cart": "🛒",
|
|
||||||
"list-checks": "✅",
|
|
||||||
"list-plus": "📋",
|
|
||||||
"file-plus": "📝",
|
|
||||||
};
|
|
||||||
return icon ? (map[icon] ?? "➕") : "➕";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "household", label: "Household", icon: "people" },
|
||||||
|
{ id: "sharing", label: "Sharing & links", icon: "link" },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: "bell" },
|
||||||
|
{ id: "calendars", label: "Calendars & lists", icon: "calendar" },
|
||||||
|
{ id: "appearance", label: "Appearance", icon: "sun" },
|
||||||
|
{ id: "data", label: "Data & backups", icon: "history" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SectionId = (typeof SECTIONS)[number]["id"];
|
||||||
|
|
||||||
|
export function SettingsSidebar({ active }: { active: SectionId }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] shadow-[var(--shadow-1)] h-fit"
|
||||||
|
style={{ borderColor: "var(--hair)" }}
|
||||||
|
aria-label="Settings sections"
|
||||||
|
>
|
||||||
|
<div className="card-h">
|
||||||
|
<h3 className="serif text-[15px] m-0 font-medium">Settings</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-1.5">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={cn("nav-item h-9")}
|
||||||
|
aria-current={active === s.id ? "page" : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("s", s.id);
|
||||||
|
router.replace(url.pathname + "?" + url.searchParams.toString() + url.hash, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavIcon name={s.icon} className="size-4" />
|
||||||
|
<span className="nav-label">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="nav-divider" />
|
||||||
|
<Link href="/settings/household" className="nav-item h-9">
|
||||||
|
<NavIcon name="users" className="size-4" />
|
||||||
|
<span className="nav-label">Manage household</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<SectionHashSync pathname={pathname} />
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the URL changes with a hash like #sharing (used by sidebar Share-links link),
|
||||||
|
// ensure the matching section opens.
|
||||||
|
function SectionHashSync({ pathname }: { pathname: string | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (!hash) return;
|
||||||
|
if (!SECTIONS.some((s) => s.id === hash)) return;
|
||||||
|
if (searchParams?.get("s") === hash) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("s", hash);
|
||||||
|
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
|
||||||
|
}, [pathname, router, searchParams]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTabsMobile({ active }: { active: SectionId }) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}>
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === s.id}
|
||||||
|
onClick={() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("s", s.id);
|
||||||
|
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label.split(" ")[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveSidebar({ active }: { active: SectionId }) {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(max-width: 759px)");
|
||||||
|
const apply = () => setIsMobile(mq.matches);
|
||||||
|
apply();
|
||||||
|
mq.addEventListener("change", apply);
|
||||||
|
return () => mq.removeEventListener("change", apply);
|
||||||
|
}, []);
|
||||||
|
if (isMobile) return <SettingsTabsMobile active={active} />;
|
||||||
|
return <SettingsSidebar active={active} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
interface Props {
|
||||||
|
date: Date;
|
||||||
|
/** Optional time string e.g. "7:30 – 9:30 pm" */
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniDayCard({ date, time }: Props) {
|
||||||
|
const month = date.toLocaleDateString(undefined, { weekday: "short", month: "short" });
|
||||||
|
const day = date.getDate();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--r-md)] p-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--hair)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--bad)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{month}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--serif)",
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "var(--ink)",
|
||||||
|
margin: "6px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
{time && <div style={{ fontSize: 13, color: "var(--ink-soft)" }}>{time}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tiny location card with a CSS-only gridded background and a center pin.
|
||||||
|
* No real map for v1 — just visual context. */
|
||||||
|
export function MiniMapCard({ name, address }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--r-md)] p-4 relative overflow-hidden flex flex-col gap-1.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--hair)",
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(31,27,22,.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(31,27,22,.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "14px 14px",
|
||||||
|
backgroundPosition: "16px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: "var(--ink-mute)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Location
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--serif)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{address && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ink-soft)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--accent)",
|
||||||
|
boxShadow: "0 0 0 4px color-mix(in oklab, var(--accent) 18%, transparent)",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
interface Props {
|
||||||
|
expiresAt: Date | null;
|
||||||
|
capabilities: { read: boolean; write: boolean };
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeExpiry(d: Date): string {
|
||||||
|
const ms = d.getTime() - Date.now();
|
||||||
|
if (ms <= 0) return "expired";
|
||||||
|
const days = Math.round(ms / 86400000);
|
||||||
|
if (days >= 1) return `in ${days} day${days === 1 ? "" : "s"}`;
|
||||||
|
const hours = Math.round(ms / 3600000);
|
||||||
|
if (hours >= 1) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||||
|
const mins = Math.round(ms / 60000);
|
||||||
|
return `in ${mins} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareBanner({ expiresAt, capabilities, token }: Props) {
|
||||||
|
const mode = capabilities.write ? "edit" : "view-only";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-6 py-2.5 text-[12.5px] flex-wrap"
|
||||||
|
style={{
|
||||||
|
background: "var(--paper-2)",
|
||||||
|
borderBottom: "0.5px solid var(--hair)",
|
||||||
|
color: "var(--ink-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dot" style={{ background: "var(--c-bills)", width: 6, height: 6 }} />
|
||||||
|
<span>
|
||||||
|
Public share link · <span style={{ textTransform: "lowercase" }}>{mode}</span>
|
||||||
|
</span>
|
||||||
|
{expiresAt ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "var(--ink-2)", fontWeight: 600 }}>
|
||||||
|
· expires {relativeExpiry(expiresAt)}
|
||||||
|
</span>
|
||||||
|
<span>· revoke anytime</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>· no expiration</span>
|
||||||
|
)}
|
||||||
|
<code
|
||||||
|
className="ml-auto"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-mute)",
|
||||||
|
background: "var(--card)",
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "0.5px solid var(--hair)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/s/{token.slice(0, 12)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
|
|
||||||
|
export function ShareBrandStrip({ sharedByName }: { sharedByName?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-6 py-7"
|
||||||
|
style={{ borderBottom: "0.5px solid var(--hair)", background: "var(--card)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 max-w-[720px] mx-auto">
|
||||||
|
<BrandMark />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--serif)",
|
||||||
|
fontSize: 15,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
famapp{" "}
|
||||||
|
{sharedByName && (
|
||||||
|
<span style={{ color: "var(--ink-mute)", fontWeight: 400 }}>
|
||||||
|
· shared by {sharedByName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function ShareDetailCard({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--r-md)] mb-3.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--hair)",
|
||||||
|
padding: "18px 22px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid items-baseline gap-4 py-2.5"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "110px 1fr",
|
||||||
|
borderBottom: "0.5px solid var(--hair)",
|
||||||
|
fontSize: "14.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-mute)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--ink-2)" }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function ShareEyebrow({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-2 mb-5"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ink-mute)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--paper-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ShareBanner } from "./share-banner";
|
||||||
|
import { ShareBrandStrip } from "./share-brand-strip";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
capabilities: { read: boolean; write: boolean };
|
||||||
|
token: string;
|
||||||
|
sharedByName?: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareFrame({ expiresAt, capabilities, token, sharedByName, children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<ShareBanner expiresAt={expiresAt ?? null} capabilities={capabilities} token={token} />
|
||||||
|
<ShareBrandStrip sharedByName={sharedByName} />
|
||||||
|
<main className="flex-1 w-full mx-auto" style={{ maxWidth: 720, padding: "36px 24px 80px" }}>
|
||||||
|
{children}
|
||||||
|
<ShareFoot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareFoot() {
|
||||||
|
return (
|
||||||
|
<div className="muted text-center text-[12px] mt-16 leading-[1.6]">
|
||||||
|
<p className="mb-1">
|
||||||
|
This page is a snapshot. The original lives in the host's famapp household and may
|
||||||
|
change — we'll reflect updates here until the link expires.
|
||||||
|
</p>
|
||||||
|
<p className="m-0">
|
||||||
|
Hosted on <code style={{ fontFamily: "var(--mono)", fontSize: 11 }}>famapp</code> ·
|
||||||
|
self-hosted ·{" "}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="underline"
|
||||||
|
style={{ color: "var(--ink-soft)", textUnderlineOffset: 3 }}
|
||||||
|
>
|
||||||
|
about famapp
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getRegistry } from "@/modules/_core/registry";
|
||||||
|
import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||||
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
import { NavLink } from "@/components/nav-link";
|
||||||
|
|
||||||
|
type Variant = "side" | "top";
|
||||||
|
|
||||||
|
const PRIMARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: "home" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
||||||
|
{ href: "/settings#sharing", label: "Share links", icon: "link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function getHouseholdInfo(userId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ household: households })
|
||||||
|
.from(householdMembers)
|
||||||
|
.innerJoin(households, eq(householdMembers.householdId, households.id))
|
||||||
|
.where(eq(householdMembers.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return null;
|
||||||
|
const members = await db
|
||||||
|
.select({ id: users.id, name: users.name, image: users.image, email: users.email })
|
||||||
|
.from(householdMembers)
|
||||||
|
.innerJoin(users, eq(householdMembers.userId, users.id))
|
||||||
|
.where(eq(householdMembers.householdId, row.household.id))
|
||||||
|
.limit(6);
|
||||||
|
return { household: row.household, members };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Sidebar({ variant = "side" }: { variant?: Variant }) {
|
||||||
|
const { modules } = getRegistry();
|
||||||
|
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
...PRIMARY_NAV,
|
||||||
|
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
|
||||||
|
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const householdInfo = session?.user?.id ? await getHouseholdInfo(session.user.id) : null;
|
||||||
|
|
||||||
|
if (variant === "top") {
|
||||||
|
return (
|
||||||
|
<header className="sidebar sidebar-top">
|
||||||
|
<Link href="/" className="brand">
|
||||||
|
<BrandMark />
|
||||||
|
<span className="brand-name">famapp</span>
|
||||||
|
</Link>
|
||||||
|
{navItems.map((n) => (
|
||||||
|
<NavLink
|
||||||
|
key={n.href}
|
||||||
|
href={n.href}
|
||||||
|
label={n.label}
|
||||||
|
icon={n.icon}
|
||||||
|
className="!w-auto !h-9"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto" />
|
||||||
|
{householdInfo && (
|
||||||
|
<span className="badge">
|
||||||
|
<NavIcon name="people" className="size-3" />
|
||||||
|
{householdInfo.household.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<Link href="/" className="brand">
|
||||||
|
<BrandMark />
|
||||||
|
<span className="brand-name">famapp</span>
|
||||||
|
</Link>
|
||||||
|
{navItems.map((n) => (
|
||||||
|
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
||||||
|
))}
|
||||||
|
<div className="nav-divider" />
|
||||||
|
{SECONDARY_NAV.map((n) => (
|
||||||
|
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
||||||
|
))}
|
||||||
|
{householdInfo && <HouseholdPill info={householdInfo} />}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseholdPill({
|
||||||
|
info,
|
||||||
|
}: {
|
||||||
|
info: NonNullable<Awaited<ReturnType<typeof getHouseholdInfo>>>;
|
||||||
|
}) {
|
||||||
|
const visible = info.members.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<div className="household-pill" title={info.household.name}>
|
||||||
|
<div style={{ display: "flex" }}>
|
||||||
|
{visible.map((m, i) => {
|
||||||
|
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={m.id}
|
||||||
|
className="avatar avatar-sm"
|
||||||
|
style={{
|
||||||
|
background: avatarColor(m.id),
|
||||||
|
marginLeft: i > 0 ? -6 : 0,
|
||||||
|
boxShadow: "0 0 0 1.5px var(--card)",
|
||||||
|
}}
|
||||||
|
title={m.name ?? m.email ?? undefined}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="household-text" style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{info.household.name}
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{info.members.length} {info.members.length === 1 ? "member" : "members"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable color from id — uses the 5 functional accents in rotation.
|
||||||
|
const ACCENT_HUES = [
|
||||||
|
"var(--c-household)",
|
||||||
|
"var(--c-private)",
|
||||||
|
"var(--c-kids)",
|
||||||
|
"var(--c-work)",
|
||||||
|
"var(--c-bills)",
|
||||||
|
];
|
||||||
|
function avatarColor(id: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
|
||||||
|
return ACCENT_HUES[hash % ACCENT_HUES.length] ?? "var(--c-household)";
|
||||||
|
}
|
||||||
+156
-14
@@ -1,7 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { THEMES, THEME_MODES } from "@/modules/_core/themes";
|
import {
|
||||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
PALETTES,
|
||||||
|
THEME_MODES,
|
||||||
|
FONT_PAIRS,
|
||||||
|
DENSITIES,
|
||||||
|
DASH_LAYOUTS,
|
||||||
|
CAL_VIEWS,
|
||||||
|
NAV_STYLES,
|
||||||
|
} from "@/modules/_core/themes";
|
||||||
|
import type {
|
||||||
|
Palette,
|
||||||
|
ThemeMode,
|
||||||
|
FontPair,
|
||||||
|
Density,
|
||||||
|
DashLayout,
|
||||||
|
CalView,
|
||||||
|
NavStyle,
|
||||||
|
} from "@/modules/_core/themes";
|
||||||
import { useTheme } from "@/hooks/use-theme";
|
import { useTheme } from "@/hooks/use-theme";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -15,30 +31,71 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialTheme?: ThemeId;
|
initialPalette?: Palette;
|
||||||
initialMode?: ThemeMode;
|
initialMode?: ThemeMode;
|
||||||
|
initialFontPair?: FontPair;
|
||||||
|
initialDensity?: Density;
|
||||||
|
initialDashLayout?: DashLayout;
|
||||||
|
initialCalView?: CalView;
|
||||||
|
initialNavStyle?: NavStyle;
|
||||||
signedIn?: boolean;
|
signedIn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemePicker({
|
export function ThemePicker({
|
||||||
initialTheme = "default",
|
initialPalette = "clay",
|
||||||
initialMode = "system",
|
initialMode = "system",
|
||||||
|
initialFontPair = "serif-sans",
|
||||||
|
initialDensity = "regular",
|
||||||
|
initialDashLayout = "classic",
|
||||||
|
initialCalView = "month",
|
||||||
|
initialNavStyle = "rail-desktop",
|
||||||
signedIn = false,
|
signedIn = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { theme, mode, setTheme, setMode } = useTheme(initialTheme, initialMode, signedIn);
|
const t = useTheme(
|
||||||
|
{
|
||||||
|
palette: initialPalette,
|
||||||
|
mode: initialMode,
|
||||||
|
fontPair: initialFontPair,
|
||||||
|
density: initialDensity,
|
||||||
|
dashLayout: initialDashLayout,
|
||||||
|
calView: initialCalView,
|
||||||
|
navStyle: initialNavStyle,
|
||||||
|
},
|
||||||
|
signedIn,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Theme</Label>
|
<Label>Theme</Label>
|
||||||
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeId)}>
|
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-56">
|
||||||
<SelectValue />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to bottom, ${
|
||||||
|
PALETTES.find((p) => p.id === t.palette)?.paper ?? "#fff"
|
||||||
|
} 55%, ${PALETTES.find((p) => p.id === t.palette)?.hex ?? "#888"} 55%)`,
|
||||||
|
borderColor: "var(--hair-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectValue />
|
||||||
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{THEMES.map((t) => (
|
{PALETTES.map((p) => (
|
||||||
<SelectItem key={t.id} value={t.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{t.label}
|
<div className="flex items-center justify-between gap-6 w-40">
|
||||||
|
<span>{p.label}</span>
|
||||||
|
<span
|
||||||
|
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||||
|
borderColor: "var(--hair-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -53,14 +110,99 @@ export function ThemePicker({
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(mode === m.id && "bg-primary text-primary-foreground")}
|
className={cn(t.mode === m.id && "bg-primary text-primary-foreground")}
|
||||||
onClick={() => setMode(m.id)}
|
onClick={() => t.setMode(m.id)}
|
||||||
>
|
>
|
||||||
{m.label}
|
{m.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Type pairing</Label>
|
||||||
|
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
|
||||||
|
<SelectTrigger className="w-72">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_PAIRS.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Density</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DENSITIES.map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(t.density === d.id && "bg-primary text-primary-foreground")}
|
||||||
|
onClick={() => t.setDensity(d.id)}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Dashboard layout</Label>
|
||||||
|
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
|
||||||
|
<SelectTrigger className="w-72">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DASH_LAYOUTS.map((d) => (
|
||||||
|
<SelectItem key={d.id} value={d.id}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Calendar default</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{CAL_VIEWS.map((c) => (
|
||||||
|
<Button
|
||||||
|
key={c.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(t.calView === c.id && "bg-primary text-primary-foreground")}
|
||||||
|
onClick={() => t.setCalView(c.id)}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Navigation</Label>
|
||||||
|
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
|
||||||
|
<SelectTrigger className="w-72">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{NAV_STYLES.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id}>
|
||||||
|
{n.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Mobile (under 760px) always uses bottom nav + FAB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuickAdd } from "@/components/quick-add-provider";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
|
||||||
|
export function TopbarNewButton() {
|
||||||
|
const { openSheet } = useQuickAdd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openSheet}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
aria-label="Quick add"
|
||||||
|
>
|
||||||
|
<NavIcon name="plus" className="size-3.5" />
|
||||||
|
<span className="hidden md:inline">New</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuickAdd } from "@/components/quick-add-provider";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
|
||||||
|
export function TopbarSearch() {
|
||||||
|
const { openPalette } = useQuickAdd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPalette}
|
||||||
|
aria-label="Search (⌘K)"
|
||||||
|
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
|
||||||
|
style={{
|
||||||
|
border: "0.5px solid var(--hair-2)",
|
||||||
|
background: "var(--card)",
|
||||||
|
color: "var(--ink-mute)",
|
||||||
|
width: 220,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavIcon name="search" className="size-3.5" />
|
||||||
|
<span style={{ flex: 1, textAlign: "left" }}>Search…</span>
|
||||||
|
<span className="kbd">⌘K</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const TITLES: Array<{ test: (p: string) => boolean; title: string; sub?: string }> = [
|
||||||
|
{ test: (p) => p === "/" || p.startsWith("/d/"), title: "Today", sub: "Dashboard" },
|
||||||
|
{
|
||||||
|
test: (p) => p === "/calendar" || p.startsWith("/calendar/"),
|
||||||
|
title: "Calendar",
|
||||||
|
sub: "household + private",
|
||||||
|
},
|
||||||
|
{ test: (p) => p === "/lists" || p.startsWith("/lists/"), title: "Lists" },
|
||||||
|
{ test: (p) => p === "/notes" || p.startsWith("/notes/"), title: "Notes" },
|
||||||
|
{ test: (p) => p === "/settings" || p.startsWith("/settings/"), title: "Settings" },
|
||||||
|
{ test: (p) => p === "/login", title: "Sign in" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TopbarTitle() {
|
||||||
|
const pathname = usePathname() ?? "";
|
||||||
|
const match = TITLES.find((t) => t.test(pathname)) ?? { title: "famapp" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{match.title}
|
||||||
|
</h1>
|
||||||
|
{"sub" in match && match.sub && <div className="crumb">{match.sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notifications, users } from "@/modules/_core/schema";
|
||||||
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
|
import { TopbarSearch } from "@/components/topbar-search";
|
||||||
|
import { TopbarTitle } from "@/components/topbar-title";
|
||||||
|
import { TopbarNewButton } from "@/components/topbar-new-button";
|
||||||
|
import { NavIcon } from "@/components/nav-icon";
|
||||||
|
|
||||||
|
async function getNotifications(userId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.userId, userId))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
const unread = rows.filter((n) => !n.readAt).length;
|
||||||
|
return { rows, unread };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserAvatar(userId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ name: users.name, email: users.email, image: users.image })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Topbar() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
const { rows: notifRows, unread } = userId
|
||||||
|
? await getNotifications(userId)
|
||||||
|
: { rows: [], unread: 0 };
|
||||||
|
const userRow = userId ? await getUserAvatar(userId) : null;
|
||||||
|
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<TopbarTitle />
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<TopbarSearch />
|
||||||
|
{userId && (
|
||||||
|
<NotificationBell
|
||||||
|
initialUnread={unread}
|
||||||
|
initialItems={notifRows.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
url: n.url ?? null,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TopbarNewButton />
|
||||||
|
{userId && (
|
||||||
|
<span
|
||||||
|
className="avatar"
|
||||||
|
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
|
||||||
|
title={userRow?.name ?? userRow?.email ?? undefined}
|
||||||
|
>
|
||||||
|
{userRow?.image ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={userRow.image}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initial
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopbarSpacer() {
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<h1 className="serif">famapp</h1>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<NavIcon name="bell" className="size-4 text-[var(--ink-mute)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-14
@@ -12,7 +12,8 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col overflow-hidden rounded-[var(--r-lg)] bg-card text-sm text-card-foreground",
|
||||||
|
"border-[0.5px] border-[var(--hair)] shadow-[var(--shadow-1)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -25,7 +26,9 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
"group/card-header flex items-center justify-between gap-2",
|
||||||
|
"px-[14px] pt-3 pb-[10px] border-b-[0.5px] border-[var(--hair)]",
|
||||||
|
"has-data-[slot=card-description]:flex-col has-data-[slot=card-description]:items-start has-data-[slot=card-description]:gap-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -35,10 +38,10 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<h3
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
"font-[family-name:var(--serif)] text-[16px] font-medium tracking-tight m-0 text-[var(--ink)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -50,7 +53,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -60,20 +63,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
className={cn("ml-auto flex items-center gap-2 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-content" className={cn("px-[14px] py-3", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -81,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center justify-end gap-2 px-[14px] py-3 border-t-[0.5px] border-[var(--hair)] bg-[var(--paper-2)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+159
-22
@@ -1,49 +1,186 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type {
|
||||||
|
Palette,
|
||||||
|
ThemeMode,
|
||||||
|
FontPair,
|
||||||
|
Density,
|
||||||
|
DashLayout,
|
||||||
|
CalView,
|
||||||
|
NavStyle,
|
||||||
|
} from "@/modules/_core/themes";
|
||||||
|
import { navStyleToDataNav } from "@/modules/_core/themes";
|
||||||
import { setUserTheme } from "@/app/settings/actions";
|
import { setUserTheme } from "@/app/settings/actions";
|
||||||
|
|
||||||
function applyTheme(theme: ThemeId, mode: ThemeMode) {
|
const MOBILE_QUERY = "(max-width: 759px)";
|
||||||
|
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
function applyTheme(state: {
|
||||||
|
palette: Palette;
|
||||||
|
mode: ThemeMode;
|
||||||
|
fontPair: FontPair;
|
||||||
|
density: Density;
|
||||||
|
navStyle: NavStyle;
|
||||||
|
}) {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
html.setAttribute("data-theme", theme);
|
html.setAttribute("data-theme", state.palette);
|
||||||
|
html.setAttribute("data-font-pair", state.fontPair);
|
||||||
|
html.setAttribute("data-density", state.density);
|
||||||
|
|
||||||
|
const isMobile = window.matchMedia(MOBILE_QUERY).matches;
|
||||||
|
const desktopNav = navStyleToDataNav(state.navStyle);
|
||||||
|
const dataNav = isMobile ? (state.navStyle === "fab-only" ? "fab" : "bottom") : desktopNav;
|
||||||
|
html.setAttribute("data-nav", dataNav);
|
||||||
|
|
||||||
const dark =
|
const dark =
|
||||||
mode === "dark" ||
|
state.mode === "dark" || (state.mode === "system" && window.matchMedia(DARK_QUERY).matches);
|
||||||
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
html.classList.toggle("dark", dark);
|
html.classList.toggle("dark", dark);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("themePalette", state.palette);
|
||||||
localStorage.setItem("themeMode", mode);
|
localStorage.setItem("themeMode", state.mode);
|
||||||
|
localStorage.setItem("themeFontPair", state.fontPair);
|
||||||
|
localStorage.setItem("themeDensity", state.density);
|
||||||
|
localStorage.setItem("themeNavStyle", state.navStyle);
|
||||||
} catch {
|
} catch {
|
||||||
// storage blocked
|
// storage blocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme(
|
export function useTheme(
|
||||||
initialTheme: ThemeId = "default",
|
initial: {
|
||||||
initialMode: ThemeMode = "system",
|
palette?: Palette;
|
||||||
|
mode?: ThemeMode;
|
||||||
|
fontPair?: FontPair;
|
||||||
|
density?: Density;
|
||||||
|
dashLayout?: DashLayout;
|
||||||
|
calView?: CalView;
|
||||||
|
navStyle?: NavStyle;
|
||||||
|
} = {},
|
||||||
signedIn = false,
|
signedIn = false,
|
||||||
) {
|
) {
|
||||||
const [theme, setThemeState] = useState<ThemeId>(initialTheme);
|
const [palette, setPaletteState] = useState<Palette>(initial.palette ?? "clay");
|
||||||
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
const [mode, setModeState] = useState<ThemeMode>(initial.mode ?? "system");
|
||||||
|
const [fontPair, setFontPairState] = useState<FontPair>(initial.fontPair ?? "serif-sans");
|
||||||
|
const [density, setDensityState] = useState<Density>(initial.density ?? "regular");
|
||||||
|
const [dashLayout, setDashLayoutState] = useState<DashLayout>(initial.dashLayout ?? "classic");
|
||||||
|
const [calView, setCalViewState] = useState<CalView>(initial.calView ?? "month");
|
||||||
|
const [navStyle, setNavStyleState] = useState<NavStyle>(initial.navStyle ?? "rail-desktop");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const setTheme = useCallback(
|
// Re-apply data-nav whenever the viewport crosses the mobile breakpoint.
|
||||||
(next: ThemeId) => {
|
useEffect(() => {
|
||||||
setThemeState(next);
|
const mq = window.matchMedia(MOBILE_QUERY);
|
||||||
applyTheme(next, mode);
|
const onChange = () => applyTheme({ palette, mode, fontPair, density, navStyle });
|
||||||
if (signedIn) void setUserTheme({ theme: next, mode });
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, [palette, mode, fontPair, density, navStyle]);
|
||||||
|
|
||||||
|
// Re-apply dark when system pref flips and we're in 'system' mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "system") return;
|
||||||
|
const mq = window.matchMedia(DARK_QUERY);
|
||||||
|
const onChange = () => applyTheme({ palette, mode, fontPair, density, navStyle });
|
||||||
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, [palette, mode, fontPair, density, navStyle]);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
(patch: Parameters<typeof setUserTheme>[0]) => {
|
||||||
|
if (signedIn) void setUserTheme(patch);
|
||||||
},
|
},
|
||||||
[mode, signedIn],
|
[signedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPalette = useCallback(
|
||||||
|
(next: Palette) => {
|
||||||
|
setPaletteState(next);
|
||||||
|
applyTheme({ palette: next, mode, fontPair, density, navStyle });
|
||||||
|
persist({ palette: next });
|
||||||
|
},
|
||||||
|
[mode, fontPair, density, navStyle, persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setMode = useCallback(
|
const setMode = useCallback(
|
||||||
(next: ThemeMode) => {
|
(next: ThemeMode) => {
|
||||||
setModeState(next);
|
setModeState(next);
|
||||||
applyTheme(theme, next);
|
applyTheme({ palette, mode: next, fontPair, density, navStyle });
|
||||||
if (signedIn) void setUserTheme({ theme, mode: next });
|
persist({ mode: next });
|
||||||
},
|
},
|
||||||
[theme, signedIn],
|
[palette, fontPair, density, navStyle, persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { theme, mode, setTheme, setMode };
|
const setFontPair = useCallback(
|
||||||
|
(next: FontPair) => {
|
||||||
|
setFontPairState(next);
|
||||||
|
applyTheme({ palette, mode, fontPair: next, density, navStyle });
|
||||||
|
persist({ fontPair: next });
|
||||||
|
},
|
||||||
|
[palette, mode, density, navStyle, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDensity = useCallback(
|
||||||
|
(next: Density) => {
|
||||||
|
setDensityState(next);
|
||||||
|
applyTheme({ palette, mode, fontPair, density: next, navStyle });
|
||||||
|
persist({ density: next });
|
||||||
|
},
|
||||||
|
[palette, mode, fontPair, navStyle, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDashLayout = useCallback(
|
||||||
|
(next: DashLayout) => {
|
||||||
|
setDashLayoutState(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("themeDashLayout", next);
|
||||||
|
} catch {
|
||||||
|
// storage blocked
|
||||||
|
}
|
||||||
|
persist({ dashLayout: next });
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCalView = useCallback(
|
||||||
|
(next: CalView) => {
|
||||||
|
setCalViewState(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("themeCalView", next);
|
||||||
|
} catch {
|
||||||
|
// storage blocked
|
||||||
|
}
|
||||||
|
persist({ calView: next });
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setNavStyle = useCallback(
|
||||||
|
(next: NavStyle) => {
|
||||||
|
setNavStyleState(next);
|
||||||
|
applyTheme({ palette, mode, fontPair, density, navStyle: next });
|
||||||
|
if (signedIn) {
|
||||||
|
void setUserTheme({ navStyle: next }).then(() => router.refresh());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[palette, mode, fontPair, density, signedIn, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
palette,
|
||||||
|
mode,
|
||||||
|
fontPair,
|
||||||
|
density,
|
||||||
|
dashLayout,
|
||||||
|
calView,
|
||||||
|
navStyle,
|
||||||
|
setPalette,
|
||||||
|
setMode,
|
||||||
|
setFontPair,
|
||||||
|
setDensity,
|
||||||
|
setDashLayout,
|
||||||
|
setCalView,
|
||||||
|
setNavStyle,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,15 @@ export async function register() {
|
|||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
||||||
startReminderWorker();
|
startReminderWorker();
|
||||||
|
|
||||||
|
const vapidSubject = process.env["VAPID_SUBJECT"];
|
||||||
|
const vapidPublic = process.env["VAPID_PUBLIC_KEY"];
|
||||||
|
const vapidPrivate = process.env["VAPID_PRIVATE_KEY"];
|
||||||
|
if (!vapidSubject || !vapidPublic || !vapidPrivate) {
|
||||||
|
console.warn(
|
||||||
|
"[famapp] VAPID keys not configured — web push notifications are disabled. " +
|
||||||
|
"Run `pnpm vapid:generate` and add VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY to .env",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-6
@@ -1,12 +1,7 @@
|
|||||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||||
import NextAuth, { type DefaultSession } from "next-auth";
|
import NextAuth, { type DefaultSession } from "next-auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import { accounts, sessions, users, verificationTokens } from "@/modules/_core/schema";
|
||||||
accounts,
|
|
||||||
sessions,
|
|
||||||
users,
|
|
||||||
verificationTokens,
|
|
||||||
} from "@/modules/_core/schema";
|
|
||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getRegistry } from "@/modules/_core";
|
||||||
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
|
import type { DashboardLayout, PresetId } from "./dashboard";
|
||||||
|
import { computePresetLayoutFromMetas } from "./dashboard";
|
||||||
|
|
||||||
|
/** Build a layout matching one of the design's three dashboard arrangements
|
||||||
|
* using the live module registry. Server-only — the registry is empty on
|
||||||
|
* the client. */
|
||||||
|
export function computePresetLayout(preset: PresetId): DashboardLayout {
|
||||||
|
const { widgets } = getRegistry();
|
||||||
|
if (widgets.length === 0) return { version: 1, widgets: [] };
|
||||||
|
|
||||||
|
const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority);
|
||||||
|
const metas: SerializedWidgetMeta[] = sorted.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
title: w.title,
|
||||||
|
description: w.description,
|
||||||
|
category: w.category,
|
||||||
|
defaultSize: w.defaultSize,
|
||||||
|
minSize: w.minSize,
|
||||||
|
maxSize: w.maxSize,
|
||||||
|
defaultConfig: w.defaultConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return computePresetLayoutFromMetas(preset, metas);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDefaultLayout(): DashboardLayout {
|
||||||
|
return computePresetLayout("classic");
|
||||||
|
}
|
||||||
+97
-26
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getRegistry } from "@/modules/_core";
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
|
|
||||||
export type WidgetPlacement = {
|
export type WidgetPlacement = {
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
@@ -35,33 +35,104 @@ export function parseDashboardLayout(raw: unknown): DashboardLayout | null {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeDefaultLayout(): DashboardLayout {
|
export type PresetId = "classic" | "split" | "glance";
|
||||||
const { widgets } = getRegistry();
|
|
||||||
const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority);
|
|
||||||
|
|
||||||
const placements: WidgetPlacement[] = [];
|
/** Client-safe layout builder: pass the metas explicitly. The server-only
|
||||||
let curX = 0;
|
* variants (`computePresetLayout`, `computeDefaultLayout`) live in
|
||||||
let curY = 0;
|
* `dashboard.server.ts` because they reach into the module registry. */
|
||||||
let rowH = 0;
|
export function computePresetLayoutFromMetas(
|
||||||
|
preset: PresetId,
|
||||||
for (const widget of sorted) {
|
metas: SerializedWidgetMeta[],
|
||||||
const { w, h } = widget.defaultSize;
|
): DashboardLayout {
|
||||||
if (curX + w > 12) {
|
switch (preset) {
|
||||||
curY += rowH;
|
case "classic":
|
||||||
curX = 0;
|
return classicLayout(metas);
|
||||||
rowH = 0;
|
case "split":
|
||||||
}
|
return splitLayout(metas);
|
||||||
placements.push({
|
case "glance":
|
||||||
widgetId: widget.id,
|
return glanceLayout(metas);
|
||||||
config: widget.defaultConfig,
|
|
||||||
x: curX,
|
|
||||||
y: curY,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
});
|
|
||||||
curX += w;
|
|
||||||
rowH = Math.max(rowH, h);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classicLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
|
||||||
|
const placements: WidgetPlacement[] = [];
|
||||||
|
let mainY = 0;
|
||||||
|
let railY = 0;
|
||||||
|
|
||||||
|
metas.forEach((m, i) => {
|
||||||
|
const inRail = i % 3 === 0 && i > 0;
|
||||||
|
if (inRail) {
|
||||||
|
placements.push({
|
||||||
|
widgetId: m.id,
|
||||||
|
config: m.defaultConfig,
|
||||||
|
x: 8,
|
||||||
|
y: railY,
|
||||||
|
w: 4,
|
||||||
|
h: m.defaultSize.h,
|
||||||
|
});
|
||||||
|
railY += m.defaultSize.h;
|
||||||
|
} else {
|
||||||
|
placements.push({
|
||||||
|
widgetId: m.id,
|
||||||
|
config: m.defaultConfig,
|
||||||
|
x: 0,
|
||||||
|
y: mainY,
|
||||||
|
w: 8,
|
||||||
|
h: m.defaultSize.h,
|
||||||
|
});
|
||||||
|
mainY += m.defaultSize.h;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { version: 1, widgets: placements };
|
return { version: 1, widgets: placements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
|
||||||
|
const placements: WidgetPlacement[] = [];
|
||||||
|
let leftY = 0;
|
||||||
|
let rightY = 0;
|
||||||
|
|
||||||
|
metas.forEach((m, i) => {
|
||||||
|
const left = i % 2 === 0;
|
||||||
|
if (left) {
|
||||||
|
placements.push({
|
||||||
|
widgetId: m.id,
|
||||||
|
config: m.defaultConfig,
|
||||||
|
x: 0,
|
||||||
|
y: leftY,
|
||||||
|
w: 6,
|
||||||
|
h: m.defaultSize.h,
|
||||||
|
});
|
||||||
|
leftY += m.defaultSize.h;
|
||||||
|
} else {
|
||||||
|
placements.push({
|
||||||
|
widgetId: m.id,
|
||||||
|
config: m.defaultConfig,
|
||||||
|
x: 6,
|
||||||
|
y: rightY,
|
||||||
|
w: 6,
|
||||||
|
h: m.defaultSize.h,
|
||||||
|
});
|
||||||
|
rightY += m.defaultSize.h;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { version: 1, widgets: placements };
|
||||||
|
}
|
||||||
|
|
||||||
|
function glanceLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
|
||||||
|
const placements: WidgetPlacement[] = [];
|
||||||
|
let y = 0;
|
||||||
|
metas.forEach((m) => {
|
||||||
|
placements.push({
|
||||||
|
widgetId: m.id,
|
||||||
|
config: m.defaultConfig,
|
||||||
|
x: 0,
|
||||||
|
y,
|
||||||
|
w: 12,
|
||||||
|
h: m.defaultSize.h,
|
||||||
|
});
|
||||||
|
y += m.defaultSize.h;
|
||||||
|
});
|
||||||
|
return { version: 1, widgets: placements };
|
||||||
|
}
|
||||||
|
|||||||
+8
-1
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
|
|||||||
import * as calendarSchema from "@/modules/calendar/schema";
|
import * as calendarSchema from "@/modules/calendar/schema";
|
||||||
import * as listsSchema from "@/modules/lists/schema";
|
import * as listsSchema from "@/modules/lists/schema";
|
||||||
import * as notesSchema from "@/modules/notes/schema";
|
import * as notesSchema from "@/modules/notes/schema";
|
||||||
|
import * as gardenSchema from "@/modules/garden/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
|
const schema = {
|
||||||
|
...coreSchema,
|
||||||
|
...calendarSchema,
|
||||||
|
...listsSchema,
|
||||||
|
...notesSchema,
|
||||||
|
...gardenSchema,
|
||||||
|
};
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Client } from "minio";
|
||||||
|
|
||||||
|
const rawEndpoint = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
|
||||||
|
const endpointUrl = new URL(rawEndpoint);
|
||||||
|
|
||||||
|
export const minioClient = new Client({
|
||||||
|
endPoint: endpointUrl.hostname,
|
||||||
|
port: endpointUrl.port
|
||||||
|
? parseInt(endpointUrl.port)
|
||||||
|
: endpointUrl.protocol === "https:"
|
||||||
|
? 443
|
||||||
|
: 80,
|
||||||
|
useSSL: endpointUrl.protocol === "https:",
|
||||||
|
accessKey: process.env.MINIO_ROOT_USER ?? "",
|
||||||
|
secretKey: process.env.MINIO_ROOT_PASSWORD ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "garden";
|
||||||
|
|
||||||
|
let bucketReady = false;
|
||||||
|
|
||||||
|
export async function ensureBucket(): Promise<void> {
|
||||||
|
if (bucketReady) return;
|
||||||
|
const exists = await minioClient.bucketExists(MINIO_BUCKET);
|
||||||
|
if (!exists) {
|
||||||
|
await minioClient.makeBucket(MINIO_BUCKET);
|
||||||
|
}
|
||||||
|
bucketReady = true;
|
||||||
|
}
|
||||||
+7
-2
@@ -15,6 +15,11 @@ export function middleware(request: NextRequest) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withPathname(response: NextResponse, pathname: string): NextResponse {
|
||||||
|
response.headers.set("x-pathname", pathname);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
function route(request: NextRequest): NextResponse {
|
function route(request: NextRequest): NextResponse {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
@@ -38,10 +43,10 @@ function route(request: NextRequest): NextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
|
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
|
||||||
return NextResponse.next();
|
return withPathname(NextResponse.next(), pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSessionCookie(request)) return NextResponse.next();
|
if (hasSessionCookie(request)) return withPathname(NextResponse.next(), pathname);
|
||||||
|
|
||||||
const loginUrl = new URL("/login", request.url);
|
const loginUrl = new URL("/login", request.url);
|
||||||
loginUrl.searchParams.set("callbackUrl", request.url);
|
loginUrl.searchParams.set("callbackUrl", request.url);
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from
|
|||||||
export { logActivity, logShareActivity } from "./activity";
|
export { logActivity, logShareActivity } from "./activity";
|
||||||
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
||||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||||
export { sendPush } from "./push";
|
export { sendPush, sendPushToEndpoint } from "./push";
|
||||||
export { notify } from "./notify";
|
export { notify } from "./notify";
|
||||||
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
||||||
|
|||||||
@@ -22,25 +22,27 @@ async function ActivityWidget({ config }: { config: unknown }) {
|
|||||||
.limit(parsed.limit ?? 20);
|
.limit(parsed.limit ?? 20);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">No recent activity</p>;
|
return <p className="text-sm text-[var(--ink-mute)]">No recent activity</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-col">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const reg = getEntityType(entry.entityType);
|
const reg = getEntityType(entry.entityType);
|
||||||
const description =
|
const description =
|
||||||
reg?.renderActivity?.(entry as ActivityLogEntry) ?? `${entry.action} ${entry.entityType}`;
|
reg?.renderActivity?.(entry as ActivityLogEntry) ?? `${entry.action} ${entry.entityType}`;
|
||||||
return (
|
return (
|
||||||
<li key={entry.id} className="flex items-start gap-2 text-sm">
|
<div key={entry.id} className="activity-row">
|
||||||
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
|
<div className="flex-1 leading-[1.4]">
|
||||||
|
<span className="obj">{description}</span>
|
||||||
|
</div>
|
||||||
|
<time>
|
||||||
{entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
{entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
</span>
|
</time>
|
||||||
<span className="leading-snug">{description}</span>
|
</div>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,32 @@ function ensureVapidConfigured() {
|
|||||||
webPush.setVapidDetails(subject, publicKey, privateKey);
|
webPush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendPushToEndpoint(
|
||||||
|
endpoint: string,
|
||||||
|
payload: { title: string; body: string; url?: string },
|
||||||
|
) {
|
||||||
|
ensureVapidConfigured();
|
||||||
|
const [sub] = await db
|
||||||
|
.select()
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(eq(pushSubscriptions.endpoint, endpoint))
|
||||||
|
.limit(1);
|
||||||
|
if (!sub) return;
|
||||||
|
try {
|
||||||
|
await webPush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
|
} else {
|
||||||
|
logger.error({ err }, "push delivery failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendPush(
|
export async function sendPush(
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: { title: string; body: string; url?: string },
|
payload: { title: string; body: string; url?: string },
|
||||||
|
|||||||
@@ -73,6 +73,26 @@ export function getWidgetMetas(): SerializedWidgetMeta[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Item toggle hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ItemTogglePayload = {
|
||||||
|
itemId: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
done: boolean;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
type ItemToggleHook = (payload: ItemTogglePayload) => Promise<void>;
|
||||||
|
|
||||||
|
const itemToggleHooks = new Map<string, ItemToggleHook>();
|
||||||
|
|
||||||
|
export function registerItemToggleHook(id: string, hook: ItemToggleHook): void {
|
||||||
|
itemToggleHooks.set(id, hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fireItemToggleHooks(payload: ItemTogglePayload): Promise<void> {
|
||||||
|
await Promise.allSettled([...itemToggleHooks.values()].map((h) => h(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
export function getQuickAdds(): SerializedQuickAddItem[] {
|
export function getQuickAdds(): SerializedQuickAddItem[] {
|
||||||
return [...modules.values()].flatMap((manifest) =>
|
return [...modules.values()].flatMap((manifest) =>
|
||||||
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
|
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export async function scheduleReminder(input: {
|
|||||||
fireAt: Date;
|
fireAt: Date;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
}) {
|
}) {
|
||||||
await db
|
await db
|
||||||
.insert(reminders)
|
.insert(reminders)
|
||||||
@@ -22,12 +24,20 @@ export async function scheduleReminder(input: {
|
|||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
fireAt: input.fireAt,
|
fireAt: input.fireAt,
|
||||||
channel: input.channel ?? "auto",
|
channel: input.channel ?? "auto",
|
||||||
|
title: input.title ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
firedAt: null,
|
firedAt: null,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [reminders.entityType, reminders.entityId],
|
target: [reminders.entityType, reminders.entityId],
|
||||||
set: { fireAt: input.fireAt, firedAt: null, createdBy: input.createdBy },
|
set: {
|
||||||
|
fireAt: input.fireAt,
|
||||||
|
title: input.title ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
firedAt: null,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +92,8 @@ export async function tickReminders() {
|
|||||||
if (!reminder.createdBy) return;
|
if (!reminder.createdBy) return;
|
||||||
try {
|
try {
|
||||||
await notify(reminder.createdBy, {
|
await notify(reminder.createdBy, {
|
||||||
title: "Reminder",
|
title: reminder.title ?? "Reminder",
|
||||||
body: `You have a reminder`,
|
body: reminder.body ?? "You have a reminder",
|
||||||
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
||||||
channels: ["push", "inapp"],
|
channels: ["push", "inapp"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ export const users = pgTable("users", {
|
|||||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||||
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
theme: text("theme").notNull().default("default"),
|
themePalette: text("theme_palette").notNull().default("clay"),
|
||||||
themeMode: text("theme_mode").notNull().default("system"),
|
themeMode: text("theme_mode").notNull().default("system"),
|
||||||
|
themeFontPair: text("theme_font_pair").notNull().default("serif-sans"),
|
||||||
|
themeDensity: text("theme_density").notNull().default("regular"),
|
||||||
|
themeDashLayout: text("theme_dash_layout").notNull().default("classic"),
|
||||||
|
themeCalView: text("theme_cal_view").notNull().default("month"),
|
||||||
|
themeNavStyle: text("theme_nav_style").notNull().default("rail-desktop"),
|
||||||
completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24),
|
completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24),
|
||||||
notifPush: boolean("notif_push").notNull().default(true),
|
notifPush: boolean("notif_push").notNull().default(true),
|
||||||
notifInApp: boolean("notif_inapp").notNull().default(true),
|
notifInApp: boolean("notif_inapp").notNull().default(true),
|
||||||
@@ -161,6 +166,8 @@ export const reminders = pgTable(
|
|||||||
entityId: uuid("entity_id").notNull(),
|
entityId: uuid("entity_id").notNull(),
|
||||||
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
|
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
|
||||||
channel: text("channel").notNull().default("auto"),
|
channel: text("channel").notNull().default("auto"),
|
||||||
|
title: text("title"),
|
||||||
|
body: text("body"),
|
||||||
firedAt: timestamp("fired_at", { withTimezone: true }),
|
firedAt: timestamp("fired_at", { withTimezone: true }),
|
||||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export async function resolveShareToken(rawToken: string): Promise<{
|
|||||||
entityId: string;
|
entityId: string;
|
||||||
capabilities: ShareLinkCapabilities;
|
capabilities: ShareLinkCapabilities;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdBy: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const tokenHash = hashToken(rawToken);
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ export async function resolveShareToken(rawToken: string): Promise<{
|
|||||||
entityId: link.entityId,
|
entityId: link.entityId,
|
||||||
capabilities: link.capabilities,
|
capabilities: link.capabilities,
|
||||||
householdId: link.householdId,
|
householdId: link.householdId,
|
||||||
|
expiresAt: link.expiresAt,
|
||||||
|
createdBy: link.createdBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-6
@@ -1,9 +1,65 @@
|
|||||||
export type ThemeMode = "light" | "dark" | "system";
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
export type ThemeId = "default" | "warm" | (string & {});
|
|
||||||
|
|
||||||
export const THEMES: ReadonlyArray<{ id: ThemeId; label: string }> = [
|
export type Palette =
|
||||||
{ id: "default", label: "Default" },
|
| "clay"
|
||||||
{ id: "warm", label: "Warm" },
|
| "indigo"
|
||||||
|
| "sage"
|
||||||
|
| "plum"
|
||||||
|
| "ink"
|
||||||
|
| "rose"
|
||||||
|
| "amber"
|
||||||
|
| "ocean"
|
||||||
|
| "slate"
|
||||||
|
| "forest";
|
||||||
|
export type FontPair = "serif-sans" | "newsreader" | "fraunces" | "sans-only";
|
||||||
|
export type Density = "compact" | "regular" | "comfy";
|
||||||
|
export type DashLayout = "classic" | "split" | "glance";
|
||||||
|
export type CalView = "month" | "week" | "day";
|
||||||
|
export type NavStyle = "rail-desktop" | "compact-rail" | "top-nav" | "fab-only";
|
||||||
|
|
||||||
|
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string; paper: string }> = [
|
||||||
|
{ id: "clay", label: "Clay", hex: "#B85C3C", paper: "#fbf9f4" },
|
||||||
|
{ id: "indigo", label: "Indigo", hex: "#3E5B8A", paper: "#f4f7fb" },
|
||||||
|
{ id: "sage", label: "Sage", hex: "#6F8B5E", paper: "#f5f8f2" },
|
||||||
|
{ id: "plum", label: "Plum", hex: "#7B4F6E", paper: "#faf5fb" },
|
||||||
|
{ id: "ink", label: "Ink", hex: "#1F1B16", paper: "#f8f7f5" },
|
||||||
|
{ id: "rose", label: "Rose", hex: "#B5485E", paper: "#fdf5f7" },
|
||||||
|
{ id: "amber", label: "Amber", hex: "#B07D1E", paper: "#fdf9f0" },
|
||||||
|
{ id: "ocean", label: "Ocean", hex: "#2E7A8A", paper: "#f2f9fb" },
|
||||||
|
{ id: "slate", label: "Slate", hex: "#4A657A", paper: "#f4f6f9" },
|
||||||
|
{ id: "forest", label: "Forest", hex: "#3A6645", paper: "#f2f7f3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FONT_PAIRS: ReadonlyArray<{ id: FontPair; label: string }> = [
|
||||||
|
{ id: "serif-sans", label: "Source Serif + Inter" },
|
||||||
|
{ id: "newsreader", label: "Newsreader + Inter" },
|
||||||
|
{ id: "fraunces", label: "Fraunces + Inter" },
|
||||||
|
{ id: "sans-only", label: "Inter only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DENSITIES: ReadonlyArray<{ id: Density; label: string }> = [
|
||||||
|
{ id: "compact", label: "Compact" },
|
||||||
|
{ id: "regular", label: "Regular" },
|
||||||
|
{ id: "comfy", label: "Comfy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DASH_LAYOUTS: ReadonlyArray<{ id: DashLayout; label: string }> = [
|
||||||
|
{ id: "classic", label: "Classic" },
|
||||||
|
{ id: "split", label: "Split" },
|
||||||
|
{ id: "glance", label: "Glance" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CAL_VIEWS: ReadonlyArray<{ id: CalView; label: string }> = [
|
||||||
|
{ id: "month", label: "Month" },
|
||||||
|
{ id: "week", label: "Week" },
|
||||||
|
{ id: "day", label: "Day" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NAV_STYLES: ReadonlyArray<{ id: NavStyle; label: string }> = [
|
||||||
|
{ id: "rail-desktop", label: "Sidebar" },
|
||||||
|
{ id: "compact-rail", label: "Compact rail" },
|
||||||
|
{ id: "top-nav", label: "Top nav" },
|
||||||
|
{ id: "fab-only", label: "FAB only" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
||||||
@@ -12,5 +68,44 @@ export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
|||||||
{ id: "system", label: "System" },
|
{ id: "system", label: "System" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VALID_THEME_IDS = new Set(THEMES.map((t) => t.id));
|
export const VALID_PALETTES = new Set<string>(PALETTES.map((p) => p.id));
|
||||||
export const VALID_THEME_MODES = new Set<string>(["light", "dark", "system"]);
|
export const VALID_FONT_PAIRS = new Set<string>(FONT_PAIRS.map((p) => p.id));
|
||||||
|
export const VALID_DENSITIES = new Set<string>(DENSITIES.map((p) => p.id));
|
||||||
|
export const VALID_DASH_LAYOUTS = new Set<string>(DASH_LAYOUTS.map((p) => p.id));
|
||||||
|
export const VALID_CAL_VIEWS = new Set<string>(CAL_VIEWS.map((p) => p.id));
|
||||||
|
export const VALID_NAV_STYLES = new Set<string>(NAV_STYLES.map((p) => p.id));
|
||||||
|
export const VALID_THEME_MODES = new Set<string>(THEME_MODES.map((p) => p.id));
|
||||||
|
|
||||||
|
export type ThemeState = {
|
||||||
|
palette: Palette;
|
||||||
|
mode: ThemeMode;
|
||||||
|
fontPair: FontPair;
|
||||||
|
density: Density;
|
||||||
|
dashLayout: DashLayout;
|
||||||
|
calView: CalView;
|
||||||
|
navStyle: NavStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeState = {
|
||||||
|
palette: "clay",
|
||||||
|
mode: "system",
|
||||||
|
fontPair: "serif-sans",
|
||||||
|
density: "regular",
|
||||||
|
dashLayout: "classic",
|
||||||
|
calView: "month",
|
||||||
|
navStyle: "rail-desktop",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map our nav style preference to the data-nav attribute we set on <html>.
|
||||||
|
// On mobile (handled by NavModeProvider) we override to "bottom" or "fab".
|
||||||
|
// fab-only has no desktop equivalent — falls back to sidebar so the nav doesn't disappear.
|
||||||
|
export function navStyleToDataNav(style: NavStyle): "sidebar" | "rail" | "top" | "fab" {
|
||||||
|
switch (style) {
|
||||||
|
case "compact-rail":
|
||||||
|
return "rail";
|
||||||
|
case "top-nav":
|
||||||
|
return "top";
|
||||||
|
default:
|
||||||
|
return "sidebar";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -43,14 +44,38 @@ type EventDraft = {
|
|||||||
remindMinutesBefore: number | null;
|
remindMinutesBefore: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_COLOR = "#2563eb";
|
const DEFAULT_COLOR = "#B85C3C";
|
||||||
|
|
||||||
|
const VIEW_MAP: Record<CalView, string> = {
|
||||||
|
month: "dayGridMonth",
|
||||||
|
week: "timeGridWeek",
|
||||||
|
day: "timeGridDay",
|
||||||
|
};
|
||||||
|
|
||||||
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
|
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
|
||||||
|
if (!hex.startsWith("#")) return hex;
|
||||||
|
let h = hex.slice(1);
|
||||||
|
if (h.length === 3)
|
||||||
|
h = h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("");
|
||||||
|
if (h.length !== 6) return hex;
|
||||||
|
const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `#${h}${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function CalendarShell({
|
export function CalendarShell({
|
||||||
calendars,
|
calendars,
|
||||||
events,
|
events,
|
||||||
|
defaultView = "month",
|
||||||
}: {
|
}: {
|
||||||
calendars: CalendarDto[];
|
calendars: CalendarDto[];
|
||||||
events: CalendarEventDto[];
|
events: CalendarEventDto[];
|
||||||
|
defaultView?: CalView;
|
||||||
}) {
|
}) {
|
||||||
const [calendarRows, setCalendarRows] = useState(calendars);
|
const [calendarRows, setCalendarRows] = useState(calendars);
|
||||||
const [eventRows, setEventRows] = useState(events);
|
const [eventRows, setEventRows] = useState(events);
|
||||||
@@ -380,15 +405,22 @@ export function CalendarShell({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="min-w-0 p-4">
|
<section className="fc-skin min-w-0 p-4">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
initialView="dayGridMonth"
|
initialView={VIEW_MAP[defaultView]}
|
||||||
headerToolbar={{
|
headerToolbar={{
|
||||||
left: "prev,next today",
|
left: "prev,next today",
|
||||||
center: "title",
|
center: "title",
|
||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||||
}}
|
}}
|
||||||
|
buttonText={{
|
||||||
|
today: "Today",
|
||||||
|
month: "Month",
|
||||||
|
week: "Week",
|
||||||
|
day: "Day",
|
||||||
|
}}
|
||||||
|
dayHeaderFormat={{ weekday: "short" }}
|
||||||
selectable
|
selectable
|
||||||
editable
|
editable
|
||||||
eventResizableFromStart
|
eventResizableFromStart
|
||||||
@@ -396,19 +428,26 @@ export function CalendarShell({
|
|||||||
eventClick={openExistingEvent}
|
eventClick={openExistingEvent}
|
||||||
eventDrop={moveEvent}
|
eventDrop={moveEvent}
|
||||||
eventResize={moveEvent}
|
eventResize={moveEvent}
|
||||||
events={visibleEvents.map((event) => ({
|
events={visibleEvents.map((event) => {
|
||||||
id: event.id,
|
const color =
|
||||||
title: event.title,
|
|
||||||
start: event.startAt,
|
|
||||||
end: event.endAt,
|
|
||||||
allDay: event.allDay,
|
|
||||||
backgroundColor:
|
|
||||||
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
||||||
DEFAULT_COLOR,
|
DEFAULT_COLOR;
|
||||||
borderColor:
|
return {
|
||||||
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
id: event.id,
|
||||||
DEFAULT_COLOR,
|
title: event.title,
|
||||||
}))}
|
start: event.startAt,
|
||||||
|
end: event.endAt,
|
||||||
|
allDay: event.allDay,
|
||||||
|
backgroundColor: withAlpha(color, 0.14),
|
||||||
|
borderColor: color,
|
||||||
|
textColor: "var(--ink-2)",
|
||||||
|
extendedProps: { calendarId: event.calendarId, color },
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
eventClassNames={(arg) => {
|
||||||
|
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
||||||
|
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
||||||
|
}}
|
||||||
height="auto"
|
height="auto"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,77 +1,167 @@
|
|||||||
import { MapPin, StickyNote } from "lucide-react";
|
import { Calendar as CalendarIcon, MapPin } from "lucide-react";
|
||||||
import type { CalendarShareData, EventShareData } from "../server/share-queries";
|
import type { CalendarShareData, EventShareData } from "../server/share-queries";
|
||||||
|
import { ShareEyebrow } from "@/components/share/share-eyebrow";
|
||||||
|
import { MiniDayCard } from "@/components/share/mini-day-card";
|
||||||
|
import { MiniMapCard } from "@/components/share/mini-map-card";
|
||||||
|
import { ShareDetailCard, ShareRow } from "@/components/share/share-detail-card";
|
||||||
|
|
||||||
function formatEventTime(startAt: string, endAt: string, allDay: boolean): string {
|
function formatTime(d: Date): string {
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeRange(startAt: string, endAt: string, allDay: boolean): string {
|
||||||
const start = new Date(startAt);
|
const start = new Date(startAt);
|
||||||
const end = new Date(endAt);
|
const end = new Date(endAt);
|
||||||
if (allDay) {
|
if (allDay) return "All day";
|
||||||
return start.toLocaleDateString(undefined, { weekday: "short", month: "long", day: "numeric" });
|
return `${formatTime(start)} – ${formatTime(end)}`;
|
||||||
}
|
}
|
||||||
const dateStr = start.toLocaleDateString(undefined, {
|
|
||||||
weekday: "short",
|
function formatFullDate(d: Date): string {
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
});
|
});
|
||||||
const startTime = start.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
||||||
const endTime = end.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
||||||
return `${dateStr} · ${startTime}–${endTime}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventSharedView({ data }: { data: EventShareData }) {
|
export function EventSharedView({ data }: { data: EventShareData }) {
|
||||||
|
const start = new Date(data.startAt);
|
||||||
|
const end = new Date(data.endAt);
|
||||||
|
const time = data.allDay ? "All day" : `${formatTime(start)} – ${formatTime(end)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
<>
|
||||||
<header className="space-y-1">
|
<ShareEyebrow>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<CalendarIcon className="size-3" />
|
||||||
{data.calendarName}
|
Event
|
||||||
|
</ShareEyebrow>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: "clamp(28px, 6vw, 38px)",
|
||||||
|
lineHeight: 1.15,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
color: "var(--ink)",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
textWrap: "pretty",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.title}
|
||||||
|
</h1>
|
||||||
|
{data.calendarName && (
|
||||||
|
<p
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
color: "var(--ink-soft)",
|
||||||
|
margin: "0 0 28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
On the {data.calendarName} calendar.
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-semibold">{data.title}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatEventTime(data.startAt, data.endAt, data.allDay)}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{data.location && (
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span>{data.location}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid gap-3.5 mb-6"
|
||||||
|
style={{ gridTemplateColumns: data.location ? "1fr 1fr" : "1fr" }}
|
||||||
|
>
|
||||||
|
<MiniDayCard date={start} time={time} />
|
||||||
|
{data.location && <MiniMapCard name={data.location} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareDetailCard>
|
||||||
|
<ShareRow label="When">
|
||||||
|
{formatFullDate(start)}
|
||||||
|
{!data.allDay && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
{formatTimeRange(data.startAt, data.endAt, data.allDay)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ShareRow>
|
||||||
|
{data.location && (
|
||||||
|
<ShareRow label="Where">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MapPin className="size-3.5 text-[var(--ink-mute)]" />
|
||||||
|
{data.location}
|
||||||
|
</span>
|
||||||
|
</ShareRow>
|
||||||
|
)}
|
||||||
|
<ShareRow label="Calendar">{data.calendarName}</ShareRow>
|
||||||
|
</ShareDetailCard>
|
||||||
|
|
||||||
{data.notes && (
|
{data.notes && (
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<p
|
||||||
<StickyNote className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
className="serif"
|
||||||
<p className="whitespace-pre-wrap">{data.notes}</p>
|
style={{
|
||||||
</div>
|
fontSize: 16,
|
||||||
|
lineHeight: 1.65,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
background: "var(--paper-2)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "18px 22px",
|
||||||
|
margin: "14px 0 24px",
|
||||||
|
textWrap: "pretty",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.notes}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSharedView({ data }: { data: CalendarShareData }) {
|
export function CalendarSharedView({ data }: { data: CalendarShareData }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
<>
|
||||||
<header>
|
<ShareEyebrow>
|
||||||
<h1 className="text-2xl font-semibold">{data.name}</h1>
|
<CalendarIcon className="size-3" />
|
||||||
<p className="text-sm text-muted-foreground">Upcoming events — next 90 days</p>
|
Calendar
|
||||||
</header>
|
</ShareEyebrow>
|
||||||
{data.events.length === 0 ? (
|
<h1
|
||||||
<p className="text-sm text-muted-foreground">No upcoming events.</p>
|
className="serif"
|
||||||
) : (
|
style={{
|
||||||
<ul className="divide-y rounded-lg border bg-background">
|
fontSize: "clamp(28px, 6vw, 38px)",
|
||||||
{data.events.map((event) => (
|
lineHeight: 1.15,
|
||||||
<li key={event.id} className="flex flex-col gap-0.5 px-4 py-3">
|
letterSpacing: "-0.02em",
|
||||||
<span className="font-medium">{event.title}</span>
|
color: "var(--ink)",
|
||||||
<span className="text-xs text-muted-foreground">
|
margin: "0 0 8px",
|
||||||
{formatEventTime(event.startAt, event.endAt, event.allDay)}
|
textWrap: "pretty",
|
||||||
</span>
|
}}
|
||||||
{event.location && (
|
>
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
{data.name}
|
||||||
<MapPin className="size-3" />
|
</h1>
|
||||||
{event.location}
|
<p className="serif" style={{ fontSize: 17, color: "var(--ink-soft)", margin: "0 0 28px" }}>
|
||||||
</span>
|
Upcoming events — next 90 days.
|
||||||
)}
|
</p>
|
||||||
</li>
|
|
||||||
))}
|
<ShareDetailCard>
|
||||||
</ul>
|
{data.events.length === 0 ? (
|
||||||
)}
|
<p className="muted text-[13.5px] py-2">No upcoming events.</p>
|
||||||
</div>
|
) : (
|
||||||
|
data.events.map((event) => {
|
||||||
|
const start = new Date(event.startAt);
|
||||||
|
return (
|
||||||
|
<div key={event.id} className="list-row" style={{ padding: "10px 0" }}>
|
||||||
|
<span className="dot" style={{ background: data.color ?? "var(--c-household)" }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14px] font-medium text-[var(--ink-2)]">{event.title}</div>
|
||||||
|
<div className="muted tnum text-[12px] mt-0.5">
|
||||||
|
{start.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
{!event.allDay && ` · ${formatTime(start)}`}
|
||||||
|
{event.location && ` · ${event.location}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ShareDetailCard>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ const upcomingConfigSchema = z.object({
|
|||||||
|
|
||||||
const monthConfigSchema = z.object({ calendarIds: calendarIdsSchema });
|
const monthConfigSchema = z.object({ calendarIds: calendarIdsSchema });
|
||||||
|
|
||||||
|
function dayLabel(d: Date): string {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const target = new Date(d);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
if (target.getTime() === today.getTime()) return "Today";
|
||||||
|
if (target.getTime() === tomorrow.getTime()) return "Tomorrow";
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(d: Date): string {
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetContext }) {
|
async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetContext }) {
|
||||||
const parsed = upcomingConfigSchema.parse(config);
|
const parsed = upcomingConfigSchema.parse(config);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -32,26 +48,52 @@ async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetCo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group by day
|
||||||
|
const groups = new Map<string, { day: Date; events: typeof events }>();
|
||||||
|
for (const e of events) {
|
||||||
|
const start = new Date(e.startAt);
|
||||||
|
const key = start.toDateString();
|
||||||
|
if (!groups.has(key)) groups.set(key, { day: start, events: [] });
|
||||||
|
groups.get(key)!.events.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-col gap-3">
|
||||||
{events.slice(0, 8).map((event) => {
|
{[...groups.values()].slice(0, 4).map((group, gi) => (
|
||||||
const start = new Date(event.startAt);
|
<div key={gi}>
|
||||||
const label = event.allDay
|
<div className="eyebrow mb-1.5 flex items-baseline gap-2">
|
||||||
? start.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
<span>{dayLabel(group.day)}</span>
|
||||||
: start.toLocaleString(undefined, {
|
<span className="text-[var(--ink-faint)] font-medium tracking-normal">
|
||||||
month: "short",
|
{group.day.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
day: "numeric",
|
</span>
|
||||||
hour: "numeric",
|
</div>
|
||||||
minute: "2-digit",
|
<div className="flex flex-col gap-1">
|
||||||
});
|
{group.events.map((event) => {
|
||||||
return (
|
const start = new Date(event.startAt);
|
||||||
<li key={event.id} className="flex items-start gap-2 text-sm">
|
return (
|
||||||
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">{label}</span>
|
<div
|
||||||
<span className="font-medium leading-snug">{event.title}</span>
|
key={event.id}
|
||||||
</li>
|
className="flex items-start gap-2.5 px-2 py-1.5 rounded-md hover:bg-[var(--shade)]"
|
||||||
);
|
>
|
||||||
})}
|
<span className="dot mt-2" style={{ background: "var(--c-household)" }} />
|
||||||
</ul>
|
<div className="min-w-[56px] tnum text-[var(--ink-mute)] text-[12px] mt-px">
|
||||||
|
{event.allDay ? "all day" : formatTime(start)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13.5px] font-medium text-[var(--ink)] truncate">
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div className="text-[11.5px] text-[var(--ink-mute)]">{event.location}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,28 +111,29 @@ async function MonthWidget({ config }: { config: unknown; ctx: WidgetContext })
|
|||||||
const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{monthName}</p>
|
<div className="eyebrow">{monthName}</div>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No events this month</p>
|
<p className="text-sm text-[var(--ink-mute)]">No events this month</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<div className="flex flex-col">
|
||||||
{events.slice(0, 10).map((event) => {
|
{events.slice(0, 10).map((event) => {
|
||||||
const eventStart = new Date(event.startAt);
|
const eventStart = new Date(event.startAt);
|
||||||
const day = eventStart.getDate();
|
const day = eventStart.getDate();
|
||||||
return (
|
return (
|
||||||
<li key={event.id} className="flex items-center gap-2 text-sm">
|
<div key={event.id} className="flex items-center gap-3 py-1.5 text-sm">
|
||||||
<span className="w-5 shrink-0 text-center text-xs font-semibold text-muted-foreground">
|
<span className="w-6 shrink-0 text-center font-medium text-[var(--ink-mute)] tnum">
|
||||||
{day}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{event.title}</span>
|
<span className="dot" style={{ background: "var(--c-household)" }} />
|
||||||
</li>
|
<span className="truncate text-[var(--ink-2)]">{event.title}</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{events.length > 10 && (
|
{events.length > 10 && (
|
||||||
<li className="text-xs text-muted-foreground">+{events.length - 10} more</li>
|
<div className="text-xs text-[var(--ink-mute)] mt-1">+{events.length - 10} more</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CareLogDto } from "../server/queries";
|
||||||
|
|
||||||
|
const CARE_ICONS: Record<string, string> = {
|
||||||
|
watering: "💧",
|
||||||
|
fertilizing: "🌱",
|
||||||
|
repotting: "🪴",
|
||||||
|
pruning: "✂️",
|
||||||
|
"pest-control": "🐛",
|
||||||
|
other: "📋",
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
if (days === 0) return "Today";
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
logs: CareLogDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CareHistoryList({ logs }: Props) {
|
||||||
|
if (logs.length === 0) {
|
||||||
|
return <p className="text-sm text-[var(--ink-mute)]">No care events logged yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<li key={log.id} className="flex gap-3 items-start text-sm">
|
||||||
|
<span className="text-lg leading-none mt-0.5" aria-hidden>
|
||||||
|
{CARE_ICONS[log.careType] ?? "📋"}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium capitalize">{log.careType}</span>
|
||||||
|
<span className="text-xs text-[var(--ink-mute)]">{timeAgo(log.performedAt)}</span>
|
||||||
|
{log.notes && <p className="text-xs text-[var(--ink-mute)]">{log.notes}</p>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { logCare } from "../server/actions";
|
||||||
|
|
||||||
|
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plantId: string;
|
||||||
|
defaultCareType?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CareLogForm({ plantId, defaultCareType = "watering", onSuccess }: Props) {
|
||||||
|
const [careType, setCareType] = useState(defaultCareType);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [performedAt, setPerformedAt] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await logCare({
|
||||||
|
plantId,
|
||||||
|
careType,
|
||||||
|
notes: notes || null,
|
||||||
|
performedAt: performedAt || undefined,
|
||||||
|
});
|
||||||
|
setNotes("");
|
||||||
|
setPerformedAt("");
|
||||||
|
onSuccess?.();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to log care. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="care-type" className="text-sm font-medium">
|
||||||
|
Care type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="care-type"
|
||||||
|
value={careType}
|
||||||
|
onChange={(e) => setCareType(e.target.value)}
|
||||||
|
className="input input-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{CARE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="performed-at" className="text-sm font-medium">
|
||||||
|
Date & time (optional — defaults to now)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="performed-at"
|
||||||
|
type="datetime-local"
|
||||||
|
value={performedAt}
|
||||||
|
onChange={(e) => setPerformedAt(e.target.value)}
|
||||||
|
className="input input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="care-notes" className="text-sm font-medium">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="care-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={2000}
|
||||||
|
className="input input-sm resize-none"
|
||||||
|
placeholder="Any observations…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm self-start" disabled={isPending}>
|
||||||
|
{isPending ? "Logging…" : "Log care"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
deleteCareSchedule,
|
||||||
|
scheduleOnCalendar,
|
||||||
|
toggleCareSchedule,
|
||||||
|
upsertCareSchedule,
|
||||||
|
} from "../server/actions";
|
||||||
|
import type { CalendarDto } from "../server/calendar-bridge";
|
||||||
|
import type { CareScheduleDto } from "../server/queries";
|
||||||
|
|
||||||
|
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plantId: string;
|
||||||
|
schedules: CareScheduleDto[];
|
||||||
|
calendars: CalendarDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function daysLabel(n: number | null): string {
|
||||||
|
if (n === null) return "No due date";
|
||||||
|
if (n < 0) return `${Math.abs(n)} day${Math.abs(n) !== 1 ? "s" : ""} overdue`;
|
||||||
|
if (n === 0) return "Due today";
|
||||||
|
return `Due in ${n} day${n !== 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [careType, setCareType] = useState("watering");
|
||||||
|
const [intervalDays, setIntervalDays] = useState("7");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
|
||||||
|
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
|
||||||
|
const [reminderMinutes, setReminderMinutes] = useState("");
|
||||||
|
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleAddSchedule(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const days = parseInt(intervalDays, 10);
|
||||||
|
if (isNaN(days) || days < 1 || days > 365) {
|
||||||
|
setFormError("Interval must be between 1 and 365 days.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await upsertCareSchedule({ plantId, careType, intervalDays: days });
|
||||||
|
setShowForm(false);
|
||||||
|
setCareType("watering");
|
||||||
|
setIntervalDays("7");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setFormError("Failed to save schedule.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteCareSchedule({ id });
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggle(id: string, enabled: boolean) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleCareSchedule({ id, enabled });
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScheduleOnCalendar(scheduleId: string) {
|
||||||
|
if (!selectedCalendarId) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await scheduleOnCalendar({
|
||||||
|
scheduleId,
|
||||||
|
calendarId: selectedCalendarId,
|
||||||
|
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
|
||||||
|
});
|
||||||
|
setCalendarOpenId(null);
|
||||||
|
setCalendarSuccess(scheduleId);
|
||||||
|
setTimeout(() => setCalendarSuccess(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide">
|
||||||
|
Schedules
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "Add schedule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleAddSchedule}
|
||||||
|
className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
<label htmlFor="sched-care-type" className="text-xs font-medium">
|
||||||
|
Care type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sched-care-type"
|
||||||
|
value={careType}
|
||||||
|
onChange={(e) => setCareType(e.target.value)}
|
||||||
|
className="input input-xs"
|
||||||
|
>
|
||||||
|
{CARE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-28">
|
||||||
|
<label htmlFor="sched-interval" className="text-xs font-medium">
|
||||||
|
Interval (days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sched-interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={intervalDays}
|
||||||
|
onChange={(e) => setIntervalDays(e.target.value)}
|
||||||
|
className="input input-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formError && <p className="text-xs text-red-500">{formError}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary btn-xs self-start" disabled={isPending}>
|
||||||
|
{isPending ? "Saving…" : "Add"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedules.length === 0 && !showForm && (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">No schedules yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedules.map((s) => (
|
||||||
|
<div key={s.id} className="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between gap-2 p-2 rounded-lg border ${
|
||||||
|
s.isOverdue
|
||||||
|
? "border-red-300 bg-red-50 dark:bg-red-950/20"
|
||||||
|
: "border-[var(--ink-faint)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium capitalize">{s.careType}</span>
|
||||||
|
<span className="text-xs text-[var(--ink-mute)]">
|
||||||
|
Every {s.intervalDays} days · {daysLabel(s.daysUntilDue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{calendars.length > 0 && s.nextDueAt && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCalendarOpenId(calendarOpenId === s.id ? null : s.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Add to calendar"
|
||||||
|
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||||
|
>
|
||||||
|
📅
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(s.id, !s.enabled)}
|
||||||
|
disabled={isPending}
|
||||||
|
title={s.enabled ? "Disable" : "Enable"}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
|
||||||
|
s.enabled
|
||||||
|
? "border-green-400 text-green-700 dark:text-green-400"
|
||||||
|
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.enabled ? "Active" : "Paused"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(s.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Delete schedule"
|
||||||
|
className="text-[var(--ink-mute)] hover:text-red-500 text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calendarOpenId === s.id && (
|
||||||
|
<div className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg border border-[var(--ink-faint)] ml-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
<label htmlFor={`cal-select-${s.id}`} className="text-xs font-medium">
|
||||||
|
Calendar
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`cal-select-${s.id}`}
|
||||||
|
value={selectedCalendarId}
|
||||||
|
onChange={(e) => setSelectedCalendarId(e.target.value)}
|
||||||
|
className="input input-xs"
|
||||||
|
>
|
||||||
|
{calendars.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-28">
|
||||||
|
<label htmlFor={`remind-${s.id}`} className="text-xs font-medium">
|
||||||
|
Remind (min)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`remind-${s.id}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
placeholder="optional"
|
||||||
|
value={reminderMinutes}
|
||||||
|
onChange={(e) => setReminderMinutes(e.target.value)}
|
||||||
|
className="input input-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleScheduleOnCalendar(s.id)}
|
||||||
|
disabled={isPending || !selectedCalendarId}
|
||||||
|
className="btn btn-primary btn-xs"
|
||||||
|
>
|
||||||
|
{isPending ? "Scheduling…" : "Schedule"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCalendarOpenId(null)} className="btn btn-ghost btn-xs">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{calendarSuccess === s.id && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
|
||||||
|
Event added to calendar.{" "}
|
||||||
|
<Link href="/calendar" className="underline">
|
||||||
|
View in calendar →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { deleteContainer } from "../server/actions";
|
||||||
|
import { ContainerForm } from "./container-form";
|
||||||
|
import type { ContainerDetailDto } from "../server/queries";
|
||||||
|
|
||||||
|
type Props = { container: ContainerDetailDto };
|
||||||
|
|
||||||
|
export function ContainerDetail({ container }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteContainer({ id: container.id });
|
||||||
|
router.push("/garden");
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{container.coverImageUrl && (
|
||||||
|
<img
|
||||||
|
src={container.coverImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-h-48 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{container.name}</h1>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{confirming ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="font-medium mb-3">Edit container</h2>
|
||||||
|
<ContainerForm
|
||||||
|
existing={container}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
|
||||||
|
<a
|
||||||
|
href={`/garden/plants/new?containerId=${container.id}`}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
+ Add plant
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{container.plants.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{container.plants.map((p) => (
|
||||||
|
<a
|
||||||
|
key={p.id}
|
||||||
|
href={`/garden/plants/${p.id}`}
|
||||||
|
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{p.primaryImageUrl && (
|
||||||
|
<img
|
||||||
|
src={p.primaryImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{p.name}</p>
|
||||||
|
{p.scientificName && (
|
||||||
|
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
|
||||||
|
>
|
||||||
|
{p.healthStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { createContainer, updateContainer } from "../server/actions";
|
||||||
|
import type { ContainerDto } from "../server/queries";
|
||||||
|
|
||||||
|
const CONTAINER_TYPES = [
|
||||||
|
{ value: "shelf", label: "Shelf" },
|
||||||
|
{ value: "terrarium", label: "Terrarium" },
|
||||||
|
{ value: "raised-bed", label: "Raised bed" },
|
||||||
|
{ value: "window-box", label: "Window box" },
|
||||||
|
{ value: "single-pot", label: "Single pot" },
|
||||||
|
{ value: "outdoor", label: "Outdoor" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
existing?: ContainerDto;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContainerForm({ existing, onSuccess, onCancel }: Props) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const input = {
|
||||||
|
name: fd.get("name") as string,
|
||||||
|
type: fd.get("type") as string,
|
||||||
|
locationNotes: (fd.get("locationNotes") as string) || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
if (existing) {
|
||||||
|
await updateContainer({ id: existing.id, ...input });
|
||||||
|
} else {
|
||||||
|
await createContainer(input);
|
||||||
|
}
|
||||||
|
formRef.current?.reset();
|
||||||
|
onSuccess?.();
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="container-name" className="text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="container-name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
defaultValue={existing?.name}
|
||||||
|
placeholder="e.g. Living Room Shelf"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="container-type" className="text-sm font-medium">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select id="container-type" name="type" defaultValue={existing?.type ?? "other"}>
|
||||||
|
{CONTAINER_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="container-notes" className="text-sm font-medium">
|
||||||
|
Location notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="container-notes"
|
||||||
|
name="locationNotes"
|
||||||
|
maxLength={500}
|
||||||
|
rows={2}
|
||||||
|
defaultValue={existing?.locationNotes ?? ""}
|
||||||
|
placeholder="Optional — e.g. south-facing window"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{onCancel && (
|
||||||
|
<button type="button" onClick={onCancel} className="btn btn-ghost" disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||||
|
{isPending ? "Saving…" : existing ? "Save" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ContainerForm } from "./container-form";
|
||||||
|
import type { ContainerDto } from "../server/queries";
|
||||||
|
|
||||||
|
type Props = { containers: ContainerDto[] };
|
||||||
|
|
||||||
|
export function ContainerList({ containers }: Props) {
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Containers</h2>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => setShowNew(true)}>
|
||||||
|
New container
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNew && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="font-medium mb-3">New container</h3>
|
||||||
|
<ContainerForm
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowNew(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowNew(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{containers.length === 0 && !showNew && (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">
|
||||||
|
No containers yet. Add one to start organising your plants.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{containers.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
href={`/garden/containers/${c.id}`}
|
||||||
|
className="card p-4 hover:bg-[var(--surface-2)] transition-colors"
|
||||||
|
>
|
||||||
|
{c.coverImageUrl && (
|
||||||
|
<img src={c.coverImageUrl} alt="" className="w-full h-32 object-cover rounded mb-3" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-medium leading-tight">{c.name}</span>
|
||||||
|
<span className="badge badge-outline text-xs shrink-0 capitalize">{c.type}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--ink-mute)] mt-1">
|
||||||
|
{c.plantCount} {c.plantCount === 1 ? "plant" : "plants"}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user