Compare commits
40 Commits
6f8fe9a798
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7b119c1a | |||
| 862f5627c7 | |||
| 083e2992c9 | |||
| 2ad9ac53f8 | |||
| f351ed3846 | |||
|
|
a50f7eaa49 | ||
|
|
fe24cd73be | ||
|
|
5319e8af2f | ||
|
|
cc212c87c7 | ||
|
|
e65d28524b | ||
|
|
25b1ba9aeb | ||
| 8f77b7c453 | |||
|
|
dd7af9e8d4 | ||
|
|
6b75e47ee9 | ||
|
|
d3af826e3a | ||
|
|
70df86ba59 | ||
|
|
67bc23c64e | ||
|
|
3b35e835c8 | ||
|
|
61dc8acacf | ||
|
|
c449b0c2ae | ||
|
|
e501679416 | ||
|
|
608a192877 | ||
|
|
62a0089ec2 | ||
|
|
0d52ea5ad1 | ||
|
|
f483630419 | ||
|
|
8ec43f3561 | ||
| ddc0a60a0c | |||
| 4aacffdf4e | |||
| b8af0f986b | |||
| 80324afea2 | |||
| 5f4c482636 | |||
| 63569aabd3 | |||
| cffa739056 | |||
| 39be24c2fc | |||
| 777569a21a | |||
| b0118d3fec | |||
| 0904790dca | |||
| c5bf108cb9 | |||
| cdb21ed58a | |||
| 9c0312676a |
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
@otm:registry=http://192.168.0.5:3022/api/packages/tonym/npm/
|
||||
@otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/
|
||||
|
||||
177
CLAUDE.md
177
CLAUDE.md
@@ -1,73 +1,144 @@
|
||||
# Moon Base — Project Notes
|
||||
|
||||
Property management for the Moon homestead (Eau Claire, WI) — food forest plant
|
||||
registry, home maintenance tracking, and equipment logs. Next.js 14 + Prisma +
|
||||
Property management for the Moon homestead (Eau Claire, WI). Next.js 14 + Prisma +
|
||||
Postgres. Same conventions as FMB (`/Users/bonnamoon/Projects/fullmoonbakehouse`).
|
||||
Repo on Gitea: **`bonna61/Moonbase`**. Live at NAS:8053. See `README.md` for the
|
||||
user-facing overview.
|
||||
|
||||
## Modules
|
||||
## Modules (all live)
|
||||
|
||||
- **v0.1 — Garden:** food forest plant registry + care/harvest log. Live.
|
||||
- **Home maintenance** — planned next: systems, recurring tasks, repair history.
|
||||
- **Equipment** — planned: tools/machines, service intervals, maintenance logs.
|
||||
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
|
||||
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
|
||||
Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
|
||||
for old rows — don't regress); a harvest can simultaneously create a pantry
|
||||
item (`addPlantLog`'s `pantry` input → GARDEN-source consumable). PlantTypes
|
||||
can link a UW–Madison Extension article (`uwExtensionUrl/Title`), searched via
|
||||
`/api/v1/plant-types/uw-search` (proxy to hort.extension.wisc.edu's WP REST
|
||||
API; helper `src/lib/garden/uw-extension.ts`).
|
||||
- **Inventory** — durable **Item**s that nest (`parentItemId`: a toolbox is an
|
||||
Item that holds Items, with a cycle guard), with **Label**s (M:N via
|
||||
`LabelOnItem`), `ItemAttachment` (PHOTO/MANUAL/RECEIPT/WARRANTY), and `ItemLog`.
|
||||
Items carry value/purchase/warranty (incl. `lifetimeWarranty`, `insured`),
|
||||
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
|
||||
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
|
||||
import** at `/api/v1/items/import` (pure parser `src/lib/inventory/homebox-import.ts`).
|
||||
`kind` is DURABLE|CONSUMABLE. Shares the Location spine.
|
||||
- **Pantry** (Kitchen → Pantry & Freezer) — CONSUMABLE Items with categories,
|
||||
price paid, tags (shared `Label` table), and expiry. Pulls go through
|
||||
`/api/v1/pantry/[id]/use` or `/bulk-use` and write `ItemLog` type `USED` with a
|
||||
structured `amount` (consumption is queryable — don't regress to NOTE strings).
|
||||
Service `src/lib/services/pantry.ts`; pure logic + tests in `src/lib/pantry/core.ts`.
|
||||
Rides the `items:*` scopes (pantry items ARE Items).
|
||||
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
|
||||
tree, and `Task`s can link to an animal (`Task.animalId`).
|
||||
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
|
||||
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
|
||||
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
|
||||
all tasks (garden + house) plus PlantType bloom/harvest windows
|
||||
(`src/lib/garden/season.ts`) and garden-brain suggestions.
|
||||
- **Garden brain** — `src/lib/garden/knowledge.ts`: hand-curated zone-4b
|
||||
(~May 15 frost) care timing, companions/foes, rotation families for ~28
|
||||
crops; pure matchers `knowledgeFor`/`tipsForMonth` (whole-word stem match —
|
||||
beware "Pear"/"Pea"-style false hits, tested). Surfaced as calendar
|
||||
"Good to do this month" (one-click YEARLY task via /api/v1/tasks) and a
|
||||
"Growing in zone 4b" card on type pages. Tips cite UW Extension via site
|
||||
search URLs — never fabricate article URLs.
|
||||
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
|
||||
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
|
||||
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
|
||||
- **Self-update** — admin "Pull & rebuild" (see Deploy).
|
||||
|
||||
## Data model (the one rule)
|
||||
|
||||
**Two nesting worlds, crossed once.** *Places* (`Location`, self-ref `parentId`,
|
||||
unlimited depth — `LocationKind` is an icon hint, never a constraint) nest in
|
||||
places. *Items* (things you own) nest in items, and an Item lives in a Place **or**
|
||||
inside another Item. You never put a Place inside an Item. **Plants** and
|
||||
**Animals** are leaves in a Place (own logs, don't nest). HomeBox + Grocy + farmOS
|
||||
collapse into: **Places · Plants/Animals (leaves) · Items (durable + consumable,
|
||||
nesting)**. Logs are per-domain tables (`PlantLog`/`ItemLog`/`AnimalLog`), NOT a
|
||||
polymorphic table; `Task` carries nullable `plantId`/`itemId`/`animalId`.
|
||||
|
||||
## Architecture conventions
|
||||
|
||||
- **API-first.** New modules (Inventory, Animals) are built on `/api/v1/*` and the
|
||||
UI calls those same endpoints — no separate UI routes. Garden/Tasks predate this
|
||||
and still use `/api/plants` etc.
|
||||
- **Service layer** `src/lib/services/*` does DB work + AuditEvent, taking
|
||||
`(ctx: AuthContext, input)`. Routes are thin: `authenticateRequest(req, scope)` →
|
||||
service → `handleApiError`. The MCP server (unbuilt) will reuse these services.
|
||||
- **Auth helper** `src/lib/api/authenticate.ts` accepts EITHER a NextAuth cookie
|
||||
session (full scope `["*"]`) OR a Bearer PAT (limited to its `scopes`; write
|
||||
implies read). Scope constants in `src/lib/api/scopes.ts` (client-safe).
|
||||
- **Decimals** for money/quantity (`@db.Decimal`) — convert to `Number()` before
|
||||
passing to client components (Prisma Decimal isn't serializable across the boundary).
|
||||
|
||||
## Blocked on a domain + HTTPS (deferred)
|
||||
|
||||
Camera scanning/photo-capture (`getUserMedia` needs a secure context), the **PWA**
|
||||
(service worker + install need HTTPS), and the **MCP server** (Claude needs a public
|
||||
HTTPS URL) all wait until there's a domain + reverse-proxy/tunnel. The app is
|
||||
LAN-only over `http://NAS:8053` today. Pantry/consumables and other pure web work
|
||||
can ship before then.
|
||||
|
||||
## Local dev
|
||||
|
||||
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`
|
||||
- Copy `.env.example` → `.env` and fill in values.
|
||||
- `npm install` then `npm run dev` (port 3000)
|
||||
- After schema edits: `npx prisma generate` then restart dev server.
|
||||
- Seed demo data: `npm run db:seed` (creates Bonna + Tony accounts + sample plants)
|
||||
- Minimal seed (admin accounts only): `SEED_MODE=minimal npm run db:seed`
|
||||
- Apply migrations: `npx prisma migrate deploy`
|
||||
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example` → `.env`.
|
||||
- `npm install`, `npm run dev` (3000). After schema edits: `npx prisma generate`.
|
||||
- `npx prisma migrate deploy` provisions Bonna + Tony. `npm run db:seed` for demo
|
||||
data (`SEED_MODE=minimal` = accounts only).
|
||||
- Backfills for the location/plant-type refactors: `npm run db:backfill-locations`,
|
||||
`npm run db:backfill-plant-types`.
|
||||
|
||||
## First-run setup (brand new machine / fresh DB)
|
||||
## Deploy — git checkout on the NAS (NOT rsync)
|
||||
|
||||
1. `npm install`
|
||||
2. Create `.env` from `.env.example`
|
||||
3. `npx prisma migrate deploy` — creates the DB schema + provisions Bonna and Tony accounts
|
||||
4. Default password for both is `moonbase!` with `mustChangePassword=true` — change on first login.
|
||||
> Working without this file (e.g. a Claude on another machine)? The self-contained
|
||||
> version is `docs/git-deploy-guide.md`.
|
||||
|
||||
The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
|
||||
`origin/master` (remote URL carries the token). Deploy by:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
|
||||
```
|
||||
|
||||
Or admins click **Settings → Updates → Pull & rebuild**, which signals the
|
||||
privileged `updater` sidecar (docker socket + adnanh/webhook) to run the same
|
||||
`git pull && compose up -d --build app` detached; the UI polls `/api/version` and
|
||||
reloads. **Never rsync over the appdata dir** — it fights the checkout.
|
||||
|
||||
- **Secrets** (`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a
|
||||
gitignored host `.env` next to the compose file, interpolated in — never committed.
|
||||
- **Docker runtime** mirrors FMB: `node:20-bookworm-slim` + openssl, runs as root,
|
||||
full `node_modules`, `next start`. Migrations run on container start via the
|
||||
entrypoint (waits for Postgres first). Image gotcha: don't let `npx prisma`
|
||||
resolve at runtime — with no local prisma on PATH it pulls Prisma 7, which
|
||||
rejects `url` in the datasource.
|
||||
|
||||
## Conventions (same as FMB)
|
||||
|
||||
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
|
||||
entry at the top of `src/lib/changelog.ts`. Minor (0.X.0) for features/migrations,
|
||||
patch for fixes. Write changelog for Bonna, not engineers.
|
||||
- **AuditEvent on every mutation** — append-only, never delete.
|
||||
- **Soft-delete** via `active=false` for Plant (keeps log history intact).
|
||||
- **Pure core + colocated test** — new logic goes in `src/lib/...` as a pure
|
||||
function with a `.test.ts` next to it; API routes stay thin.
|
||||
- **Worktrees for parallel sessions** — same pattern as FMB. Use a worktree
|
||||
(`git worktree add ../moonbase-<slug> -b <slug>`) if running two Claude sessions.
|
||||
|
||||
## Git remote
|
||||
|
||||
Add Gitea remote once the repo is created there:
|
||||
```sh
|
||||
git remote add gitea http://192.168.0.5:3022/tonym/moonbase.git
|
||||
git push gitea main
|
||||
```
|
||||
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
|
||||
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
|
||||
**Fetch first**: parallel sessions push to master — check
|
||||
`git show origin/master:src/lib/version.ts` before picking a number
|
||||
(a v0.22.0 collision has already happened once).
|
||||
- **AuditEvent on every mutation** — append-only.
|
||||
- **Soft-delete** via `active=false`.
|
||||
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
|
||||
|
||||
## Auth
|
||||
|
||||
- Username OR email login (same as FMB). Accounts: `bonna` / `tony`, role ADMIN.
|
||||
- `mustChangePassword=true` on provisioned accounts — user is prompted on next login
|
||||
(add that redirect to `src/app/(dashboard)/layout.tsx` when implementing it).
|
||||
- `getSession()` / `isAdmin()` in `src/lib/auth.ts`.
|
||||
|
||||
## Garden module — data model
|
||||
|
||||
- **Plant** — commonName, species (scientific), variety, category (PlantCategory enum),
|
||||
zone (freeform text: "Back yard north fence"), plantedAt, source, notes, active.
|
||||
- **PlantLog** — per-plant log: type (OBSERVATION / CARE / HARVEST / TREATMENT /
|
||||
TRANSPLANT / DIED), date, notes, quantity (freeform, for harvests).
|
||||
- Category labels + log type labels/colors live in `src/lib/garden/categories.ts`.
|
||||
- API: `POST /api/plants` (create plant), `POST /api/plants/[id]/logs` (add log entry).
|
||||
- Username OR email login. Accounts `bonna` / `tony`, role `ADMIN`.
|
||||
- `getSession()` / `requireAuth()` / `isAdmin()` in `src/lib/auth.ts`.
|
||||
- **Forced password change is wired** (provisioned accounts ship `moonbase!` +
|
||||
`mustChangePassword=true`): the dashboard layout redirects to `/change-password`
|
||||
(outside the layout group; backed by `/api/account/change-password`). Don't
|
||||
remove that redirect.
|
||||
- `NEXTAUTH_SECRET` is asserted at runtime in production (skipped during build).
|
||||
|
||||
## Color palette
|
||||
|
||||
Forest / earth theme. Key CSS variables:
|
||||
- `--canopy` — dark forest green sidebar
|
||||
- `--leaf` — fresh green accent
|
||||
- `--soil` / `--soil-muted` — body text
|
||||
- `--dew` — pale sage background
|
||||
- `--ember` — red for destructive actions
|
||||
Forest / earth theme: `--canopy` (sidebar), `--leaf` (accent), `--soil` (text),
|
||||
`--dew` (bg), `--ember` (destructive).
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -10,6 +10,17 @@ COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
# Workspace-style freshness for the shared OTM UI package: every image build
|
||||
# installs the latest published @otm/account-panel on top of the lockfile, so
|
||||
# rebuilds pick up package releases without needing a pin-bump commit (the
|
||||
# package.json pin is the dev-time floor). The ADD pulls the registry
|
||||
# packument, which only changes on a publish — so this layer stays cached
|
||||
# between publishes and busts exactly when a new version exists. Moonbase
|
||||
# builds on the NAS itself, so the LAN host is always reachable.
|
||||
COPY .npmrc ./
|
||||
ADD http://192.168.0.5:3022/api/packages/tonym/npm/@otm/account-panel /tmp/otm-account-panel.packument.json
|
||||
RUN npm install --no-save --no-audit --no-fund @otm/account-panel@latest
|
||||
|
||||
# ---- build ----
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Moon Base
|
||||
|
||||
Property management for the Moon homestead (Eau Claire, WI). A single place to
|
||||
track the food forest, household inventory, and recurring upkeep — built as a
|
||||
Next.js 14 + Prisma + Postgres app, deployed self-hosted on the home NAS.
|
||||
|
||||
Live at **http://192.168.0.5:8053**.
|
||||
|
||||
## Modules
|
||||
|
||||
- **Garden** — plant registry with care/harvest/observation logs, plant **groups**,
|
||||
reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
|
||||
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
|
||||
location tree.
|
||||
- **Inventory** — durable **items** that nest (a toolbox holds the tools inside
|
||||
it), with labels, photos + manuals/receipts/warranty docs, value & warranty
|
||||
tracking (with expiry warnings), serial/brand, a sold record, and custom fields
|
||||
(e.g. a vehicle's VIN). Schedule recurring **maintenance** per item. **Import
|
||||
straight from HomeBox** via CSV. Shares the same location spine as the garden.
|
||||
- **Animals** — pets & livestock with their own logs (feeding, vet, weight, meds)
|
||||
and auto-computed age, placed on the shared location tree; reminders can be
|
||||
linked to an animal.
|
||||
- **Locations** — manage the whole place tree (areas, buildings, rooms, storage),
|
||||
nested as deep as you like; everything (plants, items, animals) lives here.
|
||||
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
|
||||
optionally linked to a plant, item, or animal.
|
||||
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
|
||||
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
|
||||
and automation.
|
||||
- **Suggestions** — in-app feedback that files issues to Gitea (`@otm/account-panel`).
|
||||
- **Backup & Restore** — download/restore the whole dataset as a zip (admin only).
|
||||
- **Self-update** — admins get a "Pull & rebuild" button that updates the running
|
||||
app to the latest pushed version (see Deploy).
|
||||
|
||||
## Tech
|
||||
|
||||
Next.js 14 (App Router) · Prisma · PostgreSQL · NextAuth (credentials) ·
|
||||
Tailwind + shadcn/ui · Docker Compose.
|
||||
|
||||
## Local dev
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cp .env.example .env # fill in DATABASE_URL + NEXTAUTH_SECRET
|
||||
npx prisma migrate deploy # schema + provisions bonna/tony accounts
|
||||
npm run db:seed # optional demo data (SEED_MODE=minimal for accounts only)
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
After schema edits: `npx prisma generate` then restart. Data backfills for the
|
||||
location/plant-type refactors: `npm run db:backfill-locations`,
|
||||
`npm run db:backfill-plant-types`.
|
||||
|
||||
## Auth / first run
|
||||
|
||||
Username **or** email login. Provisioned accounts `bonna` / `tony` (role `ADMIN`)
|
||||
ship with the default password `moonbase!` and `mustChangePassword=true` — the app
|
||||
forces a password change on first sign-in.
|
||||
|
||||
## Deploy
|
||||
|
||||
The NAS appdata (`/mnt/user/appdata/moonbase`) is a **git checkout** of this repo.
|
||||
Two ways to ship a change:
|
||||
|
||||
1. **In-app (admins):** Settings → Updates → **Pull & rebuild**. A privileged
|
||||
`updater` sidecar runs `git pull && docker compose up -d --build app` and the
|
||||
UI reloads on the new version.
|
||||
2. **Manual:**
|
||||
```sh
|
||||
git push origin master
|
||||
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
|
||||
```
|
||||
|
||||
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
|
||||
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
|
||||
`.env` next to the compose file, never committed. Migrations apply automatically
|
||||
on container start. Full deploy walkthrough: [`docs/git-deploy-guide.md`](docs/git-deploy-guide.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
Every commit bumps `APP_VERSION` (`src/lib/version.ts`) + a plain-English entry in
|
||||
`src/lib/changelog.ts` (written for Bonna, shown in the footer). `AuditEvent` on
|
||||
every mutation. Soft-delete via `active=false`. Pure logic in `src/lib/*` with
|
||||
colocated tests; API routes stay thin. Repo on Gitea: `bonna61/Moonbase`.
|
||||
@@ -14,4 +14,7 @@ echo "Running Prisma migrations..."
|
||||
echo "Running Location-spine backfill (idempotent)..."
|
||||
./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs."
|
||||
|
||||
echo "Running plant-type backfill (idempotent)..."
|
||||
./node_modules/.bin/tsx prisma/scripts/backfill-plant-types.ts || echo " Backfill skipped/failed (non-fatal); check logs."
|
||||
|
||||
exec "$@"
|
||||
|
||||
77
docs/git-deploy-guide.md
Normal file
77
docs/git-deploy-guide.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Editing & deploying Moon Base — a guide for any Claude instance
|
||||
|
||||
Read this if you're a Claude (or anyone) working on Moon Base **without** the full
|
||||
project `CLAUDE.md` in context — e.g. running on a different machine. It's the
|
||||
self-contained version of how to change the code and get it onto the live server.
|
||||
|
||||
## The repo
|
||||
|
||||
- Lives on the home NAS Gitea: `http://192.168.0.5:3022/bonna61/Moonbase`
|
||||
- Clone: `git clone http://tonym:<TOKEN>@192.168.0.5:3022/bonna61/Moonbase.git`
|
||||
(the API token lives in the workspace-level `~/Projects/CLAUDE.md` — **never commit it**)
|
||||
- Default branch: **`master`**.
|
||||
|
||||
## The golden rule: the NAS appdata is a GIT CHECKOUT, not a sync target
|
||||
|
||||
The running app on the NAS lives at `/mnt/user/appdata/moonbase`, which is a real
|
||||
`git clone` tracking `origin/master`. **You deploy by pushing to Gitea, then pulling
|
||||
on the NAS — never by copying files.** Do **not** `rsync` over the appdata dir; it
|
||||
fights the checkout and corrupts it.
|
||||
|
||||
## How to make a change
|
||||
|
||||
1. **Edit** code in your local working copy.
|
||||
2. **Bump the version:** set `APP_VERSION` in `src/lib/version.ts` and add a
|
||||
plain-English entry to the **top** of `src/lib/changelog.ts` — write it for
|
||||
Bonna (the non-technical user): what changed and why. Minor (`0.X.0`) for
|
||||
features/migrations, patch (`0.0.X`) for fixes. Docs-only changes don't need a bump.
|
||||
3. **Schema change?** Create a migration: `npx prisma migrate dev --create-only
|
||||
--name <slug>`, review the SQL, then `npx prisma migrate deploy` locally. The
|
||||
server applies pending migrations automatically on container start.
|
||||
4. **Verify locally:** `npx tsc --noEmit` and `npm run test`; run `npx next build`
|
||||
for anything non-trivial (it catches client/server boundary errors `tsc` misses —
|
||||
e.g. passing a Prisma `Decimal` to a client component).
|
||||
5. **Commit.** If you're Claude, end the message with the `Co-Authored-By:` trailer.
|
||||
|
||||
## How to deploy (two ways)
|
||||
|
||||
**Preferred — in-app, no SSH:** push, then an admin clicks **Settings → Updates →
|
||||
Pull & rebuild** in the live app. A privileged `updater` sidecar runs
|
||||
`git pull && docker compose up -d --build app` detached and the UI reloads.
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
# then click "Pull & rebuild" in the app
|
||||
```
|
||||
|
||||
**Manual — needs SSH to the NAS:**
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
|
||||
```
|
||||
|
||||
Confirm success: the footer version chip (or `GET /api/version`) should show the
|
||||
version you just shipped.
|
||||
|
||||
## Secrets — never commit them
|
||||
|
||||
`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET` live in a **gitignored host
|
||||
`.env`** next to the compose file on the NAS, interpolated into compose as `${VAR}`.
|
||||
They are not in the repo. Don't add them.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Don't let `npx prisma` resolve at runtime.** With no local prisma on PATH it
|
||||
pulls Prisma 7, which rejects `url` in the datasource. The Docker image ships the
|
||||
full `node_modules` so the pinned prisma stays on PATH.
|
||||
- **Migrations run on container start** (entrypoint waits for Postgres first). A bad
|
||||
migration blocks startup — always test `migrate deploy` locally first.
|
||||
- **First deploy of a big migration:** take a backup first (Settings → Backup &
|
||||
Restore → Download) — the data is live (Bonna's garden).
|
||||
- **Two people may be committing.** If you didn't write a file, don't sweep it into
|
||||
your commit — stage only your own paths (`git add <paths>`) and check `git status`
|
||||
before committing.
|
||||
|
||||
That's the whole story: **edit locally → bump version → commit → push → pull/rebuild
|
||||
on the NAS. Never rsync.**
|
||||
62
package-lock.json
generated
62
package-lock.json
generated
@@ -9,13 +9,14 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@otm/account-panel": "0.8.2",
|
||||
"@otm/account-panel": "0.22.1",
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -1025,13 +1026,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@otm/account-panel": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "http://192.168.0.5:3022/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.8.2/account-panel-0.8.2.tgz",
|
||||
"integrity": "sha512-E7uJ3Xxm8/xz6gcUAS4TUI+eCjaVZLFs7CjF7RpwSLUn3DpIszGSE4BN0519BQOThpQ69p3m15h/b64HLMgHgw==",
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://git.poweredbyotm.com/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.22.1/account-panel-0.22.1.tgz",
|
||||
"integrity": "sha512-JwPVqe5FPENt6nlwlZ91PX58Y2QvFINHLi385zH/f4tO5PvifDYVVqwmzF0TrQP9NaUwOiHJV2PgRdotlN3c7A==",
|
||||
"peerDependencies": {
|
||||
"@stripe/react-stripe-js": ">=2",
|
||||
"@stripe/stripe-js": ">=3",
|
||||
"next": ">=14",
|
||||
"next-auth": ">=4",
|
||||
"react": ">=18"
|
||||
"react": ">=18",
|
||||
"stripe": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@stripe/react-stripe-js": {
|
||||
"optional": true
|
||||
},
|
||||
"@stripe/stripe-js": {
|
||||
"optional": true
|
||||
},
|
||||
"stripe": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
@@ -1492,6 +1507,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
|
||||
"integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.13",
|
||||
"@radix-ui/react-focus-guards": "1.1.4",
|
||||
"@radix-ui/react-focus-scope": "1.1.10",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.3.1",
|
||||
"@radix-ui/react-portal": "1.1.12",
|
||||
"@radix-ui/react-presence": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.1.6",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts",
|
||||
"db:backfill-plant-types": "tsx prisma/scripts/backfill-plant-types.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"db:reset": "prisma migrate reset --force",
|
||||
"db:backup": "sh scripts/db-backup.sh",
|
||||
@@ -22,13 +23,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@otm/account-panel": "0.8.2",
|
||||
"@otm/account-panel": "0.22.1",
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- v0.11.0 — Plant types (the "kind" record)
|
||||
-- A rich-but-all-optional reference record per kind of plant, plus an optional
|
||||
-- link from each planting. Seeded/linked by prisma/scripts/backfill-plant-types.ts.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlantType" (
|
||||
"id" TEXT NOT NULL,
|
||||
"commonName" TEXT NOT NULL,
|
||||
"species" TEXT,
|
||||
"category" "PlantCategory" NOT NULL DEFAULT 'OTHER',
|
||||
"careNotes" TEXT,
|
||||
"sun" TEXT,
|
||||
"water" TEXT,
|
||||
"bloomStart" INTEGER,
|
||||
"bloomEnd" INTEGER,
|
||||
"harvestStart" INTEGER,
|
||||
"harvestEnd" INTEGER,
|
||||
"toxicToPets" BOOLEAN,
|
||||
"edibleParts" TEXT,
|
||||
"spacing" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"notes" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlantType_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlantType_commonName_idx" ON "PlantType"("commonName");
|
||||
CREATE INDEX "PlantType_category_idx" ON "PlantType"("category");
|
||||
CREATE INDEX "PlantType_active_idx" ON "PlantType"("active");
|
||||
|
||||
-- AlterTable: optional link from a planting to its kind
|
||||
ALTER TABLE "Plant" ADD COLUMN "plantTypeId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Plant_plantTypeId_idx" ON "Plant"("plantTypeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Plant" ADD CONSTRAINT "Plant_plantTypeId_fkey" FOREIGN KEY ("plantTypeId") REFERENCES "PlantType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- v0.12.0 — API tokens (personal access tokens for the REST API + MCP)
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"scopes" TEXT[],
|
||||
"userId" TEXT NOT NULL,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
|
||||
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal file
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal file
@@ -0,0 +1,143 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ItemKind" AS ENUM ('DURABLE', 'CONSUMABLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ItemLogType" AS ENUM ('NOTE', 'MAINTENANCE', 'MOVED', 'PURCHASED', 'REPAIRED', 'DISPOSED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlantGroup" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "itemId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Item" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"kind" "ItemKind" NOT NULL DEFAULT 'DURABLE',
|
||||
"quantity" DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||
"unit" TEXT,
|
||||
"locationId" TEXT,
|
||||
"parentItemId" TEXT,
|
||||
"value" DECIMAL(10,2),
|
||||
"purchaseDate" TIMESTAMP(3),
|
||||
"warrantyExpiry" TIMESTAMP(3),
|
||||
"serial" TEXT,
|
||||
"modelNumber" TEXT,
|
||||
"brand" TEXT,
|
||||
"barcode" TEXT,
|
||||
"bestBefore" TIMESTAMP(3),
|
||||
"minStock" DECIMAL(10,2),
|
||||
"qrSlug" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"notes" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Label" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
|
||||
CONSTRAINT "Label_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LabelOnItem" (
|
||||
"itemId" TEXT NOT NULL,
|
||||
"labelId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "LabelOnItem_pkey" PRIMARY KEY ("itemId","labelId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ItemAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ItemAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ItemLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"type" "ItemLogType" NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"notes" TEXT,
|
||||
"cost" DECIMAL(10,2),
|
||||
"actorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ItemLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Item_qrSlug_key" ON "Item"("qrSlug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_locationId_idx" ON "Item"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_parentItemId_idx" ON "Item"("parentItemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_kind_idx" ON "Item"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_active_idx" ON "Item"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_name_idx" ON "Item"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_barcode_idx" ON "Item"("barcode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LabelOnItem_labelId_idx" ON "LabelOnItem"("labelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemAttachment_itemId_idx" ON "ItemAttachment"("itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemLog_itemId_idx" ON "ItemLog"("itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemLog_date_idx" ON "ItemLog"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_itemId_idx" ON "Task"("itemId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Item" ADD CONSTRAINT "Item_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Item" ADD CONSTRAINT "Item_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_labelId_fkey" FOREIGN KEY ("labelId") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemAttachment" ADD CONSTRAINT "ItemAttachment_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemLog" ADD CONSTRAINT "ItemLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
66
prisma/migrations/20260616051637_v0_14_animals/migration.sql
Normal file
66
prisma/migrations/20260616051637_v0_14_animals/migration.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnimalLogType" AS ENUM ('NOTE', 'FEEDING', 'VET', 'WEIGHT', 'MEDICATION', 'BREEDING', 'ACQUIRED', 'DIED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "animalId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Animal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"species" TEXT,
|
||||
"breed" TEXT,
|
||||
"sex" TEXT,
|
||||
"locationId" TEXT,
|
||||
"birthdate" TIMESTAMP(3),
|
||||
"acquiredAt" TIMESTAMP(3),
|
||||
"source" TEXT,
|
||||
"notes" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Animal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnimalLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"animalId" TEXT NOT NULL,
|
||||
"type" "AnimalLogType" NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"notes" TEXT,
|
||||
"weight" DECIMAL(10,2),
|
||||
"actorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnimalLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_locationId_idx" ON "Animal"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_species_idx" ON "Animal"("species");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_active_idx" ON "Animal"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnimalLog_animalId_idx" ON "AnimalLog"("animalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnimalLog_date_idx" ON "AnimalLog"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_animalId_idx" ON "Task"("animalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Animal" ADD CONSTRAINT "Animal_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnimalLog" ADD CONSTRAINT "AnimalLog_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "insured" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "lifetimeWarranty" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "purchaseFrom" TEXT,
|
||||
ADD COLUMN "warrantyDetails" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "customFields" JSONB,
|
||||
ADD COLUMN "soldDate" TIMESTAMP(3),
|
||||
ADD COLUMN "soldNotes" TEXT,
|
||||
ADD COLUMN "soldPrice" DECIMAL(10,2),
|
||||
ADD COLUMN "soldTo" TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_locationId_idx" ON "Task"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v0.19 Pantry: add storedAt, source enum, and plantId to Item
|
||||
|
||||
CREATE TYPE "ItemSource" AS ENUM ('GARDEN', 'HOMEMADE', 'STORE', 'FARMERS_MARKET', 'GIFTED', 'OTHER');
|
||||
|
||||
ALTER TABLE "Item"
|
||||
ADD COLUMN "storedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "source" "ItemSource",
|
||||
ADD COLUMN "plantId" TEXT;
|
||||
|
||||
ALTER TABLE "Item"
|
||||
ADD CONSTRAINT "Item_plantId_fkey"
|
||||
FOREIGN KEY ("plantId") REFERENCES "Plant"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Item_plantId_idx" ON "Item"("plantId");
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v0.20: pantry categories + price tracking
|
||||
|
||||
ALTER TABLE "Item"
|
||||
ADD COLUMN "pantryCategory" TEXT,
|
||||
ADD COLUMN "pantrySubcategory" TEXT,
|
||||
ADD COLUMN "pricePaid" DECIMAL(10,2),
|
||||
ADD COLUMN "priceUnit" TEXT;
|
||||
|
||||
CREATE INDEX "Item_pantryCategory_idx" ON "Item"("pantryCategory");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ItemLogType" ADD VALUE 'USED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlantLog" ADD COLUMN "amount" DECIMAL(10,2),
|
||||
ADD COLUMN "unit" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlantType" ADD COLUMN "uwExtensionTitle" TEXT,
|
||||
ADD COLUMN "uwExtensionUrl" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ramble" (
|
||||
"id" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Ramble_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ramble_pinned_archivedAt_createdAt_idx" ON "Ramble"("pinned", "archivedAt", "createdAt");
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
@@ -31,6 +31,29 @@ model User {
|
||||
mustChangePassword Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
apiTokens ApiToken[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API tokens — personal access tokens for the REST API + MCP server.
|
||||
// Only the SHA-256 hash of the raw token is stored; the raw value (mb_…) is
|
||||
// shown once at creation. `scopes` gate what a token can do (e.g. "plants:read").
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(cuid())
|
||||
name String // human label ("Home Assistant", "phone shortcut")
|
||||
tokenHash String @unique // sha256(raw)
|
||||
prefix String // first 8 chars of the raw token, shown in the UI
|
||||
scopes String[] // e.g. ["plants:read","items:write"]; ["*"] = full
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lastUsedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -49,6 +72,20 @@ model AuditEvent {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Ramble box — sticky-note brain dump for stray homestead thoughts ("move the
|
||||
// rhubarb", "oil the chainsaw"). Not a Task and not a Location leaf; just a
|
||||
// free-text jot you pin, archive (soft), or delete (hard). Ported from FMB.
|
||||
model Ramble {
|
||||
id String @id @default(cuid())
|
||||
body String
|
||||
pinned Boolean @default(false)
|
||||
archivedAt DateTime? // soft-archive; null = active
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([pinned, archivedAt, createdAt])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
|
||||
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
||||
@@ -77,6 +114,9 @@ model Location {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
plants Plant[]
|
||||
items Item[]
|
||||
animals Animal[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([parentId])
|
||||
@@index([kind])
|
||||
@@ -96,6 +136,7 @@ enum PlantCategory {
|
||||
GROUNDCOVER // strawberries, clover, creeping thyme
|
||||
VINE // grapes, hops, kiwi
|
||||
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
||||
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
|
||||
ANNUAL // legacy, treated as annual vegetable
|
||||
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
||||
PERENNIAL // asparagus, rhubarb, perennial veg
|
||||
@@ -126,6 +167,8 @@ model Plant {
|
||||
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
|
||||
locationId String? // shared Location spine
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
plantTypeId String? // optional link to the "kind" reference record
|
||||
plantType PlantType? @relation(fields: [plantTypeId], references: [id], onDelete: SetNull)
|
||||
plantedAt DateTime? // when it went in the ground
|
||||
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
|
||||
notes String? // general notes about this plant
|
||||
@@ -137,14 +180,49 @@ model Plant {
|
||||
updatedAt DateTime @updatedAt
|
||||
logs PlantLog[]
|
||||
tasks Task[]
|
||||
pantryItems Item[]
|
||||
|
||||
@@index([category])
|
||||
@@index([zone])
|
||||
@@index([locationId])
|
||||
@@index([plantTypeId])
|
||||
@@index([active])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
// PlantType — the "kind" of plant (e.g. "Lily of the valley"), holding general
|
||||
// reusable reference info. Rich but every field optional; seeded from the
|
||||
// built-in plant library (src/lib/garden/plant-db.ts). Individual Plant rows
|
||||
// optionally point at their type.
|
||||
model PlantType {
|
||||
id String @id @default(cuid())
|
||||
commonName String
|
||||
species String? // scientific name
|
||||
category PlantCategory @default(OTHER)
|
||||
careNotes String? // general care: pruning, watering, feeding
|
||||
sun String? // "Full sun", "Part shade", "Shade"
|
||||
water String? // "Low", "Moderate", "High"
|
||||
bloomStart Int? // month 1–12
|
||||
bloomEnd Int? // month 1–12
|
||||
harvestStart Int? // month 1–12
|
||||
harvestEnd Int? // month 1–12
|
||||
toxicToPets Boolean? // null = unknown
|
||||
edibleParts String? // "Fruit, young leaves"
|
||||
spacing String? // "10–15 ft", "18 in"
|
||||
imageUrl String?
|
||||
uwExtensionUrl String? // linked UW–Madison Extension article (hort.extension.wisc.edu)
|
||||
uwExtensionTitle String? // its title, shown as the link text
|
||||
notes String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
plants Plant[]
|
||||
|
||||
@@index([commonName])
|
||||
@@index([category])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
enum PlantLogType {
|
||||
OBSERVATION // general note — "looking healthy", "saw first flowers"
|
||||
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
|
||||
@@ -179,6 +257,12 @@ model Task {
|
||||
category TaskCategory @default(GARDEN)
|
||||
plantId String?
|
||||
plant Plant? @relation(fields: [plantId], references: [id])
|
||||
itemId String?
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
||||
animalId String?
|
||||
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
recurrence RecurrenceType @default(YEARLY)
|
||||
intervalDays Int? // for CUSTOM: every N days
|
||||
month Int? // 1–12: for YEARLY, which month
|
||||
@@ -191,6 +275,9 @@ model Task {
|
||||
|
||||
@@index([nextDue])
|
||||
@@index([plantId])
|
||||
@@index([itemId])
|
||||
@@index([animalId])
|
||||
@@index([locationId])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
@@ -212,10 +299,217 @@ model PlantLog {
|
||||
type PlantLogType
|
||||
date DateTime @default(now())
|
||||
notes String?
|
||||
quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful"
|
||||
quantity String? // legacy freeform harvests ("a big handful") — new logs use amount+unit
|
||||
amount Decimal? @db.Decimal(10, 2) // structured harvest quantity (queryable totals)
|
||||
unit String? // "lbs", "each", "quarts" — pairs with amount
|
||||
actorId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([plantId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory — things you own. Durable items (tools, gear) and, later,
|
||||
// consumables (food, supplies) share this one table, told apart by `kind`.
|
||||
// Items nest: an Item lives in a Location OR inside another Item (toolbox →
|
||||
// drill). Consumable-only fields (barcode, bestBefore, minStock) are unused
|
||||
// by the durable UI but present so Pantry needs no schema change.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum ItemKind {
|
||||
DURABLE // tools, gear, equipment — "where is it?"
|
||||
CONSUMABLE // food, feed, supplies — "do I have it?"
|
||||
}
|
||||
|
||||
enum ItemSource {
|
||||
GARDEN // grown/harvested here
|
||||
HOMEMADE // made/preserved ourselves
|
||||
STORE // grocery or big-box store
|
||||
FARMERS_MARKET // local market
|
||||
GIFTED // given by someone
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Item {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
kind ItemKind @default(DURABLE)
|
||||
quantity Decimal @default(1) @db.Decimal(10, 2)
|
||||
unit String? // "each", "lbs", "bottles"
|
||||
|
||||
// Where it lives — a place, or inside another item (containers are items too).
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
parentItemId String?
|
||||
parentItem Item? @relation("ItemNesting", fields: [parentItemId], references: [id], onDelete: SetNull)
|
||||
containedItems Item[] @relation("ItemNesting")
|
||||
|
||||
// Durable attributes
|
||||
value Decimal? @db.Decimal(10, 2) // purchase price
|
||||
purchaseDate DateTime?
|
||||
purchaseFrom String? // where it was bought
|
||||
warrantyExpiry DateTime?
|
||||
warrantyDetails String? // freeform warranty terms
|
||||
lifetimeWarranty Boolean @default(false)
|
||||
insured Boolean @default(false)
|
||||
serial String?
|
||||
modelNumber String?
|
||||
brand String? // brand / manufacturer
|
||||
|
||||
// Sold lifecycle (HomeBox parity)
|
||||
soldTo String?
|
||||
soldPrice Decimal? @db.Decimal(10, 2)
|
||||
soldDate DateTime?
|
||||
soldNotes String?
|
||||
|
||||
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
|
||||
customFields Json?
|
||||
|
||||
// Consumable attributes
|
||||
barcode String?
|
||||
storedAt DateTime? // date frozen / preserved / stored
|
||||
bestBefore DateTime? // expiry / best-by date
|
||||
minStock Decimal? @db.Decimal(10, 2)
|
||||
source ItemSource? // where it came from
|
||||
plantId String? // link to a garden plant (harvest source)
|
||||
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
||||
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
|
||||
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
|
||||
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
|
||||
priceUnit String? // "package", "lb", "oz", "kg", etc.
|
||||
|
||||
// Shared
|
||||
qrSlug String? @unique // short slug for a printed QR label
|
||||
imageUrl String?
|
||||
notes String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
labels LabelOnItem[]
|
||||
attachments ItemAttachment[]
|
||||
logs ItemLog[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([locationId])
|
||||
@@index([parentItemId])
|
||||
@@index([kind])
|
||||
@@index([active])
|
||||
@@index([name])
|
||||
@@index([barcode])
|
||||
@@index([plantId])
|
||||
@@index([pantryCategory])
|
||||
}
|
||||
|
||||
model Label {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String?
|
||||
items LabelOnItem[]
|
||||
}
|
||||
|
||||
model LabelOnItem {
|
||||
itemId String
|
||||
labelId String
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([itemId, labelId])
|
||||
@@index([labelId])
|
||||
}
|
||||
|
||||
model ItemAttachment {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
kind String // "PHOTO" | "MANUAL" | "RECEIPT"
|
||||
url String
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([itemId])
|
||||
}
|
||||
|
||||
enum ItemLogType {
|
||||
NOTE
|
||||
MAINTENANCE
|
||||
MOVED
|
||||
PURCHASED
|
||||
REPAIRED
|
||||
DISPOSED
|
||||
USED // consumable pulled from the pantry; `amount` holds how much
|
||||
}
|
||||
|
||||
model ItemLog {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
type ItemLogType
|
||||
date DateTime @default(now())
|
||||
notes String?
|
||||
cost Decimal? @db.Decimal(10, 2)
|
||||
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
||||
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
|
||||
actorId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([itemId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animals & pets — livestock and household pets, leaves in a Location with
|
||||
// their own care log (vet, feeding, weight, meds) and recurring reminders.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum AnimalLogType {
|
||||
NOTE
|
||||
FEEDING
|
||||
VET
|
||||
WEIGHT
|
||||
MEDICATION
|
||||
BREEDING
|
||||
ACQUIRED
|
||||
DIED
|
||||
}
|
||||
|
||||
model Animal {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
species String? // "Dog", "Cat", "Chicken", "Goat"
|
||||
breed String?
|
||||
sex String? // freeform: "Female", "Male", "Wether"
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
birthdate DateTime?
|
||||
acquiredAt DateTime?
|
||||
source String?
|
||||
notes String?
|
||||
imageUrl String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
logs AnimalLog[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([locationId])
|
||||
@@index([species])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
model AnimalLog {
|
||||
id String @id @default(cuid())
|
||||
animalId String
|
||||
animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
|
||||
type AnimalLogType
|
||||
date DateTime @default(now())
|
||||
notes String?
|
||||
weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
|
||||
actorId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([animalId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
23
prisma/scripts/backfill-plant-types.ts
Normal file
23
prisma/scripts/backfill-plant-types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* v0.11.0 Plant-type backfill. Seeds the PlantType library from the built-in
|
||||
* plant DB and links existing plantings to their kind. Idempotent — runs on
|
||||
* deploy (docker-entrypoint.sh) and can be run manually:
|
||||
*
|
||||
* npx tsx prisma/scripts/backfill-plant-types.ts
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { syncPlantTypesFromLibrary } from "../../src/lib/garden/plant-types";
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const r = await syncPlantTypesFromLibrary(db);
|
||||
console.log(`Plant types: library has ${r.types} kinds; linked ${r.linked} planting(s).`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("Plant-type backfill failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => db.$disconnect());
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaClient, PlantCategory } from "@prisma/client";
|
||||
import { hashSync } from "bcryptjs";
|
||||
import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types";
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
@@ -56,6 +57,25 @@ async function main() {
|
||||
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
|
||||
];
|
||||
|
||||
// Starter Location tree — a root property node with the garden areas under it.
|
||||
const root = await db.location.upsert({
|
||||
where: { id: "seed-loc-root" },
|
||||
update: {},
|
||||
create: { id: "seed-loc-root", name: "Moon Homestead", kind: "OTHER", path: "Moon Homestead" },
|
||||
});
|
||||
|
||||
const zoneNames = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[]));
|
||||
const zoneToLocId = new Map<string, string>();
|
||||
for (const z of zoneNames) {
|
||||
const id = `seed-loc-${z.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
await db.location.upsert({
|
||||
where: { id },
|
||||
update: {},
|
||||
create: { id, name: z, kind: "AREA", parentId: root.id, path: `Moon Homestead › ${z}` },
|
||||
});
|
||||
zoneToLocId.set(z, id);
|
||||
}
|
||||
|
||||
for (const p of plants) {
|
||||
await db.plant.upsert({
|
||||
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
|
||||
@@ -63,10 +83,15 @@ async function main() {
|
||||
create: {
|
||||
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
|
||||
...p,
|
||||
locationId: p.zone ? zoneToLocId.get(p.zone) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Seed the PlantType library and link the sample plants to their kind.
|
||||
const pt = await syncPlantTypesFromLibrary(db);
|
||||
console.log(`Seeded ${pt.types} plant types; linked ${pt.linked} plantings.`);
|
||||
|
||||
console.log("Demo seed complete.");
|
||||
}
|
||||
|
||||
|
||||
14
scripts/reset-password.ts
Normal file
14
scripts/reset-password.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { hashSync } from "bcryptjs";
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const hash = hashSync("moonbase!", 10);
|
||||
const result = await db.user.updateMany({
|
||||
data: { passwordHash: hash, mustChangePassword: false },
|
||||
});
|
||||
console.log(`Reset password for ${result.count} user(s). Login with: moonbase!`);
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => db.$disconnect());
|
||||
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ChevronLeft, MapPin, CalendarDays, Cake, Package, Scale, PawPrint } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { ageString } from "@/lib/animals/age";
|
||||
import { ANIMAL_LOG_LABELS, ANIMAL_LOG_COLORS } from "@/lib/animals/animal-fields";
|
||||
import { EditAnimalButton, type AnimalFields } from "@/components/animals/animal-dialog";
|
||||
import { DeleteAnimalButton } from "@/components/animals/delete-animal-button";
|
||||
import { AddAnimalLogButton } from "@/components/animals/add-animal-log-button";
|
||||
|
||||
function toDateInput(d: Date | null): string {
|
||||
return d ? new Date(d).toISOString().split("T")[0] : "";
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
const a = await db.animal.findUnique({ where: { id: params.id }, select: { name: true } });
|
||||
return { title: a?.name ?? "Animal" };
|
||||
}
|
||||
|
||||
export default async function AnimalDetailPage({ params }: { params: { id: string } }) {
|
||||
const [animal, locations] = await Promise.all([
|
||||
db.animal.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { location: { select: { id: true, name: true } }, logs: { orderBy: { date: "desc" } } },
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
if (!animal || !animal.active) notFound();
|
||||
|
||||
const age = ageString(animal.birthdate, new Date());
|
||||
const latestWeight = animal.logs.find((l) => l.type === "WEIGHT" && l.weight != null);
|
||||
|
||||
const editFields: AnimalFields = {
|
||||
id: animal.id,
|
||||
name: animal.name,
|
||||
species: animal.species ?? "",
|
||||
breed: animal.breed ?? "",
|
||||
sex: animal.sex ?? "",
|
||||
locationId: animal.locationId ?? "",
|
||||
birthdate: toDateInput(animal.birthdate),
|
||||
acquiredAt: toDateInput(animal.acquiredAt),
|
||||
source: animal.source ?? "",
|
||||
notes: animal.notes ?? "",
|
||||
};
|
||||
|
||||
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
|
||||
if (animal.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: animal.location.name });
|
||||
if (animal.sex) facts.push({ icon: <PawPrint className="h-4 w-4" />, label: "Sex", value: animal.sex });
|
||||
if (animal.birthdate) facts.push({ icon: <Cake className="h-4 w-4" />, label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
|
||||
if (animal.acquiredAt) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Acquired", value: formatDate(animal.acquiredAt) });
|
||||
if (animal.source) facts.push({ icon: <Package className="h-4 w-4" />, label: "From", value: animal.source });
|
||||
if (latestWeight?.weight != null) facts.push({ icon: <Scale className="h-4 w-4" />, label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/animals" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{animal.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{[animal.species, animal.breed].filter(Boolean).join(" · ")}</p>
|
||||
</div>
|
||||
{age && <Badge variant="secondary" className="ml-auto shrink-0">{age}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EditAnimalButton animal={editFields} locations={locations} />
|
||||
<AddAnimalLogButton animalId={animal.id} />
|
||||
<DeleteAnimalButton id={animal.id} name={animal.name} />
|
||||
</div>
|
||||
|
||||
{animal.imageUrl && (
|
||||
<div className="rounded-lg overflow-hidden h-56 bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={animal.imageUrl} alt={animal.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{facts.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{f.label}</p>
|
||||
<p className="truncate">{f.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{animal.notes && (
|
||||
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{animal.notes}</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">Care log</h2>
|
||||
{animal.logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
No entries yet — log a feeding, vet visit, or weigh-in above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{animal.logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${ANIMAL_LOG_COLORS[log.type]}`}>
|
||||
{ANIMAL_LOG_LABELS[log.type]}
|
||||
</span>
|
||||
{log.weight != null && <span className="text-xs text-muted-foreground">· {Number(log.weight)} lbs</span>}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||
</div>
|
||||
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/animals/page.tsx
Normal file
92
src/app/(dashboard)/animals/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
|
||||
import { ageString } from "@/lib/animals/age";
|
||||
import { AddAnimalButton } from "@/components/animals/animal-dialog";
|
||||
|
||||
export const metadata = { title: "Animals" };
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export default async function AnimalsPage() {
|
||||
const [animals, locations] = await Promise.all([
|
||||
db.animal.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Group by species ("Other" bucket last).
|
||||
const groups = new Map<string, typeof animals>();
|
||||
for (const a of animals) {
|
||||
const key = a.species?.trim() || "Other";
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(a);
|
||||
}
|
||||
const sorted = Array.from(groups.entries()).sort((a, b) =>
|
||||
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Animals & pets</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{animals.length} {animals.length === 1 ? "animal" : "animals"}
|
||||
</p>
|
||||
</div>
|
||||
<AddAnimalButton locations={locations} />
|
||||
</div>
|
||||
|
||||
{animals.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No animals yet</p>
|
||||
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sorted.map(([species, list]) => (
|
||||
<div key={species} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{list.map((a) => {
|
||||
const age = ageString(a.birthdate, now);
|
||||
return (
|
||||
<Link key={a.id} href={`/animals/${a.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
||||
{a.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
|
||||
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
|
||||
</p>
|
||||
</div>
|
||||
{a.location?.name && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<MapPin className="h-2.5 w-2.5" />{a.location.name}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/app/(dashboard)/calendar/page.tsx
Normal file
76
src/app/(dashboard)/calendar/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { CalendarView, type CalendarTask, type SeasonType } from "@/components/calendar/calendar-view";
|
||||
|
||||
export const metadata = { title: "Calendar" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CalendarPage() {
|
||||
const [tasks, types, plants] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { nextDue: "asc" },
|
||||
include: {
|
||||
plant: { select: { commonName: true, variety: true } },
|
||||
animal: { select: { name: true } },
|
||||
item: { select: { name: true } },
|
||||
location: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
db.plantType.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
plants: { some: { active: true } },
|
||||
OR: [
|
||||
{ bloomStart: { not: null } },
|
||||
{ bloomEnd: { not: null } },
|
||||
{ harvestStart: { not: null } },
|
||||
{ harvestEnd: { not: null } },
|
||||
],
|
||||
},
|
||||
orderBy: { commonName: "asc" },
|
||||
select: {
|
||||
id: true, commonName: true,
|
||||
bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true,
|
||||
},
|
||||
}),
|
||||
db.plant.findMany({
|
||||
where: { active: true },
|
||||
select: { commonName: true, plantType: { select: { commonName: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const calendarTasks: CalendarTask[] = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
category: t.category,
|
||||
nextDue: t.nextDue.toISOString(),
|
||||
recurrence: t.recurrence,
|
||||
intervalDays: t.intervalDays,
|
||||
month: t.month,
|
||||
linkedTo:
|
||||
(t.plant && `${t.plant.commonName}${t.plant.variety ? ` (${t.plant.variety})` : ""}`) ||
|
||||
t.animal?.name ||
|
||||
t.item?.name ||
|
||||
t.location?.name ||
|
||||
null,
|
||||
}));
|
||||
|
||||
const seasonTypes: SeasonType[] = types;
|
||||
|
||||
// Names the garden brain matches against: plants + their linked types.
|
||||
const plantNames = Array.from(
|
||||
new Set(plants.flatMap((p) => [p.commonName, p.plantType?.commonName]).filter((n): n is string => !!n)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Calendar</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Everything due around the homestead — garden and house — plus what's blooming and ready to pick.
|
||||
</p>
|
||||
</div>
|
||||
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Leaf, CalendarDays, Sprout } from "lucide-react";
|
||||
import { Leaf, Sprout, AlertTriangle, UtensilsCrossed } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export const metadata = { title: "Dashboard" };
|
||||
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
|
||||
const [plantCount, recentLogs] = await Promise.all([
|
||||
const thirtyDaysFromNow = new Date(Date.now() + 30 * 86_400_000);
|
||||
|
||||
const [plantCount, recentLogs, expiringItems] = await Promise.all([
|
||||
db.plant.count({ where: { active: true } }),
|
||||
db.plantLog.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
take: 5,
|
||||
include: { plant: { select: { commonName: true, variety: true } } },
|
||||
}),
|
||||
db.item.findMany({
|
||||
where: { kind: "CONSUMABLE", active: true, bestBefore: { lte: thirtyDaysFromNow } },
|
||||
orderBy: { bestBefore: "asc" },
|
||||
select: { id: true, name: true, bestBefore: true, quantity: true, unit: true },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
const firstName = session?.user.name.split(" ")[0] ?? "there";
|
||||
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/kitchen/pantry">
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<UtensilsCrossed className="h-4 w-4 text-amber-600" />
|
||||
Kitchen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{expiringItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing expiring soon</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-700">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{expiringItems.length} expiring soon
|
||||
</p>
|
||||
{expiringItems.slice(0, 3).map(i => (
|
||||
<p key={i.id} className="text-xs text-muted-foreground truncate">
|
||||
{i.name} · {new Date(i.bestBefore!).toLocaleDateString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react";
|
||||
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen, GraduationCap } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
||||
@@ -16,18 +16,22 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
|
||||
const [plant, allPlants, groups] = await Promise.all([
|
||||
const [plant, locations, groups] = await Promise.all([
|
||||
db.plant.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { logs: { orderBy: { date: "desc" } } },
|
||||
include: {
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
location: { select: { id: true, name: true } },
|
||||
plantType: { select: { id: true, commonName: true, uwExtensionUrl: true, uwExtensionTitle: true } },
|
||||
},
|
||||
}),
|
||||
db.plant.findMany({ where: { active: true }, select: { zone: true } }),
|
||||
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!plant || !plant.active) notFound();
|
||||
|
||||
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
const placeName = plant.location?.name ?? plant.zone ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -48,7 +52,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditPlantButton plant={plant} zones={zones} groups={groups} />
|
||||
<EditPlantButton plant={plant} locations={locations} groups={groups} />
|
||||
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
||||
</div>
|
||||
|
||||
@@ -60,10 +64,31 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2 text-sm">
|
||||
{plant.zone && (
|
||||
{placeName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span>{plant.zone}</span>
|
||||
<span>{placeName}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantType && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<Link href={`/garden/types/${plant.plantType.id}`} className="hover:text-foreground underline-offset-2 hover:underline">
|
||||
More about {plant.plantType.commonName}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantType?.uwExtensionUrl && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={plant.plantType.uwExtensionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-foreground underline-offset-2 hover:underline truncate"
|
||||
>
|
||||
UW Extension: {plant.plantType.uwExtensionTitle || "article"}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
@@ -87,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-lg font-semibold">Care & harvest log</h2>
|
||||
<AddLogButton plantId={plant.id} plantName={plant.commonName} />
|
||||
<AddLogButton plantId={plant.id} plantName={plant.commonName} locations={locations} />
|
||||
</div>
|
||||
|
||||
{plant.logs.length === 0 ? (
|
||||
@@ -104,8 +129,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
|
||||
{LOG_TYPE_LABELS[log.type]}
|
||||
</span>
|
||||
{log.quantity && (
|
||||
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
|
||||
{(log.amount != null || log.quantity) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {log.amount != null ? `${Number(log.amount)} ${log.unit ?? ""}`.trim() : log.quantity}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { AddPlantButton } from "@/components/garden/add-plant-button";
|
||||
import { GardenGrid } from "@/components/garden/garden-grid";
|
||||
import { GardenJournal } from "@/components/garden/garden-journal";
|
||||
|
||||
export const metadata = { title: "Garden" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GardenPage() {
|
||||
const [plants, groups] = await Promise.all([
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
|
||||
const [plants, groups, locations, recentLogs] = await Promise.all([
|
||||
db.plant.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ commonName: "asc" }],
|
||||
@@ -13,6 +16,7 @@ export default async function GardenPage() {
|
||||
_count: { select: { logs: true } },
|
||||
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
||||
group: { select: { id: true, name: true } },
|
||||
location: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
db.plantGroup.findMany({
|
||||
@@ -20,12 +24,30 @@ export default async function GardenPage() {
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { plants: true } } },
|
||||
}),
|
||||
db.location.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
db.plantLog.findMany({
|
||||
where: { date: { gte: twoWeeksAgo }, plant: { active: true } },
|
||||
orderBy: { date: "desc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true, type: true, date: true, notes: true, quantity: true,
|
||||
amount: true, unit: true,
|
||||
plant: { select: { id: true, commonName: true, variety: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
const journal = recentLogs.map((l) => ({
|
||||
...l,
|
||||
amount: l.amount != null ? Number(l.amount) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Food Forest</h1>
|
||||
@@ -33,10 +55,12 @@ export default async function GardenPage() {
|
||||
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
|
||||
</p>
|
||||
</div>
|
||||
<AddPlantButton groups={groups} zones={zones} />
|
||||
<AddPlantButton groups={groups} locations={locations} />
|
||||
</div>
|
||||
|
||||
<GardenGrid plants={plants} groups={groups} />
|
||||
<GardenGrid plants={plants} groups={groups} locations={locations} />
|
||||
|
||||
<GardenJournal entries={journal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
256
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
256
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
||||
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
|
||||
} from "lucide-react";
|
||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||
import { knowledgeFor, uwSearchUrl, ROTATION_LABELS } from "@/lib/garden/knowledge";
|
||||
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
|
||||
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
|
||||
|
||||
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
function monthRange(start: number | null, end: number | null): string | null {
|
||||
if (start && end) return `${MONTHS[start]}–${MONTHS[end]}`;
|
||||
if (start) return `from ${MONTHS[start]}`;
|
||||
if (end) return `through ${MONTHS[end]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
const t = await db.plantType.findUnique({ where: { id: params.id }, select: { commonName: true } });
|
||||
return { title: t?.commonName ?? "Plant type" };
|
||||
}
|
||||
|
||||
// Garden-brain card: companions, antagonists, rotation family, and the
|
||||
// zone-4b care timeline for this kind of plant (when we know it).
|
||||
function GrowingTogether({ name }: { name: string }) {
|
||||
const k = knowledgeFor(name);
|
||||
if (!k) return null;
|
||||
|
||||
const hasCompanions = (k.friends?.length ?? 0) > 0 || (k.foes?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Growing {k.name.toLowerCase()} in zone 4b
|
||||
</p>
|
||||
<a
|
||||
href={uwSearchUrl(k.uwQuery)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
UW Extension <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{k.tips.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{k.tips.map((t) => (
|
||||
<li key={t.action} className="flex items-start gap-2">
|
||||
<Sprout className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span>
|
||||
<span className="text-muted-foreground">{t.months.map((m) => MONTHS[m]).join(", ")}:</span>{" "}
|
||||
{t.action}
|
||||
{t.detail && <span className="text-muted-foreground"> — {t.detail}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasCompanions && (
|
||||
<div className="flex flex-col gap-1 pt-2 border-t">
|
||||
{k.friends && k.friends.length > 0 && (
|
||||
<p className="flex items-start gap-2">
|
||||
<ThumbsUp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span><span className="text-muted-foreground">Grows well with:</span> {k.friends.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
{k.foes && k.foes.length > 0 && (
|
||||
<p className="flex items-start gap-2">
|
||||
<ThumbsDown className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--ember))]" />
|
||||
<span><span className="text-muted-foreground">Keep away from:</span> {k.foes.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(k.family || k.rotationNote) && (
|
||||
<p className="flex items-start gap-2 pt-2 border-t">
|
||||
<Repeat className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
{k.family && <span className="text-muted-foreground">Rotation family: {ROTATION_LABELS[k.family]}.</span>}
|
||||
{k.rotationNote && <span> {k.rotationNote}</span>}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
|
||||
const type = await db.plantType.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
plants: {
|
||||
where: { active: true },
|
||||
orderBy: { commonName: "asc" },
|
||||
include: { location: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!type || !type.active) notFound();
|
||||
|
||||
const bloom = monthRange(type.bloomStart, type.bloomEnd);
|
||||
const harvest = monthRange(type.harvestStart, type.harvestEnd);
|
||||
|
||||
const formFields: PlantTypeFields = {
|
||||
id: type.id,
|
||||
commonName: type.commonName,
|
||||
species: type.species,
|
||||
category: type.category,
|
||||
careNotes: type.careNotes,
|
||||
sun: type.sun,
|
||||
water: type.water,
|
||||
bloomStart: type.bloomStart,
|
||||
bloomEnd: type.bloomEnd,
|
||||
harvestStart: type.harvestStart,
|
||||
harvestEnd: type.harvestEnd,
|
||||
toxicToPets: type.toxicToPets,
|
||||
edibleParts: type.edibleParts,
|
||||
spacing: type.spacing,
|
||||
uwExtensionUrl: type.uwExtensionUrl,
|
||||
uwExtensionTitle: type.uwExtensionTitle,
|
||||
notes: type.notes,
|
||||
};
|
||||
|
||||
const facts: { icon: React.ReactNode; label: string; value: string }[] = [];
|
||||
if (type.sun) facts.push({ icon: <Sun className="h-4 w-4" />, label: "Sun", value: type.sun });
|
||||
if (type.water) facts.push({ icon: <Droplets className="h-4 w-4" />, label: "Water", value: type.water });
|
||||
if (bloom) facts.push({ icon: <Flower2 className="h-4 w-4" />, label: "Blooms", value: bloom });
|
||||
if (harvest) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Harvest", value: harvest });
|
||||
if (type.edibleParts) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Edible", value: type.edibleParts });
|
||||
if (type.spacing) facts.push({ icon: <Ruler className="h-4 w-4" />, label: "Spacing", value: type.spacing });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/garden/types" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{type.commonName}</h1>
|
||||
{type.species && <p className="text-sm text-muted-foreground italic">{type.species}</p>}
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-auto shrink-0">{CATEGORY_LABELS[type.category]}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditPlantTypeButton plantType={formFields} />
|
||||
<DeletePlantTypeButton id={type.id} name={type.commonName} />
|
||||
</div>
|
||||
|
||||
{type.toxicToPets && (
|
||||
<div className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))]">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Toxic to pets — keep away from animals.
|
||||
</div>
|
||||
)}
|
||||
{type.toxicToPets === false && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="text-[hsl(var(--leaf))]">✓</span> Safe for pets.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
{facts.map((fct, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{fct.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{fct.label}</p>
|
||||
<p className="truncate">{fct.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{type.uwExtensionUrl && (
|
||||
<a
|
||||
href={type.uwExtensionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 text-sm rounded-lg border px-3 py-2.5 hover:bg-accent transition-colors"
|
||||
>
|
||||
<GraduationCap className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="text-muted-foreground">UW Extension:</span>{" "}
|
||||
{type.uwExtensionTitle || "article"}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 ml-auto shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<GrowingTogether name={type.commonName} />
|
||||
|
||||
{(type.careNotes || type.notes) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3 text-sm">
|
||||
{type.careNotes && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Care</p>
|
||||
<p className="whitespace-pre-wrap">{type.careNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
{type.notes && (
|
||||
<div className={type.careNotes ? "pt-2 border-t" : ""}>
|
||||
<p className="whitespace-pre-wrap">{type.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">
|
||||
Your plantings {type.plants.length > 0 && <span className="text-sm text-muted-foreground font-normal">· {type.plants.length}</span>}
|
||||
</h2>
|
||||
{type.plants.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
None planted yet — this is general info you can keep regardless.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{type.plants.map((p) => (
|
||||
<Link key={p.id} href={`/garden/${p.id}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent transition-colors text-sm">
|
||||
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span className="font-medium">
|
||||
{p.commonName}{p.variety && <span className="text-muted-foreground font-normal"> · {p.variety}</span>}
|
||||
</span>
|
||||
{p.location?.name && (
|
||||
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />{p.location.name}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BookOpen, ChevronRight, Leaf, AlertTriangle } from "lucide-react";
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||
import { AddPlantTypeButton } from "@/components/garden/plant-type-dialog";
|
||||
|
||||
export const metadata = { title: "Plant types" };
|
||||
|
||||
export default async function PlantTypesPage() {
|
||||
const types = await db.plantType.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { commonName: "asc" },
|
||||
include: { _count: { select: { plants: { where: { active: true } } } } },
|
||||
});
|
||||
|
||||
const byCategory = CATEGORY_ORDER.map((cat) => ({
|
||||
category: cat,
|
||||
types: types.filter((t) => t.category === cat),
|
||||
})).filter((g) => g.types.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Plant types</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
General knowledge about each kind of plant — care, sun, bloom & harvest, toxicity.
|
||||
{" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
|
||||
</p>
|
||||
</div>
|
||||
<AddPlantTypeButton />
|
||||
</div>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No plant types yet</p>
|
||||
<p className="text-sm mt-1">Add one, or they'll appear here as you add plants.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{byCategory.map((group) => (
|
||||
<div key={group.category} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{CATEGORY_LABELS[group.category]}
|
||||
</p>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{group.types.map((t) => (
|
||||
<Link key={t.id} href={`/garden/types/${t.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
||||
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{t.commonName}</p>
|
||||
{t.species && <p className="text-xs text-muted-foreground italic truncate">{t.species}</p>}
|
||||
</div>
|
||||
{t.toxicToPets && (
|
||||
<Badge variant="outline" className="gap-1 text-xs text-[hsl(var(--ember))] border-[hsl(var(--ember)/.4)]">
|
||||
<AlertTriangle className="h-3 w-3" /> Toxic
|
||||
</Badge>
|
||||
)}
|
||||
{t._count.plants > 0 && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t._count.plants} planted
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
275
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
|
||||
Store, ShieldCheck, Wrench, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
|
||||
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
|
||||
import { isOverdue, describeRecurrence } from "@/lib/tasks/schedule";
|
||||
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
|
||||
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
|
||||
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
|
||||
import { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button";
|
||||
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
|
||||
|
||||
function toDateInput(d: Date | null): string {
|
||||
return d ? new Date(d).toISOString().split("T")[0] : "";
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
const item = await db.item.findUnique({ where: { id: params.id }, select: { name: true } });
|
||||
return { title: item?.name ?? "Item" };
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params }: { params: { id: string } }) {
|
||||
const [item, locations, allItems, labels] = await Promise.all([
|
||||
db.item.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
location: { select: { id: true, name: true } },
|
||||
parentItem: { select: { id: true, name: true } },
|
||||
labels: { include: { label: true } },
|
||||
attachments: { orderBy: { createdAt: "asc" } },
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||
tasks: { where: { active: true }, orderBy: { nextDue: "asc" } },
|
||||
},
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.item.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.label.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!item || !item.active) notFound();
|
||||
|
||||
const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date());
|
||||
const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus];
|
||||
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
|
||||
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
|
||||
|
||||
const editFields: ItemFields = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
quantity: String(Number(item.quantity)),
|
||||
unit: item.unit ?? "",
|
||||
locationId: item.locationId ?? "",
|
||||
parentItemId: item.parentItemId ?? "",
|
||||
brand: item.brand ?? "",
|
||||
modelNumber: item.modelNumber ?? "",
|
||||
serial: item.serial ?? "",
|
||||
value: item.value != null ? String(Number(item.value)) : "",
|
||||
purchaseDate: toDateInput(item.purchaseDate),
|
||||
purchaseFrom: item.purchaseFrom ?? "",
|
||||
warrantyExpiry: toDateInput(item.warrantyExpiry),
|
||||
warrantyDetails: item.warrantyDetails ?? "",
|
||||
lifetimeWarranty: item.lifetimeWarranty,
|
||||
insured: item.insured,
|
||||
soldTo: item.soldTo ?? "",
|
||||
soldPrice: item.soldPrice != null ? String(Number(item.soldPrice)) : "",
|
||||
soldDate: toDateInput(item.soldDate),
|
||||
soldNotes: item.soldNotes ?? "",
|
||||
customFields: Object.entries((item.customFields as Record<string, string>) ?? {}).map(([key, value]) => ({ key, value: String(value) })),
|
||||
barcode: item.barcode ?? "",
|
||||
notes: item.notes ?? "",
|
||||
labelIds: item.labels.map((l) => l.labelId),
|
||||
};
|
||||
|
||||
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
|
||||
if (item.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: item.location.name });
|
||||
if (item.parentItem) facts.push({ icon: <Box className="h-4 w-4" />, label: "Inside", value: <Link className="hover:underline" href={`/inventory/${item.parentItem.id}`}>{item.parentItem.name}</Link> });
|
||||
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
|
||||
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
|
||||
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
|
||||
if (item.purchaseFrom) facts.push({ icon: <Store className="h-4 w-4" />, label: "From", value: item.purchaseFrom });
|
||||
if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` });
|
||||
if (item.insured) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Insured", value: "Yes" });
|
||||
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
|
||||
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
|
||||
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
|
||||
if (item.barcode) facts.push({ icon: <Barcode className="h-4 w-4" />, label: "Barcode", value: item.barcode });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/inventory" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
|
||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
{(item.lifetimeWarranty || wStatus !== "none") && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
|
||||
<AddItemLogButton itemId={item.id} />
|
||||
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
|
||||
<AttachmentUpload itemId={item.id} />
|
||||
<DeleteItemButton id={item.id} name={item.name} />
|
||||
</div>
|
||||
|
||||
{item.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.labels.map((l) => <Badge key={l.labelId} variant="secondary">{l.label.name}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((p) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={p.id} src={p.url} alt={p.name ?? ""} className="h-28 w-full object-cover rounded-lg border" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{facts.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{f.label}</p>
|
||||
<p className="truncate">{f.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.notes && (
|
||||
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
|
||||
)}
|
||||
|
||||
{item.warrantyDetails && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Warranty</p>
|
||||
<p className="whitespace-pre-wrap">{item.warrantyDetails}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.customFields && Object.keys(item.customFields as Record<string, string>).length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{Object.entries(item.customFields as Record<string, string>).map(([k, v]) => (
|
||||
<div key={k} className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{k}</p>
|
||||
<p className="truncate">{String(v)}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.soldTo && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-sm space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sold</p>
|
||||
<p>
|
||||
To {item.soldTo}
|
||||
{item.soldPrice != null && ` for ${Number(item.soldPrice).toLocaleString(undefined, { style: "currency", currency: "USD" })}`}
|
||||
{item.soldDate && ` on ${formatDate(item.soldDate)}`}
|
||||
</p>
|
||||
{item.soldNotes && <p className="text-muted-foreground">{item.soldNotes}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold">Maintenance</h2>
|
||||
</div>
|
||||
{item.tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No maintenance scheduled. Use “Schedule maintenance” above to set a recurring reminder.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{item.tasks.map((t) => {
|
||||
const overdue = isOverdue(t.nextDue);
|
||||
return (
|
||||
<div key={t.id} className="flex items-center gap-2 px-3 py-2.5 text-sm">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate">{t.title}
|
||||
<span className="text-xs text-muted-foreground"> · {describeRecurrence(t.recurrence, t.intervalDays, t.month)}</span>
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs shrink-0 ${overdue ? "text-[hsl(var(--ember))]" : "text-muted-foreground"}`}>
|
||||
{overdue && <AlertCircle className="h-3 w-3" />}
|
||||
{overdue ? "Overdue " : "Due "}{formatDate(t.nextDue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{docs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="font-display text-sm font-semibold">Documents</h2>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{docs.map((d) => (
|
||||
<a key={d.id} href={d.url} target="_blank" rel="noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />{d.name ?? d.kind}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{d.kind}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.containedItems.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="font-display text-lg font-semibold">Contains</h2>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{item.containedItems.map((c) => (
|
||||
<Link key={c.id} href={`/inventory/${c.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
|
||||
<Package className="h-4 w-4 text-[hsl(var(--leaf))]" />{c.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">History</h2>
|
||||
{item.logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
No history yet — log maintenance, a repair, or a move above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{item.logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${ITEM_LOG_COLORS[log.type]}`}>
|
||||
{ITEM_LOG_LABELS[log.type]}
|
||||
</span>
|
||||
{log.cost != null && <span className="text-xs text-muted-foreground">· {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}</span>}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||
</div>
|
||||
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/app/(dashboard)/inventory/page.tsx
Normal file
66
src/app/(dashboard)/inventory/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { AddItemButton } from "@/components/inventory/item-dialog";
|
||||
import { ImportButton } from "@/components/inventory/import-button";
|
||||
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
|
||||
import { totalValue } from "@/lib/inventory/value";
|
||||
|
||||
export const metadata = { title: "Inventory" };
|
||||
|
||||
export default async function InventoryPage() {
|
||||
const [items, locations, labels] = await Promise.all([
|
||||
db.item.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
location: { select: { id: true, name: true } },
|
||||
parentItem: { select: { id: true, name: true } },
|
||||
labels: { include: { label: true } },
|
||||
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
|
||||
},
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.label.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
// Convert Prisma Decimals to plain numbers before handing to client components.
|
||||
const gridItems: GridItem[] = items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
quantity: Number(i.quantity),
|
||||
unit: i.unit,
|
||||
value: i.value != null ? Number(i.value) : null,
|
||||
warrantyExpiry: i.warrantyExpiry,
|
||||
imageUrl: i.imageUrl,
|
||||
locationId: i.locationId,
|
||||
parentItemId: i.parentItemId,
|
||||
location: i.location,
|
||||
labels: i.labels,
|
||||
_count: i._count,
|
||||
}));
|
||||
|
||||
const total = totalValue(items.map((i) => ({
|
||||
value: i.value != null ? Number(i.value) : null,
|
||||
quantity: Number(i.quantity),
|
||||
})));
|
||||
const itemOpts = items.map((i) => ({ id: i.id, name: i.name }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Inventory</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{items.length} {items.length === 1 ? "item" : "items"}
|
||||
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportButton />
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryGrid items={gridItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { sessionContext } from "@/lib/api/authenticate";
|
||||
import { listPantry } from "@/lib/services/pantry";
|
||||
import { PantryClient } from "@/components/pantry/pantry-client";
|
||||
|
||||
export const metadata = { title: "Pantry & Freezer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PantryPage() {
|
||||
const ctx = await sessionContext();
|
||||
const [items, locations, plants] = await Promise.all([
|
||||
listPantry(ctx),
|
||||
db.location.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
OR: [
|
||||
{ name: { equals: "Kitchen", mode: "insensitive" } },
|
||||
{ path: { contains: "Kitchen", mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { path: "asc" },
|
||||
select: { id: true, name: true, path: true },
|
||||
}),
|
||||
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
|
||||
]);
|
||||
|
||||
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { SuggestionLightbulb } from "@otm/account-panel";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopNav } from "@/components/layout/topnav";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
@@ -8,6 +10,18 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
// Force a password change before anything else (provisioned accounts ship with
|
||||
// a known default + mustChangePassword=true). Read the flag from the DB, not the
|
||||
// JWT: the cookie is only refreshed on re-login/update(), so trusting it would
|
||||
// keep bouncing the user back here even after they've set a new password. The
|
||||
// target page is outside this layout group, so there's no redirect loop.
|
||||
const account = await db.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { active: true, mustChangePassword: true },
|
||||
});
|
||||
if (!account || !account.active) redirect("/login");
|
||||
if (account.mustChangePassword) redirect("/change-password");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -18,6 +32,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
{/* Floating quick-capture idea/feedback widget (parity with dms + fmb).
|
||||
Mounted at the shell root — a fixed-position child of a backdrop-blur
|
||||
ancestor would get trapped. Posts to /api/suggestions → files a Gitea
|
||||
issue; "View all →" links to the full triage page. */}
|
||||
<SuggestionLightbulb panelHref="/suggestions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
src/app/(dashboard)/locations/page.tsx
Normal file
35
src/app/(dashboard)/locations/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { LocationManager } from "@/components/locations/location-manager";
|
||||
|
||||
export const metadata = { title: "Locations" };
|
||||
|
||||
export default async function LocationsPage() {
|
||||
const locations = await db.location.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
kind: true,
|
||||
parentId: true,
|
||||
_count: {
|
||||
select: {
|
||||
plants: { where: { active: true } },
|
||||
items: { where: { active: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Locations</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Manage the places on your property — nest them as deep as you like.
|
||||
</p>
|
||||
</div>
|
||||
<LocationManager locations={locations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/app/(dashboard)/ramble/page.tsx
Normal file
41
src/app/(dashboard)/ramble/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { RambleClient } from "./ramble-client";
|
||||
|
||||
export const metadata: Metadata = { title: "Ramble" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RamblePage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const [active, archived] = await Promise.all([
|
||||
db.ramble.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: [{ pinned: "desc" }, { createdAt: "desc" }],
|
||||
}),
|
||||
db.ramble.findMany({
|
||||
where: { archivedAt: { not: null } },
|
||||
orderBy: { archivedAt: "desc" },
|
||||
take: 30,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Prisma Dates don't serialize across the server/client boundary — send ISO strings.
|
||||
const serialize = (r: (typeof active)[number]) => ({
|
||||
id: r.id,
|
||||
body: r.body,
|
||||
pinned: r.pinned,
|
||||
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<RambleClient
|
||||
initialActive={active.map(serialize)}
|
||||
initialArchived={archived.map(serialize)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Pin, PinOff, Archive, ArchiveRestore, Trash2, StickyNote } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
type Ramble = {
|
||||
id: string;
|
||||
body: string;
|
||||
pinned: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// Rotating sticky-note colors (light + dark variants), keyed off note id so a
|
||||
// note keeps its color across refreshes rather than reshuffling.
|
||||
const STICKY = [
|
||||
"bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
|
||||
"bg-rose-50 border-rose-200 dark:bg-rose-950/40 dark:border-rose-900",
|
||||
"bg-emerald-50 border-emerald-200 dark:bg-emerald-950/40 dark:border-emerald-900",
|
||||
"bg-sky-50 border-sky-200 dark:bg-sky-950/40 dark:border-sky-900",
|
||||
"bg-violet-50 border-violet-200 dark:bg-violet-950/40 dark:border-violet-900",
|
||||
];
|
||||
function stickyColor(id: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return STICKY[h % STICKY.length];
|
||||
}
|
||||
|
||||
export function RambleClient({
|
||||
initialActive,
|
||||
initialArchived,
|
||||
}: {
|
||||
initialActive: Ramble[];
|
||||
initialArchived: Ramble[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [body, setBody] = useState("");
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const active = initialActive;
|
||||
const archived = initialArchived;
|
||||
|
||||
async function post(pinned: boolean) {
|
||||
const text = body.trim();
|
||||
if (!text) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
const res = await fetch("/api/rambles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: text, pinned }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Failed to save");
|
||||
setBody("");
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
toast({ title: "Couldn't save", description: String(err).replace("Error: ", "") });
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function mutate(id: string, patch: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/rambles/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast({ title: "Couldn't update", description: (await res.json()).error ?? "" });
|
||||
return;
|
||||
}
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!window.confirm("Delete this note? This can't be undone.")) return;
|
||||
const res = await fetch(`/api/rambles/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
toast({ title: "Couldn't delete", description: (await res.json()).error ?? "" });
|
||||
return;
|
||||
}
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold flex items-center gap-2">
|
||||
<StickyNote className="h-6 w-6 text-[hsl(var(--leaf))]" />
|
||||
Ramble
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A place to dump stray thoughts — chores, ideas, reminders that aren't tasks yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") post(false);
|
||||
}}
|
||||
placeholder="What's on your mind? (⌘/Ctrl+Enter to post)"
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => post(true)} disabled={posting || !body.trim()}>
|
||||
<Pin className="h-4 w-4" /> Post pinned
|
||||
</Button>
|
||||
<Button onClick={() => post(false)} disabled={posting || !body.trim()}>
|
||||
{posting ? "Posting…" : "Post"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active notes */}
|
||||
{active.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
No notes yet. Jot something above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{active.map((r) => (
|
||||
<NoteCard key={r.id} r={r} onMutate={mutate} onRemove={remove} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archived */}
|
||||
{archived.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={() => setShowArchived((v) => !v)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showArchived ? "Hide" : "Show"} archived ({archived.length})
|
||||
</button>
|
||||
{showArchived && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-3 opacity-70">
|
||||
{archived.map((r) => (
|
||||
<NoteCard key={r.id} r={r} archivedView onMutate={mutate} onRemove={remove} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCard({
|
||||
r,
|
||||
archivedView,
|
||||
onMutate,
|
||||
onRemove,
|
||||
}: {
|
||||
r: Ramble;
|
||||
archivedView?: boolean;
|
||||
onMutate: (id: string, patch: Record<string, unknown>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("group relative rounded-lg border p-3 shadow-sm", stickyColor(r.id))}>
|
||||
{r.pinned && !archivedView && (
|
||||
<Pin className="absolute right-2 top-2 h-3.5 w-3.5 text-[hsl(var(--leaf))] fill-current" />
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap break-words pr-4 min-h-[2rem]">{r.body}</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!archivedView && (
|
||||
<button
|
||||
title={r.pinned ? "Unpin" : "Pin"}
|
||||
onClick={() => onMutate(r.id, { pinned: !r.pinned })}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{r.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title={archivedView ? "Restore" : "Archive"}
|
||||
onClick={() => onMutate(r.id, { archived: !archivedView })}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{archivedView ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
title="Delete"
|
||||
onClick={() => onRemove(r.id)}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 text-[hsl(var(--ember))]"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +1,14 @@
|
||||
"use client";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession, isAdmin } from "@/lib/auth";
|
||||
import { BackupPanel } from "@/components/settings/backup-panel";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export const metadata: Metadata = { title: "Backup & Restore" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function BackupPage() {
|
||||
const [restoreStatus, setRestoreStatus] = useState<
|
||||
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null
|
||||
>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!file.name.endsWith(".zip")) {
|
||||
setRestoreStatus({ type: "error", message: "Please upload a .zip backup file." });
|
||||
return;
|
||||
}
|
||||
if (!confirm("This will REPLACE all current data with the backup. Are you sure?")) return;
|
||||
|
||||
setRestoring(true);
|
||||
setRestoreStatus(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/admin/restore", { method: "POST", body: form });
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Restore failed");
|
||||
setRestoreStatus({ type: "success", exportedAt: json.exportedAt });
|
||||
} catch (err) {
|
||||
setRestoreStatus({ type: "error", message: String(err) });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Backup & Restore</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Download a full backup of all Moon Base data, or restore from a previous backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Backup */}
|
||||
<section className="rounded-lg border p-6 space-y-3">
|
||||
<h2 className="font-semibold text-lg">Download Backup</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Downloads all plants, logs, tasks, and settings as a <code>.zip</code> file. Keep it somewhere safe.
|
||||
</p>
|
||||
<a href="/api/admin/backup" download>
|
||||
<Button className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Download backup
|
||||
</Button>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* Restore */}
|
||||
<section className="rounded-lg border p-6 space-y-4">
|
||||
<h2 className="font-semibold text-lg">Restore from Backup</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a <code>.zip</code> backup file to restore. <strong>This replaces everything</strong> — all current data will be overwritten.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer ${
|
||||
dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"}
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{restoreStatus?.type === "success" && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-green-50 border border-green-200 p-3 text-sm text-green-800">
|
||||
<CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
Restore complete. Data from backup exported on{" "}
|
||||
<strong>{new Date(restoreStatus.exportedAt).toLocaleString()}</strong> is now live.
|
||||
Reload the page to see it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restoreStatus?.type === "error" && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<div>{restoreStatus.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
export default async function BackupPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
if (!isAdmin(session.user.role)) redirect("/dashboard");
|
||||
return <BackupPanel />;
|
||||
}
|
||||
|
||||
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal file
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiTokensPanel } from "@/components/settings/api-tokens-panel";
|
||||
|
||||
export const metadata = { title: "API tokens" };
|
||||
|
||||
export default function TokensPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">API tokens</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Personal access tokens for the Moon Base API — use them with the{" "}
|
||||
<code className="text-xs">Authorization: Bearer …</code> header.
|
||||
</p>
|
||||
</div>
|
||||
<ApiTokensPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,8 @@ export const dynamic = "force-dynamic";
|
||||
export default async function SuggestionsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
// Render the panel bare (matches FMB). Earlier versions needed a white-card
|
||||
// wrapper for contrast, but the current @otm/account-panel is theme-aware —
|
||||
// the wrapper was fighting its own styles and hiding image thumbnails.
|
||||
return <SuggestionsPanel />;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ export default async function TasksPage() {
|
||||
const tasks = await db.task.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { nextDue: "asc" },
|
||||
include: { plant: { select: { id: true, commonName: true, variety: true } } },
|
||||
include: {
|
||||
plant: { select: { id: true, commonName: true, variety: true } },
|
||||
item: { select: { id: true, name: true } },
|
||||
animal: { select: { id: true, name: true } },
|
||||
location: { select: { id: true, name: true, path: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
|
||||
@@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: {
|
||||
function TaskCard({ task, overdue = false }: {
|
||||
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
||||
plant: { id: string; commonName: string; variety: string | null } | null;
|
||||
item: { id: string; name: string } | null;
|
||||
animal: { id: string; name: string } | null;
|
||||
location: { id: string; name: string; path: string | null } | null;
|
||||
};
|
||||
overdue?: boolean;
|
||||
}) {
|
||||
@@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: {
|
||||
</div>
|
||||
|
||||
{task.plant && (
|
||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
|
||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
||||
</Link>
|
||||
)}
|
||||
{task.item && (
|
||||
<Link href={`/inventory/${task.item.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.item.name}
|
||||
</Link>
|
||||
)}
|
||||
{task.animal && (
|
||||
<Link href={`/animals/${task.animal.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.animal.name}
|
||||
</Link>
|
||||
)}
|
||||
{task.location && (
|
||||
<span className="text-xs text-muted-foreground">📍 {task.location.path || task.location.name}</span>
|
||||
)}
|
||||
|
||||
{task.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
||||
|
||||
57
src/app/api/account/change-password/route.ts
Normal file
57
src/app/api/account/change-password/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const schema = z.object({
|
||||
currentPassword: z.string().min(1, "Enter your current password."),
|
||||
newPassword: z.string().min(8, "New password must be at least 8 characters."),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let session;
|
||||
try {
|
||||
session = await requireAuth();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (!(await compare(currentPassword, user.passwordHash))) {
|
||||
return NextResponse.json({ error: "Current password is incorrect." }, { status: 400 });
|
||||
}
|
||||
if (await compare(newPassword, user.passwordHash)) {
|
||||
return NextResponse.json(
|
||||
{ error: "New password must be different from your current one." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { passwordHash: await hash(newPassword, 10), mustChangePassword: false },
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: { action: "user.change_password", actorId: session.user.id, entityId: session.user.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
29
src/app/api/account/tokens/[id]/route.ts
Normal file
29
src/app/api/account/tokens/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
// DELETE /api/account/tokens/:id — revoke one of your own tokens.
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const token = await db.apiToken.findUnique({ where: { id: params.id } });
|
||||
if (!token || token.userId !== session.user.id)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await db.apiToken.update({ where: { id: params.id }, data: { revokedAt: new Date() } });
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "api_token.revoke",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: token.name },
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/account/tokens/route.ts
Normal file
75
src/app/api/account/tokens/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import {
|
||||
generateRawToken, hashToken, tokenPrefix, KNOWN_SCOPES,
|
||||
} from "@/lib/api/authenticate";
|
||||
|
||||
const scopeSchema = z.union([z.literal("*"), z.enum(KNOWN_SCOPES)]);
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
scopes: z.array(scopeSchema).min(1),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/account/tokens — list the current user's tokens (no secrets).
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const tokens = await db.apiToken.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true, name: true, prefix: true, scopes: true,
|
||||
lastUsedAt: true, expiresAt: true, revokedAt: true, createdAt: true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(tokens);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/account/tokens — create a token. The raw value is returned ONCE.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = createSchema.parse(await req.json());
|
||||
|
||||
const raw = generateRawToken();
|
||||
const token = await db.apiToken.create({
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
tokenHash: hashToken(raw),
|
||||
prefix: tokenPrefix(raw),
|
||||
scopes: data.scopes,
|
||||
userId: session.user.id,
|
||||
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
|
||||
},
|
||||
select: { id: true, name: true, prefix: true, scopes: true, expiresAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "api_token.create",
|
||||
entityId: token.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: token.name, scopes: token.scopes },
|
||||
},
|
||||
});
|
||||
|
||||
// `token` (raw) is shown to the user once and never stored in plaintext.
|
||||
return NextResponse.json({ ...token, token: raw }, { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,31 +10,52 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
||||
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
|
||||
labels, items, labelOnItems, itemAttachments, itemLogs, animals, animalLogs, auditEvents] =
|
||||
await Promise.all([
|
||||
db.location.findMany(),
|
||||
db.plantType.findMany(),
|
||||
db.user.findMany(),
|
||||
db.apiToken.findMany(),
|
||||
db.plantGroup.findMany(),
|
||||
db.plant.findMany(),
|
||||
db.plantLog.findMany(),
|
||||
db.task.findMany(),
|
||||
db.taskCompletion.findMany(),
|
||||
db.label.findMany(),
|
||||
db.item.findMany(),
|
||||
db.labelOnItem.findMany(),
|
||||
db.itemAttachment.findMany(),
|
||||
db.itemLog.findMany(),
|
||||
db.animal.findMany(),
|
||||
db.animalLog.findMany(),
|
||||
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
|
||||
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "animals", "animalLogs", "auditEvents"],
|
||||
};
|
||||
|
||||
const zip = zipSync({
|
||||
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
||||
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
|
||||
"plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)),
|
||||
"users.json": strToU8(JSON.stringify(users, null, 2)),
|
||||
"apiTokens.json": strToU8(JSON.stringify(apiTokens, null, 2)),
|
||||
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
|
||||
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
|
||||
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
|
||||
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
|
||||
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
|
||||
"labels.json": strToU8(JSON.stringify(labels, null, 2)),
|
||||
"items.json": strToU8(JSON.stringify(items, null, 2)),
|
||||
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
|
||||
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
|
||||
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
|
||||
"animals.json": strToU8(JSON.stringify(animals, null, 2)),
|
||||
"animalLogs.json": strToU8(JSON.stringify(animalLogs, null, 2)),
|
||||
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,57 +1,135 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unzipSync, strFromU8 } from "fflate";
|
||||
import { z } from "zod";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (!isAdmin(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
|
||||
// users is the security boundary, so it's validated strictly (known fields only,
|
||||
// role constrained to the enum). Other tables are app-owned data — we only check
|
||||
// they're arrays; Prisma rejects any unknown columns on insert.
|
||||
class BadBackup extends Error {}
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
username: z.string().nullable().optional(),
|
||||
name: z.string(),
|
||||
passwordHash: z.string(),
|
||||
role: z.nativeEnum(UserRole).optional(),
|
||||
active: z.boolean().optional(),
|
||||
mustChangePassword: z.boolean().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let session;
|
||||
try {
|
||||
session = await requireAuth();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!isAdmin(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
|
||||
const buf = await file.arrayBuffer();
|
||||
const files = unzipSync(new Uint8Array(buf));
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch {
|
||||
throw new BadBackup("not a valid zip");
|
||||
}
|
||||
|
||||
const parse = (name: string) => {
|
||||
if (!files[name]) throw new Error(`Missing ${name} in zip`);
|
||||
return JSON.parse(strFromU8(files[name]));
|
||||
const parse = (name: string): unknown => {
|
||||
if (!files[name]) throw new BadBackup(`missing ${name}`);
|
||||
try {
|
||||
return JSON.parse(strFromU8(files[name]));
|
||||
} catch {
|
||||
throw new BadBackup(`invalid JSON in ${name}`);
|
||||
}
|
||||
};
|
||||
const asArray = (name: string): unknown[] => {
|
||||
const v = parse(name);
|
||||
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
|
||||
return v;
|
||||
};
|
||||
|
||||
const manifest = parse("manifest.json");
|
||||
if (manifest.version !== 1) {
|
||||
const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
|
||||
if (manifest?.version !== 1) {
|
||||
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
||||
}
|
||||
|
||||
const users = parse("users.json");
|
||||
const plantGroups = parse("plantGroups.json");
|
||||
const plants = parse("plants.json");
|
||||
const plantLogs = parse("plantLogs.json");
|
||||
const tasks = parse("tasks.json");
|
||||
const taskCompletions = parse("taskCompletions.json");
|
||||
const auditEvents = parse("auditEvents.json");
|
||||
// Older backups won't have newer tables — tolerate their absence.
|
||||
const locations = files["locations.json"] ? asArray("locations.json") : [];
|
||||
const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
|
||||
const users = z.array(userSchema).parse(asArray("users.json"));
|
||||
const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
|
||||
const plantGroups = asArray("plantGroups.json");
|
||||
const plants = asArray("plants.json");
|
||||
const plantLogs = asArray("plantLogs.json");
|
||||
const tasks = asArray("tasks.json");
|
||||
const taskCompletions = asArray("taskCompletions.json");
|
||||
const labels = files["labels.json"] ? asArray("labels.json") : [];
|
||||
const items = files["items.json"] ? asArray("items.json") : [];
|
||||
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
|
||||
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
|
||||
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
|
||||
const animals = files["animals.json"] ? asArray("animals.json") : [];
|
||||
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
|
||||
const auditEvents = asArray("auditEvents.json");
|
||||
|
||||
// Restore inside a transaction — wipe then reload in FK order
|
||||
// Restore inside a transaction — wipe then reload in FK order.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.taskCompletion.deleteMany();
|
||||
await tx.plantLog.deleteMany();
|
||||
await tx.itemLog.deleteMany();
|
||||
await tx.itemAttachment.deleteMany();
|
||||
await tx.labelOnItem.deleteMany();
|
||||
await tx.animalLog.deleteMany();
|
||||
await tx.task.deleteMany();
|
||||
await tx.item.deleteMany();
|
||||
await tx.label.deleteMany();
|
||||
await tx.animal.deleteMany();
|
||||
await tx.plant.deleteMany();
|
||||
await tx.plantGroup.deleteMany();
|
||||
await tx.plantType.deleteMany();
|
||||
await tx.location.deleteMany();
|
||||
await tx.apiToken.deleteMany();
|
||||
await tx.auditEvent.deleteMany();
|
||||
await tx.user.deleteMany();
|
||||
|
||||
if (users.length) await tx.user.createMany({ data: users });
|
||||
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
|
||||
if (plants.length) await tx.plant.createMany({ data: plants });
|
||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
||||
if (tasks.length) await tx.task.createMany({ data: tasks });
|
||||
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
|
||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
|
||||
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
|
||||
// Location self-references parentId; a single createMany is one statement,
|
||||
// so the FK is validated only after every row exists — order-independent.
|
||||
if (locations.length) await tx.location.createMany({ data: locations as never });
|
||||
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
|
||||
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
|
||||
if (plants.length) await tx.plant.createMany({ data: plants as never });
|
||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
|
||||
if (labels.length) await tx.label.createMany({ data: labels as never });
|
||||
// Item self-references parentItemId; one createMany statement validates the
|
||||
// FK only after all rows exist — order-independent.
|
||||
if (items.length) await tx.item.createMany({ data: items as never });
|
||||
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
|
||||
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
|
||||
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
|
||||
if (animals.length) await tx.animal.createMany({ data: animals as never });
|
||||
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
|
||||
if (tasks.length) await tx.task.createMany({ data: tasks as never });
|
||||
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
@@ -64,10 +142,13 @@ export async function POST(req: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (err instanceof BadBackup || err instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "The backup file is invalid or corrupted." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
console.error("restore failed:", err);
|
||||
return NextResponse.json({ error: "Restore failed." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
49
src/app/api/auth/recover/route.ts
Normal file
49
src/app/api/auth/recover/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { hashSync } from "bcryptjs";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const schema = z.object({
|
||||
secret: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const recoverySecret = process.env.RECOVERY_SECRET;
|
||||
if (!recoverySecret) {
|
||||
return NextResponse.json({ error: "Recovery not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const data = schema.parse(body);
|
||||
|
||||
if (data.secret !== recoverySecret) {
|
||||
return NextResponse.json({ error: "Invalid recovery secret" }, { status: 403 });
|
||||
}
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
where: { OR: [{ username: data.username }, { email: data.username }], active: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: hashSync(data.password, 10), mustChangePassword: false },
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: { action: "auth.recover", entityId: user.id, payload: { username: user.username } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, name: user.name });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
src/app/api/locations/[id]/log/route.ts
Normal file
71
src/app/api/locations/[id]/log/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { descendantIds } from "@/lib/locations/tree";
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||
notes: z.string().optional(),
|
||||
quantity: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/locations/[id]/log — append a log to every active plant in this
|
||||
// location and its sub-locations (e.g. "watered the whole back yard").
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
|
||||
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Location not found" }, { status: 404 });
|
||||
|
||||
// This location + everything nested under it.
|
||||
const allRows = await db.location.findMany({ select: { id: true, parentId: true, name: true } });
|
||||
const locationIds = descendantIds(params.id, allRows, true);
|
||||
|
||||
const plants = await db.plant.findMany({
|
||||
where: { locationId: { in: locationIds }, active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (plants.length === 0)
|
||||
return NextResponse.json({ error: "No plants found in this location" }, { status: 404 });
|
||||
|
||||
const data = schema.parse(await req.json());
|
||||
const date = data.date ? new Date(data.date) : new Date();
|
||||
|
||||
const logs = await db.$transaction(
|
||||
plants.map((p) =>
|
||||
db.plantLog.create({
|
||||
data: {
|
||||
plantId: p.id,
|
||||
type: data.type,
|
||||
date,
|
||||
notes: data.notes?.trim() || null,
|
||||
quantity: data.quantity?.trim() || null,
|
||||
actorId: session.user.id,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "location.log",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { location: location.name, type: data.type, plantCount: logs.length },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ count: logs.length }, { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/locations/[id]/route.ts
Normal file
106
src/app/api/locations/[id]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { LocationKind } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||
import { recomputeAllPaths, wouldCreateCycle } from "@/lib/locations/db";
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
kind: z.nativeEnum(LocationKind).optional(),
|
||||
parentId: z.string().nullable().optional(), // null → move to top level
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const data = updateSchema.parse(await req.json());
|
||||
|
||||
// Guard against cycles when re-parenting.
|
||||
if (data.parentId) {
|
||||
if (await wouldCreateCycle(params.id, data.parentId))
|
||||
return NextResponse.json(
|
||||
{ error: "A location can't be moved under itself or its own descendant" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await db.$transaction(async (tx) => {
|
||||
const u = await tx.location.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name.trim() } : {}),
|
||||
...(data.kind !== undefined ? { kind: data.kind } : {}),
|
||||
...(data.parentId !== undefined ? { parentId: data.parentId } : {}),
|
||||
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
|
||||
},
|
||||
});
|
||||
await recomputeAllPaths(tx as unknown as typeof db);
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
action: "location.update",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: u.name },
|
||||
},
|
||||
});
|
||||
return u;
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (!isAdmin(session.user.role))
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||
if (!location || !location.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Don't orphan live records — require the location be emptied first.
|
||||
const [plantCount, childCount] = await Promise.all([
|
||||
db.plant.count({ where: { locationId: params.id, active: true } }),
|
||||
db.location.count({ where: { parentId: params.id, active: true } }),
|
||||
]);
|
||||
if (plantCount > 0 || childCount > 0)
|
||||
return NextResponse.json(
|
||||
{ error: "Move the plants and sub-locations out of this location first" },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
await db.location.update({ where: { id: params.id }, data: { active: false } });
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "location.delete",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: location.name },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
22
src/app/api/locations/bulk/route.ts
Normal file
22
src/app/api/locations/bulk/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { bulkSetupLocations } from "@/lib/services/location-setup";
|
||||
|
||||
const schema = z.object({
|
||||
outline: z.string().min(1),
|
||||
groupGardenUnder: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/locations/bulk — create a Location tree from an indented outline,
|
||||
// optionally sweeping existing top-level garden areas under a named parent.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "locations:write");
|
||||
const { outline, groupGardenUnder } = schema.parse(await req.json());
|
||||
const summary = await bulkSetupLocations(ctx, { outline, groupGardenUnder });
|
||||
return NextResponse.json(summary, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
78
src/app/api/locations/route.ts
Normal file
78
src/app/api/locations/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { LocationKind } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
kind: z.nativeEnum(LocationKind).optional(),
|
||||
parentId: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/locations — flat list of active locations (client builds the tree).
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const locations = await db.location.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { plants: { where: { active: true } } } } },
|
||||
});
|
||||
return NextResponse.json(locations);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/locations — create a location node.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = createSchema.parse(await req.json());
|
||||
|
||||
const name = data.name.trim();
|
||||
if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
|
||||
// Validate parent exists (if given).
|
||||
if (data.parentId) {
|
||||
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||
}
|
||||
|
||||
const location = await db.$transaction(async (tx) => {
|
||||
const created = await tx.location.create({
|
||||
data: {
|
||||
name,
|
||||
kind: data.kind ?? LocationKind.AREA,
|
||||
parentId: data.parentId || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
await recomputeAllPaths(tx as unknown as typeof db);
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
action: "location.create",
|
||||
entityId: created.id,
|
||||
actorId: session.user.id,
|
||||
payload: { name: created.name, kind: created.kind, parentId: created.parentId },
|
||||
},
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
return NextResponse.json(location, { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
107
src/app/api/plant-types/[id]/route.ts
Normal file
107
src/app/api/plant-types/[id]/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { PlantCategory, Prisma } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||
|
||||
const month = z.coerce.number().int().min(1).max(12).nullable();
|
||||
|
||||
// All optional — a PATCH only touches the fields it sends. Empty string clears
|
||||
// a text field; null clears a numeric/boolean one.
|
||||
const updateSchema = z.object({
|
||||
commonName: z.string().min(1).optional(),
|
||||
species: z.string().optional(),
|
||||
category: z.nativeEnum(PlantCategory).optional(),
|
||||
careNotes: z.string().optional(),
|
||||
sun: z.string().optional(),
|
||||
water: z.string().optional(),
|
||||
bloomStart: month.optional(),
|
||||
bloomEnd: month.optional(),
|
||||
harvestStart: month.optional(),
|
||||
harvestEnd: month.optional(),
|
||||
toxicToPets: z.boolean().nullable().optional(),
|
||||
edibleParts: z.string().optional(),
|
||||
spacing: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
uwExtensionUrl: z.string().optional(),
|
||||
uwExtensionTitle: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
|
||||
"uwExtensionUrl", "uwExtensionTitle", "notes",
|
||||
] as const;
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const existing = await db.plantType.findUnique({ where: { id: params.id } });
|
||||
if (!existing || !existing.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = await req.json();
|
||||
const data = updateSchema.parse(body);
|
||||
|
||||
const update: Prisma.PlantTypeUpdateInput = {};
|
||||
if (data.commonName !== undefined) update.commonName = data.commonName.trim();
|
||||
if (data.category !== undefined) update.category = data.category;
|
||||
if (data.toxicToPets !== undefined) update.toxicToPets = data.toxicToPets;
|
||||
for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
|
||||
if (data[k] !== undefined) update[k] = data[k];
|
||||
}
|
||||
for (const k of TEXT_FIELDS) {
|
||||
if (data[k] !== undefined) update[k] = data[k]?.trim() || null;
|
||||
}
|
||||
|
||||
const updated = await db.plantType.update({ where: { id: params.id }, data: update });
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "plant_type.update",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { commonName: updated.commonName },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (!isAdmin(session.user.role))
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const existing = await db.plantType.findUnique({ where: { id: params.id } });
|
||||
if (!existing || !existing.active)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Soft-delete; plantings keep their plantTypeId (the row just goes inactive).
|
||||
await db.plantType.update({ where: { id: params.id }, data: { active: false } });
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "plant_type.delete",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { commonName: existing.commonName },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/plant-types/route.ts
Normal file
95
src/app/api/plant-types/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { PlantCategory } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
const month = z.coerce.number().int().min(1).max(12);
|
||||
|
||||
const createSchema = z.object({
|
||||
commonName: z.string().min(1),
|
||||
species: z.string().optional(),
|
||||
category: z.nativeEnum(PlantCategory).optional(),
|
||||
careNotes: z.string().optional(),
|
||||
sun: z.string().optional(),
|
||||
water: z.string().optional(),
|
||||
bloomStart: month.optional(),
|
||||
bloomEnd: month.optional(),
|
||||
harvestStart: month.optional(),
|
||||
harvestEnd: month.optional(),
|
||||
toxicToPets: z.boolean().nullable().optional(), // "Unknown" in the dialog arrives as null
|
||||
edibleParts: z.string().optional(),
|
||||
spacing: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
uwExtensionUrl: z.string().optional(),
|
||||
uwExtensionTitle: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/plant-types — list active kinds with a planting count.
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const types = await db.plantType.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { commonName: "asc" },
|
||||
include: { _count: { select: { plants: { where: { active: true } } } } },
|
||||
});
|
||||
return NextResponse.json(types);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/plant-types — create a kind.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = createSchema.parse(await req.json());
|
||||
const commonName = data.commonName.trim();
|
||||
if (!commonName) return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
|
||||
const created = await db.plantType.create({
|
||||
data: {
|
||||
commonName,
|
||||
species: data.species?.trim() || null,
|
||||
category: data.category ?? PlantCategory.OTHER,
|
||||
careNotes: data.careNotes?.trim() || null,
|
||||
sun: data.sun?.trim() || null,
|
||||
water: data.water?.trim() || null,
|
||||
bloomStart: data.bloomStart ?? null,
|
||||
bloomEnd: data.bloomEnd ?? null,
|
||||
harvestStart: data.harvestStart ?? null,
|
||||
harvestEnd: data.harvestEnd ?? null,
|
||||
toxicToPets: data.toxicToPets ?? null,
|
||||
edibleParts: data.edibleParts?.trim() || null,
|
||||
spacing: data.spacing?.trim() || null,
|
||||
imageUrl: data.imageUrl?.trim() || null,
|
||||
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
|
||||
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "plant_type.create",
|
||||
entityId: created.id,
|
||||
actorId: session.user.id,
|
||||
payload: { commonName: created.commonName, category: created.category },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError)
|
||||
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,23 @@ const updateSchema = z.object({
|
||||
variety: z.string().optional(),
|
||||
category: z.string().min(1),
|
||||
zone: z.string().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
groupId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// Dual-write helper (Release A): keep the legacy `zone` string in sync with the
|
||||
// chosen location's name until Release B drops the column.
|
||||
async function resolveZone(locationId?: string | null, zone?: string): Promise<string | null> {
|
||||
if (locationId) {
|
||||
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||
if (loc) return loc.name;
|
||||
}
|
||||
return zone?.trim() || null;
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
@@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
||||
? await fetchPlantImageUrl(species, commonName)
|
||||
: undefined;
|
||||
|
||||
const zone = await resolveZone(data.locationId, data.zone);
|
||||
|
||||
const updated = await db.plant.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
@@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
||||
species,
|
||||
variety: data.variety?.trim() || null,
|
||||
category: data.category as PlantCategory,
|
||||
zone: data.zone?.trim() || null,
|
||||
zone, // dual-write: kept in sync with location.name until Release B
|
||||
locationId: data.locationId ?? null,
|
||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||
source: data.source?.trim() || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
|
||||
@@ -10,12 +10,40 @@ const createSchema = z.object({
|
||||
variety: z.string().optional(),
|
||||
category: z.string().min(1),
|
||||
zone: z.string().optional(),
|
||||
locationId: z.string().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
groupId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Dual-write helper (Release A): resolve a locationId to its name so the legacy
|
||||
// `zone` column stays populated until Release B drops it. Returns the zone
|
||||
// string to stamp, falling back to the freeform zone when no location is given.
|
||||
async function resolveZone(locationId?: string, zone?: string): Promise<string | null> {
|
||||
if (locationId) {
|
||||
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||
if (loc) return loc.name;
|
||||
}
|
||||
return zone?.trim() || null;
|
||||
}
|
||||
|
||||
// Link a new planting to its "kind" record — by species first, else common name.
|
||||
async function resolvePlantTypeId(species: string | null, commonName: string): Promise<string | null> {
|
||||
if (species) {
|
||||
const bySpecies = await db.plantType.findFirst({
|
||||
where: { active: true, species: { equals: species, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (bySpecies) return bySpecies.id;
|
||||
}
|
||||
const byCommon = await db.plantType.findFirst({
|
||||
where: { active: true, commonName: { equals: commonName, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
return byCommon?.id ?? null;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
@@ -25,6 +53,8 @@ export async function POST(req: Request) {
|
||||
const species = data.species?.trim() || null;
|
||||
const commonName = data.commonName.trim();
|
||||
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
||||
const zone = await resolveZone(data.locationId, data.zone);
|
||||
const plantTypeId = await resolvePlantTypeId(species, commonName);
|
||||
|
||||
const plant = await db.plant.create({
|
||||
data: {
|
||||
@@ -32,7 +62,9 @@ export async function POST(req: Request) {
|
||||
species,
|
||||
variety: data.variety?.trim() || null,
|
||||
category: data.category as PlantCategory,
|
||||
zone: data.zone?.trim() || null,
|
||||
zone, // dual-write: kept in sync with location.name until Release B
|
||||
locationId: data.locationId || null,
|
||||
plantTypeId,
|
||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||
source: data.source?.trim() || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
|
||||
65
src/app/api/rambles/[id]/route.ts
Normal file
65
src/app/api/rambles/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const patchSchema = z.object({
|
||||
body: z.string().trim().min(1).max(5000).optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// PATCH /api/rambles/:id — edit body, pin/unpin, or archive/restore.
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
let session;
|
||||
try {
|
||||
session = await requireAuth();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = patchSchema.parse(await req.json());
|
||||
} catch (err) {
|
||||
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: { body?: string; pinned?: boolean; archivedAt?: Date | null } = {};
|
||||
if (input.body !== undefined) data.body = input.body;
|
||||
if (input.pinned !== undefined) data.pinned = input.pinned;
|
||||
if (input.archived !== undefined) data.archivedAt = input.archived ? new Date() : null;
|
||||
|
||||
try {
|
||||
const ramble = await db.ramble.update({ where: { id: params.id }, data });
|
||||
await db.auditEvent.create({
|
||||
data: { action: "ramble.update", entityId: ramble.id, actorId: session.user.id, payload: input },
|
||||
});
|
||||
return NextResponse.json(ramble);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/rambles/:id — hard delete (a ramble is disposable by design).
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
let session;
|
||||
try {
|
||||
session = await requireAuth();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.ramble.delete({ where: { id: params.id } });
|
||||
await db.auditEvent.create({
|
||||
data: { action: "ramble.delete", entityId: params.id, actorId: session.user.id },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/rambles/route.ts
Normal file
38
src/app/api/rambles/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const createSchema = z.object({
|
||||
body: z.string().trim().min(1, "Write something first.").max(5000),
|
||||
pinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// POST /api/rambles — jot a new note.
|
||||
export async function POST(req: Request) {
|
||||
let session;
|
||||
try {
|
||||
session = await requireAuth();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = createSchema.parse(await req.json());
|
||||
} catch (err) {
|
||||
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
|
||||
const ramble = await db.ramble.create({
|
||||
data: { body: input.body, pinned: input.pinned ?? false },
|
||||
});
|
||||
await db.auditEvent.create({
|
||||
data: { action: "ramble.create", entityId: ramble.id, actorId: session.user.id },
|
||||
});
|
||||
|
||||
return NextResponse.json(ramble, { status: 201 });
|
||||
}
|
||||
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalLogInput } from "@/lib/api/schemas";
|
||||
import { addAnimalLog } from "@/lib/services/animals";
|
||||
|
||||
// POST /api/v1/animals/:id/logs
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalLogInput.parse(await req.json());
|
||||
return NextResponse.json(await addAnimalLog(ctx, params.id, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
33
src/app/api/v1/animals/[id]/route.ts
Normal file
33
src/app/api/v1/animals/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalUpdateInput } from "@/lib/api/schemas";
|
||||
import { getAnimal, updateAnimal, deleteAnimal } from "@/lib/services/animals";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:read");
|
||||
return NextResponse.json(await getAnimal(ctx, params.id));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalUpdateInput.parse(await req.json());
|
||||
return NextResponse.json(await updateAnimal(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
await deleteAnimal(ctx, params.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
28
src/app/api/v1/animals/route.ts
Normal file
28
src/app/api/v1/animals/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalCreateInput } from "@/lib/api/schemas";
|
||||
import { listAnimals, createAnimal } from "@/lib/services/animals";
|
||||
|
||||
// GET /api/v1/animals?q=&species=
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:read");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
return NextResponse.json(
|
||||
await listAnimals(ctx, { q: sp.get("q") ?? undefined, species: sp.get("species") ?? undefined }),
|
||||
);
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/animals
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createAnimal(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authenticate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]);
|
||||
|
||||
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
|
||||
// under public/uploads/items (a persistent Docker volume), same as suggestions.
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
|
||||
const item = await db.item.findUnique({ where: { id: params.id }, select: { active: true, imageUrl: true } });
|
||||
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
const kind = String(form.get("kind") ?? "PHOTO").toUpperCase();
|
||||
if (!file) throw new ApiError(400, "No file uploaded");
|
||||
if (!KINDS.has(kind)) throw new ApiError(400, "Invalid attachment kind");
|
||||
|
||||
const safe = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||
const dir = path.join(process.cwd(), "public", "uploads", "items");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, safe), Buffer.from(await file.arrayBuffer()));
|
||||
const base = (process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXTAUTH_URL || "").replace(/\/+$/, "");
|
||||
const url = base ? `${base}/uploads/items/${safe}` : `/uploads/items/${safe}`;
|
||||
|
||||
const attachment = await db.itemAttachment.create({
|
||||
data: { itemId: params.id, kind, url, name: file.name },
|
||||
});
|
||||
|
||||
// First photo becomes the item's thumbnail.
|
||||
if (kind === "PHOTO" && !item.imageUrl) {
|
||||
await db.item.update({ where: { id: params.id }, data: { imageUrl: url } });
|
||||
}
|
||||
await db.auditEvent.create({
|
||||
data: { action: "item.attachment.add", entityId: params.id, actorId: ctx.userId, payload: { kind, via: ctx.via } },
|
||||
});
|
||||
|
||||
return NextResponse.json(attachment, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemLogInput } from "@/lib/api/schemas";
|
||||
import { addItemLog } from "@/lib/services/items";
|
||||
|
||||
// POST /api/v1/items/:id/logs
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemLogInput.parse(await req.json());
|
||||
return NextResponse.json(await addItemLog(ctx, params.id, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
36
src/app/api/v1/items/[id]/route.ts
Normal file
36
src/app/api/v1/items/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemUpdateInput } from "@/lib/api/schemas";
|
||||
import { getItem, updateItem, deleteItem } from "@/lib/services/items";
|
||||
|
||||
// GET /api/v1/items/:id
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
return NextResponse.json(await getItem(ctx, params.id));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/v1/items/:id
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemUpdateInput.parse(await req.json());
|
||||
return NextResponse.json(await updateItem(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/items/:id (soft-delete; admin only — enforced in the service)
|
||||
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
await deleteItem(ctx, params.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
22
src/app/api/v1/items/import/route.ts
Normal file
22
src/app/api/v1/items/import/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { importHomeBoxItems } from "@/lib/services/inventory-import";
|
||||
|
||||
const schema = z.object({
|
||||
csv: z.string().min(1),
|
||||
skip: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// POST /api/v1/items/import — import a HomeBox CSV export. `skip` lists item
|
||||
// names to leave out (e.g. the ones unchecked in the preview).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const { csv, skip } = schema.parse(await req.json());
|
||||
const summary = await importHomeBoxItems(ctx, { csv, skip });
|
||||
return NextResponse.json(summary, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/items/route.ts
Normal file
34
src/app/api/v1/items/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { itemCreateInput } from "@/lib/api/schemas";
|
||||
import { listItems, createItem } from "@/lib/services/items";
|
||||
|
||||
// GET /api/v1/items?q=&locationId=&kind=DURABLE
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const kindParam = sp.get("kind");
|
||||
const kind = kindParam === "DURABLE" || kindParam === "CONSUMABLE" ? kindParam : undefined;
|
||||
return NextResponse.json(
|
||||
await listItems(ctx, {
|
||||
q: sp.get("q") ?? undefined,
|
||||
locationId: sp.get("locationId") ?? undefined,
|
||||
kind,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/items
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = itemCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createItem(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
37
src/app/api/v1/labels/route.ts
Normal file
37
src/app/api/v1/labels/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/v1/labels
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await authenticateRequest(req, "items:read");
|
||||
return NextResponse.json(await db.label.findMany({ orderBy: { name: "asc" } }));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/labels — create (or return existing by name).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const data = createSchema.parse(await req.json());
|
||||
const name = data.name.trim();
|
||||
const existing = await db.label.findUnique({ where: { name } });
|
||||
if (existing) return NextResponse.json(existing);
|
||||
const label = await db.label.create({ data: { name, color: data.color?.trim() || null } });
|
||||
await db.auditEvent.create({
|
||||
data: { action: "label.create", entityId: label.id, actorId: ctx.userId, payload: { name } },
|
||||
});
|
||||
return NextResponse.json(label, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/locations/route.ts
Normal file
13
src/app/api/v1/locations/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { listLocations } from "@/lib/services/locations";
|
||||
|
||||
// GET /api/v1/locations
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "locations:read");
|
||||
return NextResponse.json(await listLocations(ctx));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { pantryUpdateInput } from "@/lib/api/schemas";
|
||||
import { updatePantryItem, deletePantryItem } from "@/lib/services/pantry";
|
||||
|
||||
// PATCH /api/v1/pantry/:id
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = pantryUpdateInput.parse(await req.json());
|
||||
return NextResponse.json(await updatePantryItem(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/pantry/:id — soft-delete (active=false)
|
||||
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
await deletePantryItem(ctx, params.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { pantryUseInput } from "@/lib/api/schemas";
|
||||
import { usePantryItem } from "@/lib/services/pantry";
|
||||
|
||||
// POST /api/v1/pantry/:id/use — pull an amount; logs a USED ItemLog
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = pantryUseInput.parse(await req.json());
|
||||
const result = await usePantryItem(ctx, params.id, input);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { pantryBulkUseInput } from "@/lib/api/schemas";
|
||||
import { bulkUsePantry } from "@/lib/services/pantry";
|
||||
|
||||
// POST /api/v1/pantry/bulk-use — pull several items at once.
|
||||
// Responds with `skipped` for any pull whose item no longer exists.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = pantryBulkUseInput.parse(await req.json());
|
||||
const result = await bulkUsePantry(ctx, input);
|
||||
return NextResponse.json({ ok: result.skipped.length === 0, ...result });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { pantryCatalog } from "@/lib/services/pantry";
|
||||
|
||||
// GET /api/v1/pantry/catalog?q= — autocomplete data for the add/edit dialog
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
const q = new URL(req.url).searchParams.get("q") ?? undefined;
|
||||
return NextResponse.json(await pantryCatalog(ctx, q));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
27
src/app/api/v1/pantry/route.ts
Normal file
27
src/app/api/v1/pantry/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { pantryCreateInput } from "@/lib/api/schemas";
|
||||
import { listPantry, createPantryItem } from "@/lib/services/pantry";
|
||||
|
||||
// Pantry items are consumable Items, so they ride the items:* scopes.
|
||||
|
||||
// GET /api/v1/pantry
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:read");
|
||||
return NextResponse.json(await listPantry(ctx));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/pantry
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "items:write");
|
||||
const input = pantryCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createPantryItem(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plant-types/route.ts
Normal file
14
src/app/api/v1/plant-types/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { listPlantTypes } from "@/lib/services/plant-types";
|
||||
|
||||
// GET /api/v1/plant-types?q=lily
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "plant-types:read");
|
||||
const q = new URL(req.url).searchParams.get("q") ?? undefined;
|
||||
return NextResponse.json(await listPlantTypes(ctx, { q }));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal file
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { searchUwExtension } from "@/lib/garden/uw-extension";
|
||||
|
||||
// GET /api/v1/plant-types/uw-search?q=raspberry — proxy to the UW–Madison
|
||||
// Extension horticulture article search (avoids CORS, keeps the client thin).
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await authenticateRequest(req, "plant-types:read");
|
||||
const q = new URL(req.url).searchParams.get("q") ?? "";
|
||||
return NextResponse.json(await searchUwExtension(q));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal file
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { plantLogInput } from "@/lib/api/schemas";
|
||||
import { addPlantLog } from "@/lib/services/plants";
|
||||
|
||||
// POST /api/v1/plants/:id/logs
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "plants:write");
|
||||
const input = plantLogInput.parse(await req.json());
|
||||
const log = await addPlantLog(ctx, params.id, input);
|
||||
return NextResponse.json(log, { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/plants/[id]/route.ts
Normal file
13
src/app/api/v1/plants/[id]/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { getPlant } from "@/lib/services/plants";
|
||||
|
||||
// GET /api/v1/plants/:id
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "plants:read");
|
||||
return NextResponse.json(await getPlant(ctx, params.id));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plants/route.ts
Normal file
14
src/app/api/v1/plants/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { listPlants } from "@/lib/services/plants";
|
||||
|
||||
// GET /api/v1/plants?q=apple
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "plants:read");
|
||||
const q = new URL(req.url).searchParams.get("q") ?? undefined;
|
||||
return NextResponse.json(await listPlants(ctx, { q }));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
22
src/app/api/v1/route.ts
Normal file
22
src/app/api/v1/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
|
||||
// GET /api/v1 — whoami + endpoint discovery. Handy for testing a token.
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
via: ctx.via,
|
||||
scopes: ctx.scopes,
|
||||
endpoints: {
|
||||
plants: "GET /api/v1/plants?q=, GET /api/v1/plants/:id, POST /api/v1/plants/:id/logs",
|
||||
plantTypes: "GET /api/v1/plant-types?q=",
|
||||
locations: "GET /api/v1/locations",
|
||||
tasks: "GET /api/v1/tasks, POST /api/v1/tasks, POST /api/v1/tasks/:id/complete",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal file
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { taskCompleteInput } from "@/lib/api/schemas";
|
||||
import { completeTask } from "@/lib/services/tasks";
|
||||
|
||||
// POST /api/v1/tasks/:id/complete
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "tasks:write");
|
||||
const input = taskCompleteInput.parse(await req.json().catch(() => ({})));
|
||||
return NextResponse.json(await completeTask(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
25
src/app/api/v1/tasks/route.ts
Normal file
25
src/app/api/v1/tasks/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { taskCreateInput } from "@/lib/api/schemas";
|
||||
import { listTasks, createTask } from "@/lib/services/tasks";
|
||||
|
||||
// GET /api/v1/tasks
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "tasks:read");
|
||||
return NextResponse.json(await listTasks(ctx));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/tasks
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "tasks:write");
|
||||
const input = taskCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createTask(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
13
src/app/change-password/page.tsx
Normal file
13
src/app/change-password/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { ChangePasswordForm } from "@/components/account/change-password-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Change password" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChangePasswordPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
return <ChangePasswordForm forced={session.user.mustChangePassword} />;
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Force native form controls (input/textarea/select) to the light theme so
|
||||
* they don't pick up the OS dark fill — otherwise an unstyled <input> renders
|
||||
* with a dark system background in a dark-mode browser, hiding placeholder
|
||||
* text. .dark overrides this below. */
|
||||
color-scheme: light;
|
||||
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
|
||||
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
|
||||
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
||||
@@ -58,6 +63,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--dew: 140 18% 10%;
|
||||
--dew-deep: 140 18% 12%;
|
||||
--soil: 120 18% 88%;
|
||||
|
||||
@@ -74,6 +74,11 @@ function LoginForm() {
|
||||
<Button type="submit" className="w-full" disabled={busy}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<a href="/recover" className="underline underline-offset-2 hover:text-foreground">
|
||||
Forgot password?
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
121
src/app/recover/page.tsx
Normal file
121
src/app/recover/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function RecoverPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ secret: "", username: "", password: "", confirm: "" });
|
||||
const [status, setStatus] = useState<{ type: "error" | "success"; message: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (form.password !== form.confirm) {
|
||||
setStatus({ type: "error", message: "Passwords don't match." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/recover", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret: form.secret, username: form.username, password: form.password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Failed");
|
||||
setStatus({ type: "success", message: `Password reset for ${json.name}. You can now log in.` });
|
||||
} catch (err) {
|
||||
setStatus({ type: "error", message: String(err).replace("Error: ", "") });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Account Recovery</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Enter the recovery secret from your <code>.env</code> file to reset a password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Recovery secret</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={form.secret}
|
||||
onChange={e => setForm(f => ({ ...f, secret: e.target.value }))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="from your .env file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="bonna"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={form.confirm}
|
||||
onChange={e => setForm(f => ({ ...f, confirm: e.target.value }))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className={`rounded-md p-3 text-sm ${status.type === "success" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Resetting…" : "Reset password"}
|
||||
</button>
|
||||
|
||||
{status?.type === "success" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-full rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
Go to login
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/account/change-password-form.tsx
Normal file
116
src/components/account/change-password-form.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Leaf } from "lucide-react";
|
||||
|
||||
export function ChangePasswordForm({ forced }: { forced: boolean }) {
|
||||
const router = useRouter();
|
||||
const { update } = useSession();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (next !== confirm) {
|
||||
setError("The new passwords don't match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/account/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(json.error ?? "Couldn't change your password.");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// Refresh the session token so mustChangePassword clears, then continue.
|
||||
await update();
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-6 text-center">
|
||||
<div className="h-14 w-14 rounded-full bg-[hsl(var(--canopy))] flex items-center justify-center mb-3">
|
||||
<Leaf className="h-7 w-7 text-[hsl(var(--canopy-foreground))]" />
|
||||
</div>
|
||||
<h1 className="font-display text-3xl text-foreground tracking-tight">
|
||||
Choose a password
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{forced
|
||||
? "Before you continue, please set your own password."
|
||||
: "Update your Moon Base password."}
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="current">Current password</Label>
|
||||
<Input
|
||||
id="current"
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="next">New password</Label>
|
||||
<Input
|
||||
id="next"
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-[hsl(var(--ember))]">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save password"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/animals/add-animal-log-button.tsx
Normal file
80
src/components/animals/add-animal-log-button.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { ANIMAL_LOG_TYPES, ANIMAL_LOG_LABELS } from "@/lib/animals/animal-fields";
|
||||
|
||||
export function AddAnimalLogButton({ animalId }: { animalId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState("NOTE");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [weight, setWeight] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/v1/animals/${animalId}/logs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, notes: notes.trim() || undefined, weight: weight ? Number(weight) : null }),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
|
||||
toast({ title: "Logged!" });
|
||||
setNotes(""); setWeight(""); setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<ClipboardList className="h-4 w-4 mr-1" /> Log
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader><DialogTitle>Log for this animal</DialogTitle></DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANIMAL_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ANIMAL_LOG_LABELS[t]}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{type === "WEIGHT" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Weight (lbs)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={weight} onChange={(e) => setWeight(e.target.value)} autoFocus />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes</Label>
|
||||
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/animals/animal-dialog.tsx
Normal file
168
src/components/animals/animal-dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus, Pencil } from "lucide-react";
|
||||
import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
|
||||
|
||||
export type LocationOpt = { id: string; name: string };
|
||||
|
||||
export type AnimalFields = {
|
||||
id?: string;
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string;
|
||||
sex: string;
|
||||
locationId: string;
|
||||
birthdate: string;
|
||||
acquiredAt: string;
|
||||
source: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function blankAnimal(): AnimalFields {
|
||||
return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
|
||||
}
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
function AnimalDialog({ trigger, title, initial, locations }: {
|
||||
trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [f, setF] = useState<AnimalFields>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
function set<K extends keyof AnimalFields>(k: K, v: AnimalFields[K]) {
|
||||
setF((p) => ({ ...p, [k]: v }));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!f.name.trim()) { setError("Please give the animal a name."); return; }
|
||||
setError("");
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
name: f.name.trim(),
|
||||
species: f.species.trim() || undefined,
|
||||
breed: f.breed.trim() || undefined,
|
||||
sex: f.sex.trim() || undefined,
|
||||
locationId: f.locationId || null,
|
||||
birthdate: f.birthdate || null,
|
||||
acquiredAt: f.acquiredAt || null,
|
||||
source: f.source.trim() || undefined,
|
||||
notes: f.notes.trim() || undefined,
|
||||
};
|
||||
const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
|
||||
method: f.id ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: f.id ? "Saved!" : "Added!" });
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Name *</Label>
|
||||
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Bella" />
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Species</Label>
|
||||
<Input list="species-list" value={f.species} onChange={(e) => set("species", e.target.value)} placeholder="Dog" />
|
||||
<datalist id="species-list">
|
||||
{COMMON_SPECIES.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Breed</Label>
|
||||
<Input value={f.breed} onChange={(e) => set("breed", e.target.value)} placeholder="Border collie" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Sex</Label>
|
||||
<Input value={f.sex} onChange={(e) => set("sex", e.target.value)} placeholder="Female" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location</Label>
|
||||
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Birthdate</Label>
|
||||
<Input type="date" value={f.birthdate} onChange={(e) => set("birthdate", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Acquired</Label>
|
||||
<Input type="date" value={f.acquiredAt} onChange={(e) => set("acquiredAt", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Where from?</Label>
|
||||
<Input value={f.source} onChange={(e) => set("source", e.target.value)} placeholder="Breeder, shelter, hatchery…" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Notes</Label>
|
||||
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
|
||||
return (
|
||||
<AnimalDialog title="Add an animal" initial={blankAnimal()} locations={locations}
|
||||
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add animal</Button>} />
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
|
||||
return (
|
||||
<AnimalDialog title="Edit animal" initial={animal} locations={locations}
|
||||
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
|
||||
);
|
||||
}
|
||||
55
src/components/animals/delete-animal-button.tsx
Normal file
55
src/components/animals/delete-animal-button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export function DeleteAnimalButton({ id, name }: { id: string; name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function handleDelete() {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/v1/animals/${id}`, { method: "DELETE" });
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not remove.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Removed", description: `"${name}" removed.` });
|
||||
setOpen(false);
|
||||
router.push("/animals");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Remove
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove “{name}”?</DialogTitle>
|
||||
<DialogDescription>The animal is hidden but its full history is kept.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} disabled={busy}
|
||||
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
|
||||
{busy ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
336
src/components/calendar/calendar-view.tsx
Normal file
336
src/components/calendar/calendar-view.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
// Month calendar merging every kind of task (garden, home, equipment) with
|
||||
// the garden's bloom/harvest windows. Tasks project forward from their
|
||||
// nextDue via occurrencesInRange; windows come from PlantType months.
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, Apple, BellPlus, ChevronLeft, ChevronRight, ExternalLink, Flower2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule";
|
||||
import { monthInWindow } from "@/lib/garden/season";
|
||||
import { tipsForMonth, type MonthlySuggestion } from "@/lib/garden/knowledge";
|
||||
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CalendarTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: "GARDEN" | "HOME" | "EQUIPMENT" | "OTHER";
|
||||
nextDue: string; // ISO
|
||||
recurrence: RecurrenceType;
|
||||
intervalDays: number | null;
|
||||
month: number | null;
|
||||
linkedTo: string | null; // plant/animal/item/location name
|
||||
};
|
||||
|
||||
export type SeasonType = {
|
||||
id: string;
|
||||
commonName: string;
|
||||
bloomStart: number | null;
|
||||
bloomEnd: number | null;
|
||||
harvestStart: number | null;
|
||||
harvestEnd: number | null;
|
||||
};
|
||||
|
||||
// "Good to do this month" — garden-brain suggestions matched to the plants on
|
||||
// hand, each one a click away from becoming a real yearly reminder.
|
||||
function SuggestionList({ month, year, monthLabel, plantNames, existingTitles }: {
|
||||
month: number; // 1–12
|
||||
year: number;
|
||||
monthLabel: string;
|
||||
plantNames: string[];
|
||||
existingTitles: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => tipsForMonth(month, plantNames),
|
||||
[month, plantNames],
|
||||
);
|
||||
const existing = useMemo(
|
||||
() => new Set(existingTitles.map((t) => t.toLowerCase())),
|
||||
[existingTitles],
|
||||
);
|
||||
const open = suggestions.filter(
|
||||
(s) => !existing.has(s.action.toLowerCase()) && !added.has(s.action),
|
||||
);
|
||||
|
||||
if (open.length === 0) return null;
|
||||
|
||||
async function addReminder(s: MonthlySuggestion) {
|
||||
setBusy(s.action);
|
||||
try {
|
||||
const today = new Date();
|
||||
const inViewedMonth = today.getFullYear() === year && today.getMonth() + 1 === month;
|
||||
const nextDue = inViewedMonth ? today : new Date(year, month - 1, 1);
|
||||
const res = await fetch("/api/v1/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: s.action,
|
||||
notes: [s.crop, s.detail].filter(Boolean).join(" — ") || undefined,
|
||||
category: "GARDEN",
|
||||
recurrence: "YEARLY",
|
||||
month,
|
||||
nextDue: nextDue.toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Could not add reminder");
|
||||
setAdded((prev) => new Set(prev).add(s.action));
|
||||
toast({ title: "Reminder added", description: `"${s.action}" will come back every ${monthLabel}.` });
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-[hsl(var(--leaf)/.05)] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-[hsl(var(--leaf)/.08)]">
|
||||
<Sparkles className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||
<span className="text-sm font-medium">Good to do in {monthLabel}</span>
|
||||
<span className="text-xs text-muted-foreground">· for what you grow (zone 4b)</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{open.map((s) => (
|
||||
<div key={s.action} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">
|
||||
{s.action}
|
||||
{s.crop && <span className="text-muted-foreground font-normal text-xs"> · {s.crop}</span>}
|
||||
</p>
|
||||
{s.detail && <p className="text-xs text-muted-foreground">{s.detail}</p>}
|
||||
</div>
|
||||
<a
|
||||
href={s.uwUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Read more at UW Extension"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs shrink-0"
|
||||
disabled={busy === s.action}
|
||||
onClick={() => addReminder(s)}
|
||||
>
|
||||
{busy === s.action ? "…" : <><BellPlus className="h-3 w-3" /> Remind me</>}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CATEGORY_STYLES: Record<CalendarTask["category"], string> = {
|
||||
GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]",
|
||||
HOME: "bg-amber-100 text-amber-800",
|
||||
EQUIPMENT: "bg-sky-100 text-sky-800",
|
||||
OTHER: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function iso(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function CalendarView({ tasks, seasonTypes, plantNames }: {
|
||||
tasks: CalendarTask[];
|
||||
seasonTypes: SeasonType[];
|
||||
plantNames: string[];
|
||||
}) {
|
||||
const today = new Date();
|
||||
const todayIso = iso(today);
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth()); // 0-indexed
|
||||
const [selected, setSelected] = useState<string | null>(todayIso);
|
||||
|
||||
const monthStart = new Date(year, month, 1);
|
||||
const monthEnd = new Date(year, month + 1, 0);
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
for (const t of tasks) {
|
||||
const occ = occurrencesInRange(
|
||||
{ nextDue: new Date(t.nextDue), recurrence: t.recurrence, intervalDays: t.intervalDays, month: t.month },
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
for (const d of occ) {
|
||||
const key = iso(d);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(t);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tasks, year, month]);
|
||||
|
||||
const blooming = seasonTypes.filter((t) => monthInWindow(month + 1, t.bloomStart, t.bloomEnd));
|
||||
const harvesting = seasonTypes.filter((t) => monthInWindow(month + 1, t.harvestStart, t.harvestEnd));
|
||||
const overdue = tasks.filter((t) => new Date(t.nextDue) < new Date(todayIso));
|
||||
|
||||
function nav(delta: number) {
|
||||
const d = new Date(year, month + delta, 1);
|
||||
setYear(d.getFullYear());
|
||||
setMonth(d.getMonth());
|
||||
setSelected(null);
|
||||
}
|
||||
|
||||
const leadingBlanks = monthStart.getDay();
|
||||
const daysInMonth = monthEnd.getDate();
|
||||
const selectedTasks = selected ? byDay.get(selected) ?? [] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-xl font-semibold flex-1">
|
||||
{monthStart.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
|
||||
</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => nav(-1)} aria-label="Previous month">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()); setSelected(todayIso); }}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => nav(1)} aria-label="Next month">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(blooming.length > 0 || harvesting.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
{harvesting.map((t) => (
|
||||
<Badge key={`h-${t.id}`} variant="outline" className="gap-1 border-[hsl(var(--leaf)/.5)] text-[hsl(var(--leaf))]">
|
||||
<Apple className="h-3 w-3" /> {t.commonName} ready to harvest
|
||||
</Badge>
|
||||
))}
|
||||
{blooming.map((t) => (
|
||||
<Badge key={`b-${t.id}`} variant="outline" className="gap-1 border-pink-300 text-pink-700">
|
||||
<Flower2 className="h-3 w-3" /> {t.commonName} in bloom
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overdue.length > 0 && (
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.1)] transition-colors"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{overdue.length} {overdue.length === 1 ? "task is" : "tasks are"} overdue — see Reminders
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b bg-muted/40">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="px-2 py-1.5 text-xs font-medium text-muted-foreground text-center">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{Array.from({ length: leadingBlanks }).map((_, i) => (
|
||||
<div key={`blank-${i}`} className="min-h-[72px] border-b border-r bg-muted/20" />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const key = iso(new Date(year, month, day));
|
||||
const dayTasks = byDay.get(key) ?? [];
|
||||
const isToday = key === todayIso;
|
||||
const isSelected = key === selected;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelected(key)}
|
||||
className={cn(
|
||||
"min-h-[72px] border-b border-r p-1 text-left align-top transition-colors hover:bg-accent/50",
|
||||
isSelected && "bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded-full text-xs",
|
||||
isToday ? "bg-[hsl(var(--leaf))] text-white font-semibold" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{dayTasks.slice(0, 3).map((t) => (
|
||||
<div
|
||||
key={`${t.id}-${key}`}
|
||||
className={cn("truncate rounded px-1 py-px text-[10px] leading-4", CATEGORY_STYLES[t.category])}
|
||||
>
|
||||
{t.title}
|
||||
</div>
|
||||
))}
|
||||
{dayTasks.length > 3 && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">+{dayTasks.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SuggestionList
|
||||
month={month + 1}
|
||||
year={year}
|
||||
monthLabel={monthStart.toLocaleDateString(undefined, { month: "long" })}
|
||||
plantNames={plantNames}
|
||||
existingTitles={tasks.map((t) => t.title)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{new Date(`${selected}T00:00:00`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
|
||||
</h3>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground border rounded-lg px-4 py-3">Nothing scheduled.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card divide-y">
|
||||
{selectedTasks.map((t) => {
|
||||
const isCurrentDue = iso(new Date(t.nextDue)) === selected;
|
||||
return (
|
||||
<div key={t.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", CATEGORY_STYLES[t.category])}>
|
||||
{t.category.toLowerCase()}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{t.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{describeRecurrence(t.recurrence, t.intervalDays, t.month)}
|
||||
{t.linkedTo && ` · ${t.linkedTo}`}
|
||||
</p>
|
||||
</div>
|
||||
{isCurrentDue && <CompleteTaskButton taskId={t.id} taskTitle={t.title} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,24 +24,33 @@ const LOG_TYPE_ORDER: PlantLogType[] = [
|
||||
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
|
||||
];
|
||||
|
||||
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
|
||||
|
||||
const schema = z.object({
|
||||
type: z.nativeEnum(PlantLogType),
|
||||
date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
quantity: z.string().optional(),
|
||||
amount: z.string().optional(), // form keeps strings; parsed on submit
|
||||
unit: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
plantId: string;
|
||||
plantName: string;
|
||||
locations: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function AddLogButton({ plantId, plantName }: Props) {
|
||||
const NO_LOCATION = "__none__";
|
||||
|
||||
export function AddLogButton({ plantId, plantName, locations }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
|
||||
const [toPantry, setToPantry] = useState(false);
|
||||
const [pantryLocationId, setPantryLocationId] = useState<string>(NO_LOCATION);
|
||||
const [bestBefore, setBestBefore] = useState("");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -51,21 +60,50 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
function resetAll() {
|
||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
||||
setLogType("OBSERVATION");
|
||||
setToPantry(false);
|
||||
setPantryLocationId(NO_LOCATION);
|
||||
setBestBefore("");
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const res = await fetch(`/api/plants/${plantId}/logs`, {
|
||||
const isHarvest = values.type === "HARVEST";
|
||||
const amount = isHarvest && values.amount ? parseFloat(values.amount) : null;
|
||||
if (isHarvest && values.amount && (amount == null || isNaN(amount) || amount < 0)) {
|
||||
toast({ title: "Enter the amount as a number", description: "e.g. 2 or 2.5 — pick a unit next to it.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/plants/${plantId}/logs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
body: JSON.stringify({
|
||||
type: values.type,
|
||||
date: values.date,
|
||||
notes: values.notes,
|
||||
amount: amount != null && !isNaN(amount) ? amount : undefined,
|
||||
unit: isHarvest ? values.unit || undefined : undefined,
|
||||
pantry: isHarvest && toPantry
|
||||
? {
|
||||
locationId: pantryLocationId === NO_LOCATION ? null : pantryLocationId,
|
||||
bestBefore: bestBefore || null,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Log entry saved!" });
|
||||
toast({
|
||||
title: "Log entry saved!",
|
||||
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
||||
setLogType("OBSERVATION");
|
||||
resetAll();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
||||
</div>
|
||||
|
||||
{logType === "HARVEST" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quantity">How much did you harvest?</Label>
|
||||
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>How much did you harvest?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder="2.5"
|
||||
className="w-24"
|
||||
{...form.register("amount")}
|
||||
/>
|
||||
<Input
|
||||
list="harvest-units"
|
||||
placeholder="lbs, each, quarts…"
|
||||
className="flex-1"
|
||||
{...form.register("unit")}
|
||||
/>
|
||||
<datalist id="harvest-units">
|
||||
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-[hsl(var(--leaf))]"
|
||||
checked={toPantry}
|
||||
onChange={(e) => setToPantry(e.target.checked)}
|
||||
/>
|
||||
Add to Pantry & Freezer
|
||||
</label>
|
||||
{toPantry && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Where will it be stored?</Label>
|
||||
<Select value={pantryLocationId} onValueChange={setPantryLocationId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_LOCATION}>No specific spot</SelectItem>
|
||||
{locations.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="best-before" className="text-xs">Best before (optional)</Label>
|
||||
<Input
|
||||
id="best-before"
|
||||
type="date"
|
||||
value={bestBefore}
|
||||
onChange={(e) => setBestBefore(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
|
||||
|
||||
type Group = { id: string; name: string };
|
||||
type LocationOption = { id: string; name: string };
|
||||
|
||||
const BLANK = {
|
||||
commonName: "",
|
||||
@@ -33,12 +34,19 @@ const BLANK = {
|
||||
newGroupName: "",
|
||||
};
|
||||
|
||||
export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) {
|
||||
export function AddPlantButton({
|
||||
groups = [],
|
||||
locations = [],
|
||||
}: {
|
||||
groups?: Group[];
|
||||
locations?: LocationOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fields, setFields] = useState(BLANK);
|
||||
const [selectedZones, setSelectedZones] = useState<string[]>([]);
|
||||
const [newZoneName, setNewZoneName] = useState("");
|
||||
const [addingZone, setAddingZone] = useState(false);
|
||||
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([]);
|
||||
const [newLocationName, setNewLocationName] = useState("");
|
||||
const [addingLocation, setAddingLocation] = useState(false);
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -47,25 +55,46 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// All zones including newly typed ones not yet in the DB
|
||||
const allZones = Array.from(new Set([...zones, ...selectedZones]));
|
||||
|
||||
function set(key: keyof typeof BLANK, value: string) {
|
||||
setFields((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleZone(zone: string) {
|
||||
setSelectedZones((prev) =>
|
||||
prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone]
|
||||
function nameFor(id: string) {
|
||||
return locationOptions.find((l) => l.id === id)?.name ?? "Location";
|
||||
}
|
||||
|
||||
function toggleLocation(id: string) {
|
||||
setSelectedLocationIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
function addNewZone() {
|
||||
const name = newZoneName.trim();
|
||||
// Persist new locations to the shared spine immediately so the plant gets a
|
||||
// real locationId (the freeform "zone" string is dual-written server-side).
|
||||
async function addNewLocation() {
|
||||
const name = newLocationName.trim();
|
||||
if (!name) return;
|
||||
if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]);
|
||||
setNewZoneName("");
|
||||
setAddingZone(false);
|
||||
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) {
|
||||
if (!selectedLocationIds.includes(existing.id)) toggleLocation(existing.id);
|
||||
setNewLocationName("");
|
||||
setAddingLocation(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/locations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const loc = await res.json();
|
||||
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
|
||||
setSelectedLocationIds((prev) => [...prev, loc.id]);
|
||||
setNewLocationName("");
|
||||
setAddingLocation(false);
|
||||
}
|
||||
|
||||
function onNameChange(value: string) {
|
||||
@@ -90,9 +119,9 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
function handleClose() {
|
||||
setOpen(false);
|
||||
setFields(BLANK);
|
||||
setSelectedZones([]);
|
||||
setNewZoneName("");
|
||||
setAddingZone(false);
|
||||
setSelectedLocationIds([]);
|
||||
setNewLocationName("");
|
||||
setAddingLocation(false);
|
||||
setError("");
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
@@ -130,14 +159,14 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
groupId,
|
||||
};
|
||||
|
||||
// Create one plant per selected zone (or one with no zone if none selected).
|
||||
const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined];
|
||||
// Create one plant per selected location (or one with no location if none).
|
||||
const locsToCreate = selectedLocationIds.length > 0 ? selectedLocationIds : [undefined];
|
||||
const results = await Promise.all(
|
||||
zonesToCreate.map((zone) =>
|
||||
locsToCreate.map((locationId) =>
|
||||
fetch("/api/plants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...base, zone }),
|
||||
body: JSON.stringify({ ...base, locationId }),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -149,7 +178,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
return;
|
||||
}
|
||||
|
||||
const count = zonesToCreate.length;
|
||||
const count = locsToCreate.length;
|
||||
toast({
|
||||
title: "Plant added!",
|
||||
description: count > 1
|
||||
@@ -254,23 +283,23 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multi-location picker */}
|
||||
{/* Multi-location picker (shared Location spine) */}
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>
|
||||
Location(s) on your property
|
||||
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
|
||||
</Label>
|
||||
|
||||
{/* Selected zones as chips */}
|
||||
{selectedZones.length > 0 && (
|
||||
{/* Selected locations as chips */}
|
||||
{selectedLocationIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{selectedZones.map((z) => (
|
||||
<Badge key={z} variant="secondary" className="gap-1 pr-1">
|
||||
{selectedLocationIds.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{z}
|
||||
{nameFor(id)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleZone(z)}
|
||||
onClick={() => toggleLocation(id)}
|
||||
className="ml-0.5 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -280,45 +309,45 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing zones as checkboxes */}
|
||||
{allZones.length > 0 && (
|
||||
{/* Existing locations as checkboxes */}
|
||||
{locationOptions.length > 0 && (
|
||||
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
|
||||
{allZones.map((z) => (
|
||||
{locationOptions.map((loc) => (
|
||||
<label
|
||||
key={z}
|
||||
key={loc.id}
|
||||
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedZones.includes(z)}
|
||||
onChange={() => toggleZone(z)}
|
||||
checked={selectedLocationIds.includes(loc.id)}
|
||||
onChange={() => toggleLocation(loc.id)}
|
||||
className="accent-[hsl(var(--leaf))]"
|
||||
/>
|
||||
{z}
|
||||
{loc.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new zone */}
|
||||
{addingZone ? (
|
||||
{/* Add new location */}
|
||||
{addingLocation ? (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
placeholder="e.g. Front bed, Back fence guild…"
|
||||
value={newZoneName}
|
||||
onChange={(e) => setNewZoneName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
|
||||
value={newLocationName}
|
||||
onChange={(e) => setNewLocationName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={addNewZone}>Add</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}>
|
||||
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingLocation(false); setNewLocationName(""); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingZone(true)}
|
||||
onClick={() => setAddingLocation(true)}
|
||||
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
|
||||
>
|
||||
+ Add new location
|
||||
@@ -398,7 +427,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"}
|
||||
{busy ? "Saving…" : selectedLocationIds.length > 1 ? `Add to ${selectedLocationIds.length} locations` : "Add plant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
58
src/components/garden/delete-plant-type-button.tsx
Normal file
58
src/components/garden/delete-plant-type-button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export function DeletePlantTypeButton({ id, name }: { id: string; name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function handleDelete() {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/plant-types/${id}`, { method: "DELETE" });
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
toast({ title: "Error", description: "Could not delete.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Deleted", description: `"${name}" removed from your plant types.` });
|
||||
setOpen(false);
|
||||
router.push("/garden/types");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete “{name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the plant-type reference. Your individual plantings stay put —
|
||||
they just won't be linked to a type anymore.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} disabled={busy}
|
||||
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,14 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||
|
||||
type LocationOption = { id: string; name: string };
|
||||
|
||||
const schema = z.object({
|
||||
commonName: z.string().min(1, "Name is required"),
|
||||
species: z.string().optional(),
|
||||
variety: z.string().optional(),
|
||||
category: z.nativeEnum(PlantCategory),
|
||||
zone: z.string().optional(),
|
||||
locationId: z.string().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -37,10 +39,19 @@ function toDateInput(d: Date | null): string {
|
||||
return new Date(d).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) {
|
||||
export function EditPlantButton({
|
||||
plant,
|
||||
locations = [],
|
||||
groups = [],
|
||||
}: {
|
||||
plant: Plant;
|
||||
locations?: LocationOption[];
|
||||
groups?: { id: string; name: string }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creatingZone, setCreatingZone] = useState(false);
|
||||
const [newZoneName, setNewZoneName] = useState("");
|
||||
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
|
||||
const [creatingLocation, setCreatingLocation] = useState(false);
|
||||
const [newLocationName, setNewLocationName] = useState("");
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -51,19 +62,44 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
||||
species: plant.species ?? "",
|
||||
variety: plant.variety ?? "",
|
||||
category: plant.category,
|
||||
zone: plant.zone ?? "",
|
||||
locationId: plant.locationId ?? "",
|
||||
plantedAt: toDateInput(plant.plantedAt),
|
||||
source: plant.source ?? "",
|
||||
notes: plant.notes ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
async function addNewLocation() {
|
||||
const name = newLocationName.trim();
|
||||
if (!name) return;
|
||||
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) {
|
||||
form.setValue("locationId", existing.id);
|
||||
setNewLocationName("");
|
||||
setCreatingLocation(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/locations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const loc = await res.json();
|
||||
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
|
||||
form.setValue("locationId", loc.id);
|
||||
setNewLocationName("");
|
||||
setCreatingLocation(false);
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
if (newZoneName.trim()) values.zone = newZoneName.trim();
|
||||
const res = await fetch(`/api/plants/${plant.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
body: JSON.stringify({ ...values, locationId: values.locationId || null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
@@ -127,12 +163,12 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Location on your property</Label>
|
||||
{!creatingZone ? (
|
||||
{!creatingLocation ? (
|
||||
<Select
|
||||
defaultValue={plant.zone ?? ""}
|
||||
defaultValue={plant.locationId ?? ""}
|
||||
onValueChange={(v) => {
|
||||
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
|
||||
else { form.setValue("zone", v); setNewZoneName(""); }
|
||||
if (v === "__new__") { setCreatingLocation(true); form.setValue("locationId", ""); }
|
||||
else { form.setValue("locationId", v); setNewLocationName(""); }
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -140,8 +176,8 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No location</SelectItem>
|
||||
{zones.map((z) => (
|
||||
<SelectItem key={z} value={z}>{z}</SelectItem>
|
||||
{locationOptions.map((loc) => (
|
||||
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__">+ Add new location…</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -150,11 +186,13 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
|
||||
value={newZoneName}
|
||||
onChange={(e) => setNewZoneName(e.target.value)}
|
||||
value={newLocationName}
|
||||
onChange={(e) => setNewLocationName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}>
|
||||
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingLocation(false); setNewLocationName(""); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
// The garden "shelf": every plant visible as a photo card with one-tap
|
||||
// logging. Search + chips replace the old collapse trees.
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Leaf, MapPin, Search, Users } from "lucide-react";
|
||||
import { GroupLogButton } from "@/components/garden/group-log-button";
|
||||
import { ZoneLogButton } from "@/components/garden/zone-log-button";
|
||||
import { LocationLogButton } from "@/components/garden/location-log-button";
|
||||
import { QuickLogActions } from "@/components/garden/quick-log-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Plant = {
|
||||
id: string;
|
||||
@@ -18,6 +19,8 @@ type Plant = {
|
||||
species: string | null;
|
||||
category: string;
|
||||
zone: string | null;
|
||||
locationId: string | null;
|
||||
location: { id: string; name: string } | null;
|
||||
plantedAt: Date | null;
|
||||
imageUrl: string | null;
|
||||
group: { id: string; name: string } | null;
|
||||
@@ -25,36 +28,55 @@ type Plant = {
|
||||
logs: { date: Date; type: string }[];
|
||||
};
|
||||
|
||||
type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { plants: number };
|
||||
};
|
||||
type Group = { id: string; name: string; _count: { plants: number } };
|
||||
type LocationOption = { id: string; name: string };
|
||||
|
||||
type ViewMode = "type" | "location";
|
||||
|
||||
// Cluster plants that share the same commonName + variety into one row.
|
||||
function clusterByVariety(plants: Plant[]) {
|
||||
const map = new Map<string, Plant[]>();
|
||||
for (const p of plants) {
|
||||
const key = `${p.commonName}||${p.variety ?? ""}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(p);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
|
||||
function placeName(p: Plant): string | null {
|
||||
return p.location?.name ?? p.zone ?? null;
|
||||
}
|
||||
|
||||
export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [view, setView] = useState<ViewMode>("type");
|
||||
const LAST_VERB: Record<string, string> = {
|
||||
OBSERVATION: "noted",
|
||||
CARE: "tended",
|
||||
HARVEST: "picked",
|
||||
TREATMENT: "treated",
|
||||
TRANSPLANT: "moved",
|
||||
DIED: "died",
|
||||
};
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function lastActivity(p: Plant): string | null {
|
||||
const log = p.logs[0];
|
||||
if (!log) return null;
|
||||
const days = Math.floor((Date.now() - new Date(log.date).getTime()) / 86_400_000);
|
||||
const when = days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`;
|
||||
return `${LAST_VERB[log.type] ?? "logged"} ${when}`;
|
||||
}
|
||||
|
||||
export function GardenGrid({ plants, groups, locations }: {
|
||||
plants: Plant[];
|
||||
groups: Group[];
|
||||
locations: LocationOption[];
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [chip, setChip] = useState<string>("all"); // "all" | "place" | group id
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
let list = plants;
|
||||
if (chip !== "all" && chip !== "place") list = list.filter((p) => p.group?.id === chip);
|
||||
if (q) {
|
||||
list = list.filter((p) =>
|
||||
p.commonName.toLowerCase().includes(q) ||
|
||||
p.variety?.toLowerCase().includes(q) ||
|
||||
p.species?.toLowerCase().includes(q) ||
|
||||
placeName(p)?.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return [...list].sort((a, b) => a.commonName.localeCompare(b.commonName));
|
||||
}, [plants, search, chip]);
|
||||
|
||||
const activeGroup = groups.find((g) => g.id === chip);
|
||||
|
||||
if (plants.length === 0) {
|
||||
return (
|
||||
@@ -67,240 +89,164 @@ export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
|
||||
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
|
||||
By type
|
||||
</Button>
|
||||
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
|
||||
By location
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Find a plant…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view === "type" ? (
|
||||
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
|
||||
) : (
|
||||
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeView({ plants, groups, expanded, toggle }: {
|
||||
plants: Plant[];
|
||||
groups: Group[];
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
}) {
|
||||
const ungrouped = plants.filter((p) => !p.group);
|
||||
const grouped = groups.map((g) => ({
|
||||
...g,
|
||||
plants: plants.filter((p) => p.group?.id === g.id),
|
||||
})).filter((g) => g.plants.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((g) => {
|
||||
const isOpen = expanded.has(g.id);
|
||||
const clusters = clusterByVariety(g.plants);
|
||||
return (
|
||||
<div key={g.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggle(g.id)}
|
||||
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
<Users className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||
<span className="font-display font-semibold">{g.name}</span>
|
||||
<span className="text-sm text-muted-foreground">· {g.plants.length} {g.plants.length === 1 ? "plant" : "plants"}</span>
|
||||
</button>
|
||||
<GroupLogButton groupId={g.id} groupName={g.name} />
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className="pl-6 space-y-2">
|
||||
{clusters.map((cluster) => (
|
||||
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
|
||||
<div className="space-y-2">
|
||||
{clusterByVariety(ungrouped).map((cluster) => (
|
||||
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single variety that may span multiple locations.
|
||||
function VarietyRow({ plants, expanded, toggle }: {
|
||||
plants: Plant[];
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
}) {
|
||||
const rep = plants[0];
|
||||
const key = `variety||${rep.commonName}||${rep.variety ?? ""}`;
|
||||
const isOpen = expanded.has(key);
|
||||
|
||||
// Collapsible row with location chips.
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm">
|
||||
{rep.commonName}
|
||||
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Chip active={chip === "all"} onClick={() => setChip("all")}>
|
||||
All · {plants.length}
|
||||
</Chip>
|
||||
{groups.filter((g) => g._count.plants > 0).map((g) => (
|
||||
<Chip key={g.id} active={chip === g.id} onClick={() => setChip(g.id)}>
|
||||
{g.name}
|
||||
</Chip>
|
||||
))}
|
||||
<Chip active={chip === "place"} onClick={() => setChip("place")}>
|
||||
<MapPin className="h-3 w-3" /> By place
|
||||
</Chip>
|
||||
{activeGroup && (
|
||||
<span className="ml-auto">
|
||||
<GroupLogButton groupId={activeGroup.id} groupName={activeGroup.name} />
|
||||
</span>
|
||||
{!isOpen && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{plants.map((p) => (
|
||||
<Badge key={p.id} variant="outline" className="text-xs gap-1">
|
||||
<MapPin className="h-2.5 w-2.5" />
|
||||
{p.zone ?? "No location"}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
||||
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
{chip === "place" ? (
|
||||
<PlaceSections plants={filtered} locations={locations} />
|
||||
) : (
|
||||
<CardGrid plants={filtered} locations={locations} />
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No plants match{search ? ` “${search.trim()}”` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationView({ plants, expanded, toggle }: {
|
||||
plants: Plant[];
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
function Chip({ active, onClick, children }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||
}) {
|
||||
const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort();
|
||||
const noZone = plants.filter((p) => !p.zone);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
|
||||
active
|
||||
? "bg-[hsl(var(--leaf))] text-white border-[hsl(var(--leaf))]"
|
||||
: "bg-background hover:bg-accent text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CardGrid({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{plants.map((p) => <PlantCard key={p.id} plant={p} locations={locations} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceSections({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
|
||||
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
|
||||
const byKey = new Map<string, Place>();
|
||||
const noPlace: Plant[] = [];
|
||||
|
||||
for (const p of plants) {
|
||||
const name = placeName(p);
|
||||
if (!name) { noPlace.push(p); continue; }
|
||||
const key = p.locationId ?? `zone:${name}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
|
||||
byKey.get(key)!.plants.push(p);
|
||||
}
|
||||
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{zones.map((zone) => {
|
||||
const zonePlants = plants.filter((p) => p.zone === zone);
|
||||
const isOpen = expanded.has(zone);
|
||||
return (
|
||||
<div key={zone} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggle(zone)}
|
||||
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||
<span className="font-display font-semibold">{zone}</span>
|
||||
<span className="text-sm text-muted-foreground">· {zonePlants.length} {zonePlants.length === 1 ? "plant" : "plants"}</span>
|
||||
</button>
|
||||
<ZoneLogButton zone={zone} plantCount={zonePlants.length} />
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
||||
{zonePlants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggle(zone)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{zonePlants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
|
||||
</button>
|
||||
)}
|
||||
{places.map((place) => (
|
||||
<div key={place.key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||
<span className="font-display font-semibold">{place.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}
|
||||
</span>
|
||||
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{noZone.length > 0 && (
|
||||
<CardGrid plants={place.plants} locations={locations} />
|
||||
</div>
|
||||
))}
|
||||
{noPlace.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{zones.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{noZone.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>
|
||||
<CardGrid plants={noPlace} locations={locations} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlantCard({ plant }: { plant: Plant }) {
|
||||
function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) {
|
||||
const place = placeName(plant);
|
||||
const activity = lastActivity(plant);
|
||||
|
||||
return (
|
||||
<Link href={`/garden/${plant.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
|
||||
{plant.imageUrl && (
|
||||
<div className="h-36 w-full overflow-hidden bg-muted">
|
||||
<div className="rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<Link href={`/garden/${plant.id}`} className="block group">
|
||||
{plant.imageUrl ? (
|
||||
<div className="h-28 w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={plant.imageUrl}
|
||||
alt={plant.commonName}
|
||||
className="h-full w-full object-cover"
|
||||
className="h-full w-full object-cover group-hover:scale-[1.03] transition-transform"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!plant.imageUrl && (
|
||||
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
||||
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
|
||||
) : (
|
||||
<div className="h-28 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
||||
<Leaf className="h-9 w-9 text-[hsl(var(--leaf)/.3)]" />
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="pt-3 pb-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{plant.commonName}
|
||||
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
|
||||
</p>
|
||||
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{plant.zone && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{plant.zone}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3 w-3 shrink-0" />
|
||||
<span>Planted {formatDate(plant.plantedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.logs[0] && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span>Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 text-xs text-muted-foreground/60">
|
||||
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</Link>
|
||||
<div className="p-2.5 pt-2 flex-1 flex flex-col gap-1.5">
|
||||
<Link href={`/garden/${plant.id}`} className="min-w-0">
|
||||
<p className="font-medium text-sm truncate leading-tight">
|
||||
{plant.commonName}
|
||||
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{place && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
{place && activity && " · "}
|
||||
{activity}
|
||||
</p>
|
||||
{plant.group && (
|
||||
<Badge variant="outline" className="w-fit text-[10px] gap-1 py-0">
|
||||
<Users className="h-2.5 w-2.5" />
|
||||
{plant.group.name}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-auto pt-1">
|
||||
<QuickLogActions plantId={plant.id} plantName={plant.commonName} locations={locations} />
|
||||
</div>
|
||||
</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