Compare commits
10 Commits
420321c9a9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dbf6ea335 | |||
| 4dcdaa474a | |||
| a0c1ae9703 | |||
| ba2a76f7dd | |||
| ed0a3f9389 | |||
| 4672f70a60 | |||
| 5431177b7a | |||
| 155e7849e1 | |||
| 84c9e5304e | |||
| 897b1c9bef |
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.alwisp.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
run: |
|
||||||
|
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
|
||||||
|
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
|
||||||
+10
-4
@@ -51,12 +51,18 @@ FROM node:22-alpine AS runtime
|
|||||||
# Install tini for proper PID 1 signal handling
|
# Install tini for proper PID 1 signal handling
|
||||||
RUN apk add --no-cache tini su-exec
|
RUN apk add --no-cache tini su-exec
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# No native deps — runtime only needs node_modules for pure-JS packages (express, cors, etc.)
|
# Copy workspace manifests and lockfile — pnpm needs these to install correctly
|
||||||
# Copy them directly from the builder stage instead of re-installing
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* .npmrc ./
|
||||||
COPY --from=server-builder /build/node_modules ./node_modules
|
COPY apps/server/package.json ./apps/server/
|
||||||
COPY --from=server-builder /build/apps/server/node_modules ./apps/server/node_modules 2>/dev/null || true
|
COPY apps/client/package.json ./apps/client/
|
||||||
|
|
||||||
|
# Install production dependencies only — fresh install avoids broken symlinks
|
||||||
|
# from COPY-ing pnpm's virtual store between stages
|
||||||
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
# Copy compiled server output (includes dist/db/migrations/*.js compiled by tsc)
|
# Copy compiled server output (includes dist/db/migrations/*.js compiled by tsc)
|
||||||
COPY --from=server-builder /build/apps/server/dist ./apps/server/dist
|
COPY --from=server-builder /build/apps/server/dist ./apps/server/dist
|
||||||
|
|||||||
+390
@@ -0,0 +1,390 @@
|
|||||||
|
# Family Planner — Project Reference
|
||||||
|
|
||||||
|
A self-hosted family dashboard designed for always-on display (wall tablet, TV, Unraid server). Manages calendars, chores, shopping, meals, a message board, countdowns, and a photo screensaver — all in one Docker container with zero external services required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| **Runtime** | Node.js 22, Docker (Alpine) |
|
||||||
|
| **Backend** | Express 4, TypeScript 5 |
|
||||||
|
| **Database** | Node.js built-in SQLite (`node:sqlite`), WAL mode |
|
||||||
|
| **File uploads** | Multer |
|
||||||
|
| **Frontend** | React 18, TypeScript 5, Vite 5 |
|
||||||
|
| **Styling** | Tailwind CSS 3, CSS custom properties (theme tokens) |
|
||||||
|
| **Animation** | Framer Motion 11 |
|
||||||
|
| **State / data** | TanStack Query 5, Zustand 4 |
|
||||||
|
| **Routing** | React Router 6 |
|
||||||
|
| **Icons** | Lucide React |
|
||||||
|
| **Date utils** | date-fns 3 |
|
||||||
|
| **Package manager** | pnpm (workspaces monorepo) |
|
||||||
|
| **CI/CD** | Gitea Actions → Docker build & push to private registry |
|
||||||
|
| **Deployment target** | Unraid (Community Applications / CLI install) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
family-planner/
|
||||||
|
├── apps/
|
||||||
|
│ ├── client/ # React frontend
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── layout/
|
||||||
|
│ │ │ │ └── AppShell.tsx # Sidebar, mobile drawer, page wrapper
|
||||||
|
│ │ │ ├── screensaver/
|
||||||
|
│ │ │ │ └── Screensaver.tsx # Idle screensaver w/ Ken Burns slideshow
|
||||||
|
│ │ │ └── ui/ # Design-system primitives
|
||||||
|
│ │ │ ├── Avatar.tsx
|
||||||
|
│ │ │ ├── Badge.tsx
|
||||||
|
│ │ │ ├── Button.tsx
|
||||||
|
│ │ │ ├── Input.tsx
|
||||||
|
│ │ │ ├── Modal.tsx
|
||||||
|
│ │ │ ├── Select.tsx
|
||||||
|
│ │ │ ├── Textarea.tsx
|
||||||
|
│ │ │ └── ThemeToggle.tsx
|
||||||
|
│ │ ├── features/ # Feature-scoped sub-components
|
||||||
|
│ │ │ ├── calendar/
|
||||||
|
│ │ │ ├── chores/
|
||||||
|
│ │ │ └── shopping/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ └── useMembers.ts
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ └── api.ts # Axios instance + all API types
|
||||||
|
│ │ ├── pages/ # One file per route
|
||||||
|
│ │ │ ├── Calendar.tsx ✅ complete
|
||||||
|
│ │ │ ├── Chores.tsx ✅ complete
|
||||||
|
│ │ │ ├── Photos.tsx ✅ complete
|
||||||
|
│ │ │ ├── Settings.tsx ✅ complete
|
||||||
|
│ │ │ ├── Members.tsx ✅ complete
|
||||||
|
│ │ │ ├── Shopping.tsx ✅ complete
|
||||||
|
│ │ │ ├── Dashboard.tsx 🔲 stub
|
||||||
|
│ │ │ ├── Meals.tsx 🔲 stub
|
||||||
|
│ │ │ ├── Board.tsx 🔲 stub
|
||||||
|
│ │ │ └── Countdowns.tsx 🔲 stub
|
||||||
|
│ │ └── store/
|
||||||
|
│ │ ├── settingsStore.ts # Zustand settings (synced from DB)
|
||||||
|
│ │ └── themeStore.ts # Zustand theme + CSS token application
|
||||||
|
│ └── server/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── db.ts # node:sqlite wrapper + transaction helper
|
||||||
|
│ │ ├── runner.ts # Sequential migration runner
|
||||||
|
│ │ └── migrations/
|
||||||
|
│ │ └── 001_initial.ts # Full schema + seed data
|
||||||
|
│ ├── routes/ # One router file per domain
|
||||||
|
│ │ ├── members.ts ✅ full CRUD
|
||||||
|
│ │ ├── settings.ts ✅ key/value GET + PATCH
|
||||||
|
│ │ ├── events.ts ✅ full CRUD + date-range filter
|
||||||
|
│ │ ├── shopping.ts ✅ full CRUD (lists + items)
|
||||||
|
│ │ ├── chores.ts ✅ full CRUD + completions
|
||||||
|
│ │ ├── meals.ts ✅ full CRUD (upsert by date)
|
||||||
|
│ │ ├── messages.ts ✅ full CRUD + pin/expiry
|
||||||
|
│ │ ├── countdowns.ts ✅ full CRUD + event link
|
||||||
|
│ │ └── photos.ts ✅ list, upload, serve, delete, slideshow
|
||||||
|
│ └── index.ts # Express app + static client serve
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ └── docker-build.yml # Push to main → build + push Docker image
|
||||||
|
├── Dockerfile # Multi-stage: client build → server build → runtime
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docker-entrypoint.sh # PUID/PGID ownership fix + app start
|
||||||
|
├── UNRAID.md # Full Unraid install guide (GUI + CLI)
|
||||||
|
├── INSTALL.md
|
||||||
|
└── PROJECT.md # ← this file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
All tables live in a single SQLite file at `$DATA_DIR/family.db`.
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `members` | Family member profiles (name, color, avatar) |
|
||||||
|
| `settings` | Key/value app configuration store |
|
||||||
|
| `events` | Calendar events (all-day, timed, recurrence field) |
|
||||||
|
| `shopping_lists` | Named shopping lists |
|
||||||
|
| `shopping_items` | Line items (quantity, checked state, member assignment) |
|
||||||
|
| `chores` | Chore definitions (recurrence, due date, status) |
|
||||||
|
| `chore_completions` | Completion history per chore per member |
|
||||||
|
| `meals` | One dinner entry per calendar date (upsert pattern) |
|
||||||
|
| `messages` | Board messages (color, emoji, pin, optional expiry) |
|
||||||
|
| `countdowns` | Countdown timers (linked to calendar event or standalone) |
|
||||||
|
|
||||||
|
Photos are stored as files on disk — no database table. The configured folder path lives in `settings.photo_folder` (or `$PHOTOS_DIR` env var in Docker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed Features
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- [x] pnpm monorepo (`apps/client` + `apps/server`)
|
||||||
|
- [x] Multi-stage Dockerfile — client build, server build, minimal Alpine runtime
|
||||||
|
- [x] Sequential migration runner — aborts startup on failure
|
||||||
|
- [x] Gitea Actions CI — builds and pushes Docker image on push to `main`
|
||||||
|
- [x] Unraid install guide (GUI template + CLI script)
|
||||||
|
- [x] PUID/PGID support via `docker-entrypoint.sh`
|
||||||
|
- [x] `tini` for correct PID 1 signal handling
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
- [x] CSS custom property token system (surface, border, text, accent)
|
||||||
|
- [x] Light / dark mode with smooth transitions
|
||||||
|
- [x] 5 accent colours (Indigo, Teal, Rose, Amber, Slate)
|
||||||
|
- [x] Collapsible sidebar with animated labels
|
||||||
|
- [x] Mobile overlay drawer
|
||||||
|
- [x] Primitive component library: `Button`, `Input`, `Textarea`, `Select`, `Modal`, `Avatar`, `Badge`, `ThemeToggle`
|
||||||
|
- [x] Page entry/exit animations via Framer Motion
|
||||||
|
|
||||||
|
### Members
|
||||||
|
- [x] Create, edit, delete family members
|
||||||
|
- [x] Custom display colour per member
|
||||||
|
- [x] Avatar field
|
||||||
|
- [x] Member assignment used throughout app (events, chores, shopping items)
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- [x] Theme mode + accent colour (persisted to DB and applied via CSS tokens)
|
||||||
|
- [x] Photo folder path (absolute path or Docker bind-mount override)
|
||||||
|
- [x] Slideshow speed (3 s – 15 s)
|
||||||
|
- [x] Slideshow order (random / sequential / newest first)
|
||||||
|
- [x] Idle timeout (1 min – 10 min, or disabled)
|
||||||
|
- [x] Time format (12h / 24h)
|
||||||
|
- [x] Date format (MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD)
|
||||||
|
- [x] Weather API key, location, units (stored — widget pending)
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
- [x] Month grid view with event dots
|
||||||
|
- [x] Per-day event list modal
|
||||||
|
- [x] Create / edit / delete events
|
||||||
|
- [x] All-day events
|
||||||
|
- [x] Member colour coding
|
||||||
|
- [x] Custom event colour override
|
||||||
|
- [x] Recurrence field (stored, display only)
|
||||||
|
- [x] Month navigation with slide animation
|
||||||
|
|
||||||
|
### Shopping
|
||||||
|
- [x] Multiple named lists
|
||||||
|
- [x] Add / edit / delete items with optional quantity
|
||||||
|
- [x] Check / uncheck items (checked items grouped separately)
|
||||||
|
- [x] Clear all checked items
|
||||||
|
- [x] Member assignment per item
|
||||||
|
- [x] Keyboard shortcut: Enter to add
|
||||||
|
- [x] Create / delete lists
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
- [x] Create / edit / delete chores
|
||||||
|
- [x] Title, description, due date, recurrence label, member assignment
|
||||||
|
- [x] Status: pending / done
|
||||||
|
- [x] Filter by status or by member
|
||||||
|
- [x] Completion count badge
|
||||||
|
- [x] Completion history recorded in `chore_completions`
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- [x] Batch upload via browser (drag & drop anywhere on page, or file picker)
|
||||||
|
- [x] Multi-file selection — up to 200 files per upload, 50 MB each
|
||||||
|
- [x] Server saves to configured photo folder root (multer disk storage)
|
||||||
|
- [x] Filenames sanitised + timestamp-suffixed to prevent collisions
|
||||||
|
- [x] Responsive photo grid (2 – 6 columns)
|
||||||
|
- [x] Hover overlay: filename label + delete button
|
||||||
|
- [x] Click to open full-screen lightbox
|
||||||
|
- [x] Lightbox: prev / next navigation, keyboard arrows, Esc to close, photo counter
|
||||||
|
- [x] Permanent delete with confirmation modal
|
||||||
|
- [x] Graceful "not configured" state with link to Settings
|
||||||
|
- [x] Empty state with prominent upload drop zone
|
||||||
|
- [x] Recursive folder scan (pre-existing subdirectory photos appear in slideshow)
|
||||||
|
- [x] Path traversal protection on all file-serving and delete endpoints
|
||||||
|
|
||||||
|
### Screensaver
|
||||||
|
- [x] Activates automatically after configurable idle timeout
|
||||||
|
- [x] Idle detection: `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`
|
||||||
|
- [x] Ken Burns effect — 8 presets alternating zoom-in / zoom-out with diagonal, vertical, and horizontal pan
|
||||||
|
- [x] Crossfade between photos (1.2 s, `AnimatePresence mode="sync"`)
|
||||||
|
- [x] Respects `slideshow_order` setting (random shuffled each cycle, sequential, newest first)
|
||||||
|
- [x] Respects `slideshow_speed` setting (3 s – 15 s)
|
||||||
|
- [x] Next photo preloaded while current is displayed
|
||||||
|
- [x] Live clock overlay — large thin numerals, responsive font size (`clamp`)
|
||||||
|
- [x] Clock respects `time_format` setting (12h with AM/PM, 24h)
|
||||||
|
- [x] Date line below clock
|
||||||
|
- [x] "Tap to dismiss" hint fades out after 3.5 s
|
||||||
|
- [x] Dismiss on click, touch, or any keypress
|
||||||
|
- [x] No-photo fallback: dark gradient + clock only
|
||||||
|
- [x] Gradient scrim over photos for clock legibility
|
||||||
|
- [x] `z-[200]` — renders above all modals and UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In Progress / Stubs
|
||||||
|
|
||||||
|
These pages have **complete backend APIs** but their frontend is a placeholder `<div>`.
|
||||||
|
|
||||||
|
### Meals `🔲`
|
||||||
|
**Backend:** `GET /api/meals`, `PUT /api/meals/:date` (upsert), `DELETE /api/meals/:date`
|
||||||
|
**Planned UI:**
|
||||||
|
- Weekly grid (Mon – Sun) showing the current week's dinner plan
|
||||||
|
- Click any day to open a quick-edit modal (title, description, recipe URL)
|
||||||
|
- Week navigation (prev / next)
|
||||||
|
- "No meal planned" empty state per day
|
||||||
|
- Recipe link button when `recipe_url` is set
|
||||||
|
|
||||||
|
### Message Board `🔲`
|
||||||
|
**Backend:** `GET /api/messages`, `POST`, `PATCH /:id`, `DELETE /:id`
|
||||||
|
**Planned UI:**
|
||||||
|
- Sticky-note card grid
|
||||||
|
- Custom card background colour + emoji
|
||||||
|
- Pin important messages to the top
|
||||||
|
- Optional expiry (auto-removed after date)
|
||||||
|
- Member attribution
|
||||||
|
- Quick compose at the bottom
|
||||||
|
|
||||||
|
### Countdowns `🔲`
|
||||||
|
**Backend:** `GET /api/countdowns`, `POST`, `PUT /:id`, `DELETE /:id`
|
||||||
|
**Planned UI:**
|
||||||
|
- Card grid showing days remaining to each target date
|
||||||
|
- Large number + label per card
|
||||||
|
- Custom colour and emoji per countdown
|
||||||
|
- Optional link to a calendar event
|
||||||
|
- Flag to show on Dashboard
|
||||||
|
- Completed countdowns hidden (target date in the past is filtered server-side)
|
||||||
|
|
||||||
|
### Dashboard `🔲`
|
||||||
|
**Planned UI (depends on all modules above being complete):**
|
||||||
|
- Today's date + time (respects `time_format` / `date_format`)
|
||||||
|
- Weather widget (OpenWeatherMap — API key + location already in Settings)
|
||||||
|
- Upcoming calendar events (next 3–5)
|
||||||
|
- Today's meal plan
|
||||||
|
- Active chore count / completion summary
|
||||||
|
- Shopping list item count
|
||||||
|
- Pinned messages preview
|
||||||
|
- Countdowns flagged `show_on_dashboard`
|
||||||
|
- Quick-action buttons to each module
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Near-term (next sessions)
|
||||||
|
- [ ] **Meals page** — weekly dinner grid with modal editor
|
||||||
|
- [ ] **Message Board page** — sticky-note UI with compose, pin, expiry
|
||||||
|
- [ ] **Countdowns page** — day-count cards with create/edit/delete
|
||||||
|
- [ ] **Dashboard** — wire up all modules once the above are complete
|
||||||
|
- [ ] **Weather widget** — OpenWeatherMap fetch on the Dashboard using stored credentials
|
||||||
|
|
||||||
|
### Medium-term
|
||||||
|
- [ ] **Recurring chore automation** — auto-reset status to `pending` on schedule instead of just storing the recurrence label
|
||||||
|
- [ ] **Calendar recurrence expansion** — expand recurring events into visible instances on the grid
|
||||||
|
- [ ] **Meal recipe import** — paste a URL, scrape title from `<title>` or Open Graph
|
||||||
|
- [ ] **Shopping list reorder** — drag-and-drop reorder items (sort_order column already in schema)
|
||||||
|
- [ ] **Member avatar upload** — upload image instead of text initials
|
||||||
|
- [ ] **Screensaver burn-in mitigation** — slowly drift clock position across OLED panels
|
||||||
|
|
||||||
|
### Future / Nice-to-have
|
||||||
|
- [ ] **PWA manifest + service worker** — installable on tablet home screen, offline cache for static assets
|
||||||
|
- [ ] **Push notifications** — chore reminders, upcoming events (requires service worker)
|
||||||
|
- [ ] **Multi-user auth** — PIN-per-member or password gate (currently open LAN access only)
|
||||||
|
- [ ] **Backup / restore UI** — download `family.db` and restore from file in the app
|
||||||
|
- [ ] **Community Applications XML template** — publish official Unraid CA template
|
||||||
|
- [ ] **Dark mode auto-schedule** — switch theme at sunrise/sunset based on location
|
||||||
|
- [ ] **Grocery store integration** — import a shared list from a URL or barcode scan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
All endpoints are under `/api`. The server also serves the built React client at `/` (catch-all `index.html`).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/members` | List all members |
|
||||||
|
| POST | `/api/members` | Create member |
|
||||||
|
| PUT | `/api/members/:id` | Update member |
|
||||||
|
| DELETE | `/api/members/:id` | Delete member |
|
||||||
|
| GET | `/api/settings` | Get all settings as flat object |
|
||||||
|
| PATCH | `/api/settings` | Update one or more settings keys |
|
||||||
|
| GET | `/api/events` | List events (optional `?start=&end=` ISO range) |
|
||||||
|
| POST | `/api/events` | Create event |
|
||||||
|
| PUT | `/api/events/:id` | Update event |
|
||||||
|
| DELETE | `/api/events/:id` | Delete event |
|
||||||
|
| GET | `/api/shopping/lists` | List all shopping lists |
|
||||||
|
| POST | `/api/shopping/lists` | Create list |
|
||||||
|
| DELETE | `/api/shopping/lists/:id` | Delete list + cascade items |
|
||||||
|
| GET | `/api/shopping/lists/:id/items` | List items for a list |
|
||||||
|
| POST | `/api/shopping/lists/:id/items` | Add item |
|
||||||
|
| PATCH | `/api/shopping/lists/:id/items/:itemId` | Update item (check, rename, etc.) |
|
||||||
|
| DELETE | `/api/shopping/lists/:id/items/:itemId` | Delete item |
|
||||||
|
| DELETE | `/api/shopping/lists/:id/items/checked` | Clear all checked items |
|
||||||
|
| GET | `/api/chores` | List chores with member info + completion count |
|
||||||
|
| POST | `/api/chores` | Create chore |
|
||||||
|
| PUT | `/api/chores/:id` | Update chore |
|
||||||
|
| PATCH | `/api/chores/:id/complete` | Record a completion |
|
||||||
|
| DELETE | `/api/chores/:id` | Delete chore |
|
||||||
|
| GET | `/api/meals` | List meals (optional `?start=&end=` date range) |
|
||||||
|
| PUT | `/api/meals/:date` | Upsert meal for a date (YYYY-MM-DD) |
|
||||||
|
| DELETE | `/api/meals/:date` | Remove meal for a date |
|
||||||
|
| GET | `/api/messages` | List non-expired messages (pinned first) |
|
||||||
|
| POST | `/api/messages` | Create message |
|
||||||
|
| PATCH | `/api/messages/:id` | Update message |
|
||||||
|
| DELETE | `/api/messages/:id` | Delete message |
|
||||||
|
| GET | `/api/countdowns` | List future countdowns (ordered by date) |
|
||||||
|
| POST | `/api/countdowns` | Create countdown |
|
||||||
|
| PUT | `/api/countdowns/:id` | Update countdown |
|
||||||
|
| DELETE | `/api/countdowns/:id` | Delete countdown |
|
||||||
|
| GET | `/api/photos` | List all photos recursively `{ configured, count, photos }` |
|
||||||
|
| GET | `/api/photos/slideshow` | Same list, optimised for screensaver use |
|
||||||
|
| GET | `/api/photos/file/*` | Serve a photo by relative path (path traversal protected) |
|
||||||
|
| POST | `/api/photos/upload` | Upload photos (`multipart/form-data`, field `photos`) |
|
||||||
|
| DELETE | `/api/photos/file/*` | Delete a photo by relative path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3001` | HTTP port the server listens on |
|
||||||
|
| `DATA_DIR` | `../../data` (dev) / `/data` (Docker) | SQLite database directory |
|
||||||
|
| `PHOTOS_DIR` | *(unset)* | Override photo folder path (ignores DB setting when set) |
|
||||||
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | CORS allowed origin (dev only) |
|
||||||
|
| `PUID` | `99` | User ID for file ownership in container |
|
||||||
|
| `PGID` | `100` | Group ID for file ownership in container |
|
||||||
|
| `TZ` | `UTC` | Container timezone |
|
||||||
|
| `NODE_NO_WARNINGS` | `1` | Suppresses experimental SQLite API warning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install deps
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start both client (port 5173) and server (port 3001) with hot reload
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Type-check client
|
||||||
|
pnpm --filter client exec tsc --noEmit
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server proxies `/api/*` to `localhost:3001`, so you can develop against the live server without CORS issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image locally
|
||||||
|
docker build -t family-planner .
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
docker run -p 3001:3001 \
|
||||||
|
-v $(pwd)/data:/data \
|
||||||
|
-v /path/to/photos:/photos \
|
||||||
|
family-planner
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gitea Actions workflow builds and pushes automatically on every push to `main`.
|
||||||
Generated
+3113
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
|||||||
Image, Menu, X, ChevronRight,
|
Image, Menu, X, ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
||||||
|
import { Screensaver } from '@/components/screensaver/Screensaver';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -192,6 +193,9 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */}
|
||||||
|
<Screensaver />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { api, type AppSettings } from '@/lib/api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
interface Photo {
|
||||||
|
name: string;
|
||||||
|
rel: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ken Burns presets ──────────────────────────────────────────────────────
|
||||||
|
// Scale always ≥ 1.08 so panning never reveals black edges.
|
||||||
|
// Alternates between zoom-in and zoom-out for variety.
|
||||||
|
const KB_PRESETS = [
|
||||||
|
{ i: { scale: 1.08, x: '-3%', y: '-2%' }, a: { scale: 1.18, x: '3%', y: '2%' } }, // zoom in → bottom-right
|
||||||
|
{ i: { scale: 1.18, x: '3%', y: '2%' }, a: { scale: 1.08, x: '-3%', y: '-2%' } }, // zoom out → top-left
|
||||||
|
{ i: { scale: 1.08, x: '3%', y: '-2%' }, a: { scale: 1.18, x: '-3%', y: '2%' } }, // zoom in → bottom-left
|
||||||
|
{ i: { scale: 1.18, x: '-3%', y: '2%' }, a: { scale: 1.08, x: '3%', y: '-2%' } }, // zoom out → top-right
|
||||||
|
{ i: { scale: 1.08, x: '0%', y: '-3%' }, a: { scale: 1.18, x: '0%', y: '3%' } }, // zoom in → pan down
|
||||||
|
{ i: { scale: 1.18, x: '0%', y: '3%' }, a: { scale: 1.08, x: '0%', y: '-3%' } }, // zoom out → pan up
|
||||||
|
{ i: { scale: 1.08, x: '-3%', y: '0%' }, a: { scale: 1.18, x: '3%', y: '0%' } }, // zoom in → pan right
|
||||||
|
{ i: { scale: 1.18, x: '3%', y: '0%' }, a: { scale: 1.08, x: '-3%', y: '0%' } }, // zoom out → pan left
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
function shuffleArray<T>(arr: T[]): T[] {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderPhotos(photos: Photo[], order: string): Photo[] {
|
||||||
|
if (order === 'random') return shuffleArray(photos);
|
||||||
|
if (order === 'newest') return [...photos].reverse();
|
||||||
|
return [...photos]; // sequential
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a random Ken Burns preset that isn't the one we just used
|
||||||
|
function pickKb(prevIdx: number, total: number = KB_PRESETS.length): number {
|
||||||
|
if (total === 1) return 0;
|
||||||
|
let next: number;
|
||||||
|
do { next = Math.floor(Math.random() * total); } while (next === prevIdx);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export function Screensaver() {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [photoIdx, setPhotoIdx] = useState(0);
|
||||||
|
const [kbIdx, setKbIdx] = useState(0);
|
||||||
|
const [clock, setClock] = useState(new Date());
|
||||||
|
const [hintVisible, setHintVisible] = useState(false);
|
||||||
|
const [orderedPhotos, setOrderedPhotos] = useState<Photo[]>([]);
|
||||||
|
|
||||||
|
// Refs to avoid stale closures in event handlers
|
||||||
|
const activeRef = useRef(false);
|
||||||
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const photoTimerRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
const hintTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Keep ref in sync with state
|
||||||
|
useEffect(() => { activeRef.current = active; }, [active]);
|
||||||
|
|
||||||
|
// ── Fetch settings ───────────────────────────────────────────────────────
|
||||||
|
const { data: settings } = useQuery<AppSettings>({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => api.get('/settings').then((r) => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fetch photos for slideshow ────────────────────────────────────────────
|
||||||
|
const { data: slideshowData } = useQuery<{ count: number; photos: Photo[] }>({
|
||||||
|
queryKey: ['photos-slideshow'],
|
||||||
|
queryFn: () => api.get('/photos/slideshow').then((r) => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const idleTimeoutMs = parseInt(settings?.idle_timeout ?? '120000', 10);
|
||||||
|
const slideshowSpeed = parseInt(settings?.slideshow_speed ?? '6000', 10);
|
||||||
|
const slideshowOrder = settings?.slideshow_order ?? 'random';
|
||||||
|
const timeFormat = settings?.time_format ?? '12h';
|
||||||
|
const allPhotos = slideshowData?.photos ?? [];
|
||||||
|
|
||||||
|
// ── Clock tick ────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = setInterval(() => setClock(new Date()), 1000);
|
||||||
|
return () => clearInterval(tick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Activate ──────────────────────────────────────────────────────────────
|
||||||
|
const activate = useCallback(() => {
|
||||||
|
const ordered = orderPhotos(allPhotos, slideshowOrder);
|
||||||
|
setOrderedPhotos(ordered);
|
||||||
|
setPhotoIdx(0);
|
||||||
|
setKbIdx(Math.floor(Math.random() * KB_PRESETS.length));
|
||||||
|
setActive(true);
|
||||||
|
setHintVisible(true);
|
||||||
|
clearTimeout(hintTimerRef.current);
|
||||||
|
hintTimerRef.current = setTimeout(() => setHintVisible(false), 3500);
|
||||||
|
}, [allPhotos, slideshowOrder]);
|
||||||
|
|
||||||
|
// ── Deactivate ────────────────────────────────────────────────────────────
|
||||||
|
const deactivate = useCallback(() => {
|
||||||
|
setActive(false);
|
||||||
|
clearTimeout(hintTimerRef.current);
|
||||||
|
setHintVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Idle detection ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (idleTimeoutMs === 0) return; // disabled in settings
|
||||||
|
|
||||||
|
const startIdleTimer = () => {
|
||||||
|
clearTimeout(idleTimerRef.current);
|
||||||
|
idleTimerRef.current = setTimeout(activate, idleTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onActivity = () => {
|
||||||
|
if (activeRef.current) return; // screensaver handles its own dismissal
|
||||||
|
startIdleTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const;
|
||||||
|
EVENTS.forEach((e) => document.addEventListener(e, onActivity, { passive: true }));
|
||||||
|
startIdleTimer(); // kick off on mount / settings change
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
EVENTS.forEach((e) => document.removeEventListener(e, onActivity));
|
||||||
|
clearTimeout(idleTimerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activate, idleTimeoutMs]); // intentionally omit `active` — we read it via ref
|
||||||
|
|
||||||
|
// ── Dismiss on any keypress while screensaver is active ───────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const onKey = () => deactivate();
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [active, deactivate]);
|
||||||
|
|
||||||
|
// ── Photo advance timer ───────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || orderedPhotos.length === 0) return;
|
||||||
|
|
||||||
|
photoTimerRef.current = setInterval(() => {
|
||||||
|
setPhotoIdx((prev) => {
|
||||||
|
const next = prev + 1;
|
||||||
|
if (next >= orderedPhotos.length) {
|
||||||
|
// Re-order for next cycle (reshuffle random)
|
||||||
|
if (slideshowOrder === 'random') {
|
||||||
|
setOrderedPhotos(shuffleArray(allPhotos));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setKbIdx((prev) => pickKb(prev));
|
||||||
|
}, slideshowSpeed);
|
||||||
|
|
||||||
|
return () => clearInterval(photoTimerRef.current);
|
||||||
|
}, [active, orderedPhotos.length, slideshowSpeed, slideshowOrder, allPhotos]);
|
||||||
|
|
||||||
|
// ── Derived values ────────────────────────────────────────────────────────
|
||||||
|
const currentPhoto = orderedPhotos[photoIdx] ?? null;
|
||||||
|
const nextPhoto = orderedPhotos[(photoIdx + 1) % Math.max(orderedPhotos.length, 1)] ?? null;
|
||||||
|
const kb = KB_PRESETS[kbIdx];
|
||||||
|
|
||||||
|
const timeStr = timeFormat === '24h' ? format(clock, 'H:mm') : format(clock, 'h:mm');
|
||||||
|
const periodStr = timeFormat === '12h' ? format(clock, 'a') : '';
|
||||||
|
const dateStr = format(clock, 'EEEE, MMMM d');
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-[200] overflow-hidden bg-black cursor-pointer select-none"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
onClick={deactivate}
|
||||||
|
>
|
||||||
|
{/* ── Photo layer ───────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence mode="sync">
|
||||||
|
{currentPhoto ? (
|
||||||
|
<motion.div
|
||||||
|
key={`photo-${photoIdx}-${currentPhoto.rel}`}
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 1.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
{/* Ken Burns motion layer */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={kb.i}
|
||||||
|
animate={kb.a}
|
||||||
|
transition={{ duration: slideshowSpeed / 1000, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${currentPhoto.url})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
/* No photos: animated dark gradient */
|
||||||
|
<motion.div
|
||||||
|
key="no-photo"
|
||||||
|
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Preload next photo (hidden) */}
|
||||||
|
{nextPhoto && nextPhoto.rel !== currentPhoto?.rel && (
|
||||||
|
<img src={nextPhoto.url} className="hidden" alt="" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Gradient scrim (bottom-heavy for clock legibility) ─────────── */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.10) 100%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Clock ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="absolute bottom-12 left-14 pointer-events-none">
|
||||||
|
{/* Time row */}
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<span
|
||||||
|
className="text-white font-thin tabular-nums leading-none"
|
||||||
|
style={{ fontSize: 'clamp(5rem, 11vw, 10rem)' }}
|
||||||
|
>
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
{periodStr && (
|
||||||
|
<span
|
||||||
|
className="text-white/75 font-light leading-none mb-3"
|
||||||
|
style={{ fontSize: 'clamp(1.75rem, 3.5vw, 3.25rem)' }}
|
||||||
|
>
|
||||||
|
{periodStr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<p
|
||||||
|
className="text-white/65 font-light tracking-wide mt-2"
|
||||||
|
style={{ fontSize: 'clamp(1rem, 2vw, 1.75rem)' }}
|
||||||
|
>
|
||||||
|
{dateStr}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── "Tap to dismiss" hint ─────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hintVisible && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-6 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white/75 text-sm pointer-events-none whitespace-nowrap"
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
Tap anywhere to dismiss
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,474 @@
|
|||||||
export default function BoardPage() {
|
import { useState } from 'react';
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Message Board</h1><p className="text-secondary mt-2">Phase 4</p></div>;
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { MessageSquare, Pin, PinOff, Trash2, Plus } from 'lucide-react';
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
|
import { api, type Message } from '@/lib/api';
|
||||||
|
import { useMembers } from '@/hooks/useMembers';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Textarea } from '@/components/ui/Textarea';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NOTE_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fbcfe8', '#fed7aa', '#e9d5ff'];
|
||||||
|
|
||||||
|
const EXPIRY_OPTIONS = [
|
||||||
|
{ label: 'No expiry', value: '' },
|
||||||
|
{ label: '1 day', value: '1d' },
|
||||||
|
{ label: '3 days', value: '3d' },
|
||||||
|
{ label: '1 week', value: '1w' },
|
||||||
|
{ label: '2 weeks', value: '2w' },
|
||||||
|
{ label: '1 month', value: '1m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function calcExpiry(option: string): string | null {
|
||||||
|
if (!option) return null;
|
||||||
|
const d = new Date();
|
||||||
|
if (option === '1d') d.setDate(d.getDate() + 1);
|
||||||
|
else if (option === '3d') d.setDate(d.getDate() + 3);
|
||||||
|
else if (option === '1w') d.setDate(d.getDate() + 7);
|
||||||
|
else if (option === '2w') d.setDate(d.getDate() + 14);
|
||||||
|
else if (option === '1m') d.setMonth(d.getMonth() + 1);
|
||||||
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutation types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MsgCreate {
|
||||||
|
body: string;
|
||||||
|
color: string;
|
||||||
|
emoji: string | null;
|
||||||
|
member_id: number | null;
|
||||||
|
pinned: boolean;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsgPatch {
|
||||||
|
id: number;
|
||||||
|
body?: string;
|
||||||
|
color?: string;
|
||||||
|
emoji?: string | null;
|
||||||
|
member_id?: number | null;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function BoardPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [color, setColor] = useState(NOTE_COLORS[0]);
|
||||||
|
const [emoji, setEmoji] = useState('');
|
||||||
|
const [memberId, setMemberId] = useState<number | null>(null);
|
||||||
|
const [pinned, setPinned] = useState(false);
|
||||||
|
const [expiryOption, setExpiryOption] = useState('');
|
||||||
|
|
||||||
|
// ── Query ────────────────────────────────────────────────────────
|
||||||
|
const { data: messages = [], isLoading } = useQuery<Message[]>({
|
||||||
|
queryKey: ['messages'],
|
||||||
|
queryFn: () => api.get('/messages').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedMessages = messages.filter((m) => !!m.pinned);
|
||||||
|
const regularMessages = messages.filter((m) => !m.pinned);
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: MsgCreate) => api.post('/messages', data).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['messages'] });
|
||||||
|
closeCompose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: MsgPatch) =>
|
||||||
|
api.patch(`/messages/${id}`, data).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['messages'] });
|
||||||
|
closeCompose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/messages/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['messages'] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────
|
||||||
|
function openCompose() {
|
||||||
|
setEditMessage(null);
|
||||||
|
setBody('');
|
||||||
|
setColor(NOTE_COLORS[0]);
|
||||||
|
setEmoji('');
|
||||||
|
setMemberId(null);
|
||||||
|
setPinned(false);
|
||||||
|
setExpiryOption('');
|
||||||
|
setComposeOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(msg: Message) {
|
||||||
|
setEditMessage(msg);
|
||||||
|
setBody(msg.body);
|
||||||
|
setColor(msg.color);
|
||||||
|
setEmoji(msg.emoji ?? '');
|
||||||
|
setMemberId(msg.member_id);
|
||||||
|
setPinned(!!msg.pinned);
|
||||||
|
setExpiryOption('');
|
||||||
|
setComposeOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCompose() {
|
||||||
|
setComposeOpen(false);
|
||||||
|
setEditMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!body.trim()) return;
|
||||||
|
if (editMessage) {
|
||||||
|
const patch: MsgPatch = {
|
||||||
|
id: editMessage.id,
|
||||||
|
body: body.trim(),
|
||||||
|
color,
|
||||||
|
emoji: emoji.trim() || null,
|
||||||
|
member_id: memberId,
|
||||||
|
pinned,
|
||||||
|
};
|
||||||
|
patchMutation.mutate(patch);
|
||||||
|
} else {
|
||||||
|
const payload: MsgCreate = {
|
||||||
|
body: body.trim(),
|
||||||
|
color,
|
||||||
|
emoji: emoji.trim() || null,
|
||||||
|
member_id: memberId,
|
||||||
|
pinned,
|
||||||
|
expires_at: calcExpiry(expiryOption),
|
||||||
|
};
|
||||||
|
createMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || patchMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Message Board</h1>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{messages.length} note{messages.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openCompose}>
|
||||||
|
<Plus size={15} /> New Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ───────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-40 rounded-2xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-5xl mb-4">📌</div>
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">No notes yet</p>
|
||||||
|
<p className="text-secondary text-sm mb-6">
|
||||||
|
Leave a message for the family to see.
|
||||||
|
</p>
|
||||||
|
<Button onClick={openCompose}>
|
||||||
|
<Plus size={16} /> Create Note
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Pinned section */}
|
||||||
|
{pinnedMessages.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
|
<Pin size={12} className="text-muted" />
|
||||||
|
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
|
||||||
|
Pinned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{pinnedMessages.map((msg) => (
|
||||||
|
<NoteCard
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onEdit={() => openEdit(msg)}
|
||||||
|
onDelete={() => setDeleteTarget(msg)}
|
||||||
|
onTogglePin={() =>
|
||||||
|
patchMutation.mutate({ id: msg.id, pinned: !msg.pinned })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Regular section */}
|
||||||
|
{regularMessages.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{regularMessages.map((msg) => (
|
||||||
|
<NoteCard
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onEdit={() => openEdit(msg)}
|
||||||
|
onDelete={() => setDeleteTarget(msg)}
|
||||||
|
onTogglePin={() =>
|
||||||
|
patchMutation.mutate({ id: msg.id, pinned: !msg.pinned })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Compose / Edit Modal ─────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={composeOpen}
|
||||||
|
onClose={closeCompose}
|
||||||
|
title={editMessage ? 'Edit Note' : 'New Note'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Colour swatches */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary mb-2">Colour</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{NOTE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-transform ${
|
||||||
|
color === c
|
||||||
|
? 'border-accent scale-110'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select colour ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">
|
||||||
|
Emoji (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emoji}
|
||||||
|
onChange={(e) => setEmoji(e.target.value)}
|
||||||
|
placeholder="e.g. 🎉"
|
||||||
|
maxLength={4}
|
||||||
|
className="w-20 rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Textarea
|
||||||
|
label="Message"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Write your note…"
|
||||||
|
rows={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Member */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">
|
||||||
|
From (optional)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={memberId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMemberId(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">No attribution</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pin + Expiry (expiry only for new notes) */}
|
||||||
|
<div className="flex items-center gap-6 flex-wrap">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pinned}
|
||||||
|
onChange={(e) => setPinned(e.target.checked)}
|
||||||
|
className="rounded accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-secondary">Pin note</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!editMessage && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-secondary whitespace-nowrap">Expires:</label>
|
||||||
|
<select
|
||||||
|
value={expiryOption}
|
||||||
|
onChange={(e) => setExpiryOption(e.target.value)}
|
||||||
|
className="rounded-lg border border-theme bg-surface-raised px-2 py-1.5 text-sm text-primary focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
>
|
||||||
|
{EXPIRY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
|
<Button variant="secondary" onClick={closeCompose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!body.trim()} loading={isSaving}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
title="Delete Note?"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary text-sm">
|
||||||
|
This will permanently delete this note. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NoteCard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NoteCardProps {
|
||||||
|
message: Message;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteCard({ message, onEdit, onDelete, onTogglePin }: NoteCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="relative group rounded-2xl p-4 cursor-pointer shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
style={{ backgroundColor: message.color }}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
{/* Hover action buttons */}
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePin();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-black/10 hover:bg-black/20 text-gray-900 transition-colors"
|
||||||
|
aria-label={!!message.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
{!!message.pinned ? <PinOff size={13} /> : <Pin size={13} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-black/10 hover:bg-black/20 text-gray-900 transition-colors"
|
||||||
|
aria-label="Delete note"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji */}
|
||||||
|
{message.emoji && (
|
||||||
|
<div className="text-3xl mb-2 leading-none">{message.emoji}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<p className="text-sm text-gray-900 leading-relaxed line-clamp-6 whitespace-pre-wrap">
|
||||||
|
{message.body}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div className="text-xs text-gray-900/70">
|
||||||
|
{message.member_name && (
|
||||||
|
<span className="font-medium">{message.member_name} · </span>
|
||||||
|
)}
|
||||||
|
<span>{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}</span>
|
||||||
|
</div>
|
||||||
|
{message.expires_at && (
|
||||||
|
<span className="text-xs text-gray-900/60">
|
||||||
|
Expires {format(new Date(message.expires_at), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,407 @@
|
|||||||
export default function CountdownsPage() {
|
import { useState } from 'react';
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Countdowns</h1><p className="text-secondary mt-2">Phase 4</p></div>;
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Timer, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { format, differenceInCalendarDays } from 'date-fns';
|
||||||
|
import { api, type Countdown } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CD_COLORS = [
|
||||||
|
'#6366f1',
|
||||||
|
'#14b8a6',
|
||||||
|
'#f43f5e',
|
||||||
|
'#f59e0b',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#10b981',
|
||||||
|
'#3b82f6',
|
||||||
|
'#ec4899',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Mutation types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CdCreate {
|
||||||
|
title: string;
|
||||||
|
target_date: string;
|
||||||
|
emoji: string | null;
|
||||||
|
color: string;
|
||||||
|
show_on_dashboard: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CdPatch {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
target_date?: string;
|
||||||
|
emoji?: string | null;
|
||||||
|
color?: string;
|
||||||
|
show_on_dashboard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
export default function CountdownsPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editCountdown, setEditCountdown] = useState<Countdown | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Countdown | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [targetDate, setTargetDate] = useState('');
|
||||||
|
const [emoji, setEmoji] = useState('');
|
||||||
|
const [color, setColor] = useState(CD_COLORS[0]);
|
||||||
|
const [showOnDashboard, setShowOnDashboard] = useState(false);
|
||||||
|
|
||||||
|
// ── Query ────────────────────────────────────────────────────────
|
||||||
|
const { data: countdowns = [], isLoading } = useQuery<Countdown[]>({
|
||||||
|
queryKey: ['countdowns'],
|
||||||
|
queryFn: () => api.get('/countdowns').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CdCreate) => api.post('/countdowns', data).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['countdowns'] });
|
||||||
|
closeForm();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: CdPatch) =>
|
||||||
|
api.put(`/countdowns/${id}`, data).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['countdowns'] });
|
||||||
|
closeForm();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/countdowns/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['countdowns'] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────
|
||||||
|
function openCreate() {
|
||||||
|
setEditCountdown(null);
|
||||||
|
setTitle('');
|
||||||
|
setTargetDate('');
|
||||||
|
setEmoji('');
|
||||||
|
setColor(CD_COLORS[0]);
|
||||||
|
setShowOnDashboard(false);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cd: Countdown) {
|
||||||
|
setEditCountdown(cd);
|
||||||
|
setTitle(cd.title);
|
||||||
|
setTargetDate(cd.target_date);
|
||||||
|
setEmoji(cd.emoji ?? '');
|
||||||
|
setColor(cd.color);
|
||||||
|
setShowOnDashboard(!!cd.show_on_dashboard);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditCountdown(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!title.trim() || !targetDate) return;
|
||||||
|
if (editCountdown) {
|
||||||
|
const patch: CdPatch = {
|
||||||
|
id: editCountdown.id,
|
||||||
|
title: title.trim(),
|
||||||
|
target_date: targetDate,
|
||||||
|
emoji: emoji.trim() || null,
|
||||||
|
color,
|
||||||
|
show_on_dashboard: showOnDashboard,
|
||||||
|
};
|
||||||
|
updateMutation.mutate(patch);
|
||||||
|
} else {
|
||||||
|
const payload: CdCreate = {
|
||||||
|
title: title.trim(),
|
||||||
|
target_date: targetDate,
|
||||||
|
emoji: emoji.trim() || null,
|
||||||
|
color,
|
||||||
|
show_on_dashboard: showOnDashboard,
|
||||||
|
};
|
||||||
|
createMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
const canSave = title.trim().length > 0 && targetDate.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Timer size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Countdowns</h1>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{countdowns.length} event{countdowns.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus size={15} /> New Countdown
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Grid ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-44 rounded-2xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : countdowns.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-5xl mb-4">⏳</div>
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">No countdowns yet</p>
|
||||||
|
<p className="text-secondary text-sm mb-6">
|
||||||
|
Track upcoming events and milestones.
|
||||||
|
</p>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus size={16} /> Add Countdown
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
<AnimatePresence>
|
||||||
|
{countdowns.map((cd, index) => {
|
||||||
|
const daysLeft = differenceInCalendarDays(
|
||||||
|
new Date(cd.target_date + 'T00:00:00'),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CountdownCard
|
||||||
|
key={cd.id}
|
||||||
|
countdown={cd}
|
||||||
|
daysLeft={daysLeft}
|
||||||
|
index={index}
|
||||||
|
onEdit={() => openEdit(cd)}
|
||||||
|
onDelete={() => setDeleteTarget(cd)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Create / Edit Modal ──────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={formOpen}
|
||||||
|
onClose={closeForm}
|
||||||
|
title={editCountdown ? 'Edit Countdown' : 'New Countdown'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Summer Holiday"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date + Emoji in 2-col grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Target Date"
|
||||||
|
type="date"
|
||||||
|
value={targetDate}
|
||||||
|
onChange={(e) => setTargetDate(e.target.value)}
|
||||||
|
min={today}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">
|
||||||
|
Emoji (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emoji}
|
||||||
|
onChange={(e) => setEmoji(e.target.value)}
|
||||||
|
placeholder="e.g. 🎄"
|
||||||
|
maxLength={4}
|
||||||
|
className="w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colour swatches */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary mb-2">Colour</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{CD_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-transform ${
|
||||||
|
color === c
|
||||||
|
? 'border-accent scale-110'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select colour ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show on dashboard */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOnDashboard}
|
||||||
|
onChange={(e) => setShowOnDashboard(e.target.checked)}
|
||||||
|
className="rounded accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-secondary">Show on Dashboard</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
|
<Button variant="secondary" onClick={closeForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!canSave} loading={isSaving}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
title="Delete Countdown?"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary text-sm">
|
||||||
|
This will permanently delete{' '}
|
||||||
|
<strong className="text-primary">{deleteTarget?.title}</strong>. This cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CountdownCard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CountdownCardProps {
|
||||||
|
countdown: Countdown;
|
||||||
|
daysLeft: number;
|
||||||
|
index: number;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountdownCard({ countdown: cd, daysLeft, index, onEdit, onDelete }: CountdownCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.04 }}
|
||||||
|
className="group relative rounded-2xl border border-theme bg-surface overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Top colour strip */}
|
||||||
|
<div className="h-1.5 w-full" style={{ backgroundColor: cd.color }} />
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<div className="p-4 flex flex-col items-center text-center gap-1">
|
||||||
|
{cd.emoji && <div className="text-3xl leading-none mb-1">{cd.emoji}</div>}
|
||||||
|
|
||||||
|
{/* Day number */}
|
||||||
|
<span
|
||||||
|
className="text-5xl font-bold tabular-nums leading-none"
|
||||||
|
style={{ color: cd.color }}
|
||||||
|
>
|
||||||
|
{daysLeft}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<span className="text-xs text-secondary">
|
||||||
|
{daysLeft === 1 ? 'day away' : 'days away'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<p className="text-sm font-semibold text-primary mt-1 line-clamp-2">{cd.title}</p>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{format(new Date(cd.target_date + 'T00:00:00'), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dashboard badge */}
|
||||||
|
{!!cd.show_on_dashboard && (
|
||||||
|
<span className="mt-1 px-2 py-0.5 rounded-full bg-surface-raised border border-theme text-xs text-muted">
|
||||||
|
On dashboard
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover actions */}
|
||||||
|
<div className="absolute top-3 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-surface-raised border border-theme text-secondary hover:text-primary transition-colors"
|
||||||
|
aria-label="Edit countdown"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-surface-raised border border-theme text-secondary hover:text-red-500 transition-colors"
|
||||||
|
aria-label="Delete countdown"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,422 @@
|
|||||||
export default function Dashboard() {
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { format, differenceInCalendarDays, isToday, isTomorrow } from 'date-fns';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { api, type AppSettings, type CalendarEvent, type Meal, type Message, type Countdown, type Chore } from '@/lib/api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
interface EventWithMember extends CalendarEvent {
|
||||||
|
member_name: string | null;
|
||||||
|
member_color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChoreWithMember extends Chore {
|
||||||
|
member_name: string | null;
|
||||||
|
member_color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
meal_today: Meal | null;
|
||||||
|
upcoming_events: EventWithMember[];
|
||||||
|
pending_chores: ChoreWithMember[];
|
||||||
|
shopping_unchecked: number;
|
||||||
|
pinned_messages: Message[];
|
||||||
|
countdowns: Countdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeatherData {
|
||||||
|
configured: boolean;
|
||||||
|
error?: string;
|
||||||
|
city?: string;
|
||||||
|
temp?: number;
|
||||||
|
feels_like?: number;
|
||||||
|
humidity?: number;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
units?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
function getGreeting(): string {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h >= 5 && h < 12) return 'Good morning';
|
||||||
|
if (h >= 12 && h < 17) return 'Good afternoon';
|
||||||
|
if (h >= 17 && h < 21) return 'Good evening';
|
||||||
|
return 'Good night';
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDateLabel(event: CalendarEvent): string {
|
||||||
|
const start = new Date(event.start_at);
|
||||||
|
if (isToday(start)) return 'Today';
|
||||||
|
if (isTomorrow(start)) return 'Tomorrow';
|
||||||
|
return format(start, 'EEE, MMM d');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimeLabel(event: CalendarEvent): string {
|
||||||
|
if (event.all_day) return 'All day';
|
||||||
|
return format(new Date(event.start_at), 'h:mm a');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempUnit(units?: string): string {
|
||||||
|
if (units === 'metric') return '°C';
|
||||||
|
if (units === 'standard') return 'K';
|
||||||
|
return '°F';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Animation variants ─────────────────────────────────────────────────────
|
||||||
|
const fade = { hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } };
|
||||||
|
const stagger = { show: { transition: { staggerChildren: 0.07 } } };
|
||||||
|
|
||||||
|
// ── Card wrapper ───────────────────────────────────────────────────────────
|
||||||
|
const cardCls = 'bg-surface rounded-2xl shadow-sm border border-theme p-5';
|
||||||
|
|
||||||
|
// ── Widget header ──────────────────────────────────────────────────────────
|
||||||
|
function WidgetHeader({ emoji, label, to }: { emoji: string; label: string; to: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">Good morning 👋</h1>
|
<h2 className="font-semibold text-primary flex items-center gap-2">
|
||||||
<p className="text-secondary">Your family dashboard is coming together. More widgets arriving in Phase 2.</p>
|
<span>{emoji}</span> {label}
|
||||||
|
</h2>
|
||||||
|
<Link to={to} className="text-xs text-accent hover:underline">
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [clock, setClock] = useState(new Date());
|
||||||
|
|
||||||
|
// Live clock
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setClock(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: settings } = useQuery<AppSettings>({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => api.get('/settings').then((r) => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dashboard } = useQuery<DashboardData>({
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: () => api.get('/dashboard').then((r) => r.data),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: weather } = useQuery<WeatherData>({
|
||||||
|
queryKey: ['weather'],
|
||||||
|
queryFn: () => api.get('/weather').then((r) => r.data),
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Derived values ─────────────────────────────────────────────────────
|
||||||
|
const tf = settings?.time_format ?? '12h';
|
||||||
|
const timeStr = tf === '24h' ? format(clock, 'H:mm') : format(clock, 'h:mm');
|
||||||
|
const period = tf === '12h' ? format(clock, 'a') : '';
|
||||||
|
const dateStr = format(clock, 'EEEE, MMMM d, yyyy');
|
||||||
|
const greeting = getGreeting();
|
||||||
|
|
||||||
|
const countdownsToShow = (dashboard?.countdowns ?? []).filter((cd) => {
|
||||||
|
const days = differenceInCalendarDays(
|
||||||
|
new Date(cd.target_date + 'T00:00:00'),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
return days >= 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* ── Hero: greeting + clock + weather ───────────────────────────── */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4"
|
||||||
|
variants={fade} initial="hidden" animate="show"
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
{/* Greeting + clock */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold text-secondary">{greeting} 👋</p>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
|
<span className="text-6xl font-thin text-primary tabular-nums leading-none">
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
{period && (
|
||||||
|
<span className="text-2xl text-secondary font-light leading-none">{period}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary mt-2 text-sm">{dateStr}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather card (shown only when configured and no error) */}
|
||||||
|
{weather?.configured && !weather.error && weather.temp !== undefined && (
|
||||||
|
<div className={`${cardCls} flex items-center gap-4`}>
|
||||||
|
{weather.icon && (
|
||||||
|
<img
|
||||||
|
src={`https://openweathermap.org/img/wn/${weather.icon}@2x.png`}
|
||||||
|
alt={weather.description ?? 'weather'}
|
||||||
|
className="w-16 h-16 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-4xl font-light text-primary leading-none">
|
||||||
|
{weather.temp}{tempUnit(weather.units)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-secondary capitalize mt-0.5">{weather.description}</p>
|
||||||
|
<p className="text-xs text-muted mt-1">
|
||||||
|
{weather.city}
|
||||||
|
{weather.humidity !== undefined && ` · ${weather.humidity}% humidity`}
|
||||||
|
{weather.feels_like !== undefined && ` · Feels ${weather.feels_like}${tempUnit(weather.units)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weather not configured nudge */}
|
||||||
|
{weather && !weather.configured && (
|
||||||
|
<p className="text-xs text-muted self-end">
|
||||||
|
<Link to="/settings" className="hover:underline text-accent">Configure weather →</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weather configured but erroring */}
|
||||||
|
{weather?.configured && weather.error && (
|
||||||
|
<div className={`${cardCls} flex items-center gap-3 text-sm`}>
|
||||||
|
<span className="text-2xl">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-primary">Weather unavailable</p>
|
||||||
|
<p className="text-xs text-muted mt-0.5">{weather.error}</p>
|
||||||
|
<Link to="/settings" className="text-xs text-accent hover:underline">Check settings →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Main widget grid ───────────────────────────────────────────── */}
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
|
||||||
|
variants={stagger} initial="hidden" animate="show"
|
||||||
|
>
|
||||||
|
{/* ── Today's Dinner ── */}
|
||||||
|
<motion.div variants={fade} className={cardCls}>
|
||||||
|
<WidgetHeader emoji="🍽️" label="Tonight's Dinner" to="/meals" />
|
||||||
|
{dashboard?.meal_today ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-primary text-lg leading-snug">
|
||||||
|
{dashboard.meal_today.title}
|
||||||
|
</p>
|
||||||
|
{dashboard.meal_today.description && (
|
||||||
|
<p className="text-sm text-secondary">{dashboard.meal_today.description}</p>
|
||||||
|
)}
|
||||||
|
{dashboard.meal_today.recipe_url && (
|
||||||
|
<a
|
||||||
|
href={dashboard.meal_today.recipe_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-accent hover:underline block pt-1"
|
||||||
|
>
|
||||||
|
View recipe ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary text-sm">No dinner planned for today.</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Upcoming Events ── */}
|
||||||
|
<motion.div variants={fade} className={`${cardCls} md:col-span-1 xl:col-span-2`}>
|
||||||
|
<WidgetHeader emoji="📅" label="Upcoming Events" to="/calendar" />
|
||||||
|
{dashboard?.upcoming_events?.length ? (
|
||||||
|
<ul className="space-y-0">
|
||||||
|
{dashboard.upcoming_events.slice(0, 5).map((event) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-theme last:border-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: event.color ?? event.member_color ?? 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-primary truncate">{event.title}</p>
|
||||||
|
{event.member_name && (
|
||||||
|
<p className="text-xs text-secondary">{event.member_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<p className="text-xs font-medium text-secondary">{eventDateLabel(event)}</p>
|
||||||
|
<p className="text-xs text-muted">{eventTimeLabel(event)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary text-sm">No events in the next 7 days. 🎉</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Chores ── */}
|
||||||
|
<motion.div variants={fade} className={cardCls}>
|
||||||
|
<WidgetHeader emoji="✅" label="Chores" to="/chores" />
|
||||||
|
{dashboard ? (
|
||||||
|
dashboard.pending_chores.length === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-3xl mb-1">🎉</p>
|
||||||
|
<p className="text-sm text-secondary">All chores are done!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-light text-primary tabular-nums leading-none mb-3">
|
||||||
|
{dashboard.pending_chores.length}
|
||||||
|
<span className="text-sm text-secondary font-normal ml-2">pending</span>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{dashboard.pending_chores.slice(0, 4).map((chore) => (
|
||||||
|
<li key={chore.id} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: chore.member_color ?? 'var(--color-text-muted)' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-primary truncate flex-1">{chore.title}</span>
|
||||||
|
{chore.member_name && (
|
||||||
|
<span className="text-xs text-muted flex-shrink-0">{chore.member_name}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{dashboard.pending_chores.length > 4 && (
|
||||||
|
<li className="text-xs text-muted pl-4">
|
||||||
|
+{dashboard.pending_chores.length - 4} more
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary text-sm">Loading…</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Shopping ── */}
|
||||||
|
<motion.div variants={fade} className={cardCls}>
|
||||||
|
<WidgetHeader emoji="🛒" label="Shopping" to="/shopping" />
|
||||||
|
{dashboard !== undefined ? (
|
||||||
|
dashboard.shopping_unchecked === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-3xl mb-1">✨</p>
|
||||||
|
<p className="text-sm text-secondary">Shopping list is clear!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-light text-primary tabular-nums leading-none">
|
||||||
|
{dashboard.shopping_unchecked}
|
||||||
|
<span className="text-sm text-secondary font-normal ml-2">
|
||||||
|
item{dashboard.shopping_unchecked !== 1 ? 's' : ''} to buy
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted mt-3">
|
||||||
|
Tap to open your shopping list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary text-sm">Loading…</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Pinned Messages ────────────────────────────────────────────── */}
|
||||||
|
{(dashboard?.pinned_messages?.length ?? 0) > 0 && (
|
||||||
|
<motion.section
|
||||||
|
variants={fade} initial="hidden" animate="show"
|
||||||
|
transition={{ delay: 0.25, duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-primary flex items-center gap-2">
|
||||||
|
📌 Pinned Messages
|
||||||
|
</h2>
|
||||||
|
<Link to="/board" className="text-xs text-accent hover:underline">
|
||||||
|
Message board →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{dashboard!.pinned_messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="rounded-xl p-4 shadow-sm"
|
||||||
|
style={{ background: msg.color }}
|
||||||
|
>
|
||||||
|
{msg.emoji && <p className="text-2xl mb-1.5">{msg.emoji}</p>}
|
||||||
|
<p className="text-sm text-gray-900 font-medium whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{msg.body}
|
||||||
|
</p>
|
||||||
|
{msg.member_name && (
|
||||||
|
<p className="text-xs text-gray-600 mt-2">— {msg.member_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Countdowns ─────────────────────────────────────────────────── */}
|
||||||
|
{countdownsToShow.length > 0 && (
|
||||||
|
<motion.section
|
||||||
|
variants={fade} initial="hidden" animate="show"
|
||||||
|
transition={{ delay: 0.35, duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-primary flex items-center gap-2">
|
||||||
|
⏳ Countdowns
|
||||||
|
</h2>
|
||||||
|
<Link to="/countdowns" className="text-xs text-accent hover:underline">
|
||||||
|
All countdowns →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
|
{countdownsToShow.map((cd) => {
|
||||||
|
const daysLeft = differenceInCalendarDays(
|
||||||
|
new Date(cd.target_date + 'T00:00:00'),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cd.id}
|
||||||
|
className={`${cardCls} text-center relative overflow-hidden`}
|
||||||
|
>
|
||||||
|
{/* Colour strip */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-1.5"
|
||||||
|
style={{ background: cd.color }}
|
||||||
|
/>
|
||||||
|
<div className="pt-2">
|
||||||
|
{cd.emoji && (
|
||||||
|
<p className="text-2xl mb-2 leading-none">{cd.emoji}</p>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className="text-5xl font-thin tabular-nums leading-none"
|
||||||
|
style={{ color: cd.color }}
|
||||||
|
>
|
||||||
|
{daysLeft}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted mt-1">
|
||||||
|
{daysLeft === 0 ? 'Today!' : daysLeft === 1 ? 'day' : 'days'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-primary mt-2 leading-tight px-1">
|
||||||
|
{cd.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,444 @@
|
|||||||
export default function MealsPage() {
|
import { useState, useMemo } from 'react';
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Meal Planner</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
UtensilsCrossed,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfWeek,
|
||||||
|
addWeeks,
|
||||||
|
subWeeks,
|
||||||
|
addDays,
|
||||||
|
parseISO,
|
||||||
|
isToday,
|
||||||
|
isSameWeek,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { api, type Meal } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Textarea } from '@/components/ui/Textarea';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getWeekStart(date: Date): Date {
|
||||||
|
return startOfWeek(date, { weekStartsOn: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeekDays(weekStart: Date): Date[] {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MealsPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
||||||
|
getWeekStart(new Date())
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekKey = format(currentWeekStart, 'yyyy-MM-dd');
|
||||||
|
const weekEnd = addDays(currentWeekStart, 6);
|
||||||
|
|
||||||
|
const isCurrentWeek = isSameWeek(currentWeekStart, new Date(), { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
|
const [editMeal, setEditMeal] = useState<Meal | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [recipeUrl, setRecipeUrl] = useState('');
|
||||||
|
|
||||||
|
// ── Query ────────────────────────────────────────────────────────
|
||||||
|
const { data: meals = [], isLoading } = useQuery<Meal[]>({
|
||||||
|
queryKey: ['meals', weekKey],
|
||||||
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get('/meals', {
|
||||||
|
params: {
|
||||||
|
start: weekKey,
|
||||||
|
end: format(weekEnd, 'yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mealMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Meal>();
|
||||||
|
for (const m of meals) {
|
||||||
|
map.set(m.date, m);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [meals]);
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
const upsertMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
date,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
date: string;
|
||||||
|
body: { title: string; description?: string; recipe_url?: string };
|
||||||
|
}) => api.put(`/meals/${date}`, body).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['meals', weekKey] });
|
||||||
|
closeForm();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (date: string) => api.delete(`/meals/${date}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['meals', weekKey] });
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setSelectedDate('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────
|
||||||
|
function openAdd(date: string) {
|
||||||
|
setEditMeal(null);
|
||||||
|
setSelectedDate(date);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setRecipeUrl('');
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(meal: Meal) {
|
||||||
|
setEditMeal(meal);
|
||||||
|
setSelectedDate(meal.date);
|
||||||
|
setTitle(meal.title);
|
||||||
|
setDescription(meal.description ?? '');
|
||||||
|
setRecipeUrl(meal.recipe_url ?? '');
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditMeal(null);
|
||||||
|
setSelectedDate('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(date: string) {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!title.trim() || !selectedDate) return;
|
||||||
|
upsertMutation.mutate({
|
||||||
|
date: selectedDate,
|
||||||
|
body: {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
recipe_url: recipeUrl.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDays = buildWeekDays(currentWeekStart);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0 flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UtensilsCrossed size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Meal Planner</h1>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{format(currentWeekStart, 'MMM d')} – {format(weekEnd, 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isCurrentWeek && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentWeekStart(getWeekStart(new Date()))}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentWeekStart((w) => getWeekStart(subWeeks(w, 1)))}
|
||||||
|
className="p-2 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
aria-label="Previous week"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentWeekStart((w) => getWeekStart(addWeeks(w, 1)))}
|
||||||
|
className="p-2 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
aria-label="Next week"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Week Grid ─────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-auto p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-7 gap-3 min-w-[700px]">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-48 rounded-2xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-7 gap-3 min-w-[700px]">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
|
const meal = mealMap.get(dateStr);
|
||||||
|
const today = isToday(day);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dateStr}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col rounded-2xl border transition-colors min-h-[180px]',
|
||||||
|
today
|
||||||
|
? 'border-accent bg-accent/5'
|
||||||
|
: 'border-theme bg-surface'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Day header */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'px-3 pt-3 pb-2 border-b',
|
||||||
|
today ? 'border-accent/20' : 'border-theme'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-xs font-semibold uppercase tracking-wide',
|
||||||
|
today ? 'text-accent' : 'text-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, 'EEE')}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-lg font-bold leading-tight',
|
||||||
|
today ? 'text-accent' : 'text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cell content */}
|
||||||
|
<div className="flex-1 p-3">
|
||||||
|
{meal ? (
|
||||||
|
<MealCell
|
||||||
|
meal={meal}
|
||||||
|
onEdit={() => openEdit(meal)}
|
||||||
|
onDelete={() => openDelete(dateStr)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => openAdd(dateStr)}
|
||||||
|
className="w-full h-full min-h-[80px] flex flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-theme text-muted hover:border-accent hover:text-accent transition-colors group"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="opacity-60 group-hover:opacity-100" />
|
||||||
|
<span className="text-xs">Add dinner</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Add / Edit Modal ──────────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={formOpen}
|
||||||
|
onClose={closeForm}
|
||||||
|
title={editMeal ? 'Edit Meal' : 'Add Dinner'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedDate && (
|
||||||
|
<p className="text-xs text-muted -mt-2">
|
||||||
|
{format(parseISO(selectedDate), 'EEEE, MMMM d')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
|
placeholder="e.g. Spaghetti Bolognese"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Notes, ingredients, serving ideas…"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Recipe URL (optional)"
|
||||||
|
value={recipeUrl}
|
||||||
|
onChange={(e) => setRecipeUrl(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
{editMeal && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:text-red-600 mr-auto"
|
||||||
|
onClick={() => {
|
||||||
|
closeForm();
|
||||||
|
openDelete(editMeal.date);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button variant="secondary" onClick={closeForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
loading={upsertMutation.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setSelectedDate('');
|
||||||
|
}}
|
||||||
|
title="Remove Meal?"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary text-sm">
|
||||||
|
This will remove the meal for{' '}
|
||||||
|
<strong className="text-primary">
|
||||||
|
{selectedDate ? format(parseISO(selectedDate), 'EEEE, MMMM d') : ''}
|
||||||
|
</strong>
|
||||||
|
. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setSelectedDate('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => selectedDate && deleteMutation.mutate(selectedDate)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MealCell ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MealCellProps {
|
||||||
|
meal: Meal;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MealCell({ meal, onEdit, onDelete }: MealCellProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={meal.id}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="group relative h-full flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-primary leading-tight line-clamp-2 pr-14">
|
||||||
|
{meal.title}
|
||||||
|
</p>
|
||||||
|
{meal.description && (
|
||||||
|
<p className="text-xs text-secondary line-clamp-3 leading-relaxed">
|
||||||
|
{meal.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover actions */}
|
||||||
|
<div className="absolute top-0 right-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{meal.recipe_url && (
|
||||||
|
<a
|
||||||
|
href={meal.recipe_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="p-1 rounded text-muted hover:text-accent transition-colors"
|
||||||
|
aria-label="Open recipe"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded text-muted hover:text-primary transition-colors"
|
||||||
|
aria-label="Edit meal"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded text-muted hover:text-red-500 transition-colors"
|
||||||
|
aria-label="Delete meal"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,471 @@
|
|||||||
export default function PhotosPage() {
|
import { useState, useRef, useCallback } from 'react';
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Photo Slideshow</h1><p className="text-secondary mt-2">Phase 3</p></div>;
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Image, Upload, Trash2, X, ChevronLeft, ChevronRight,
|
||||||
|
Settings, CloudUpload, CheckCircle2, AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
interface Photo {
|
||||||
|
name: string;
|
||||||
|
rel: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotosResponse {
|
||||||
|
configured: boolean;
|
||||||
|
count: number;
|
||||||
|
photos: Photo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadStatus {
|
||||||
|
file: File;
|
||||||
|
state: 'pending' | 'done' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
export default function PhotosPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [uploadQueue, setUploadQueue] = useState<UploadStatus[]>([]);
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Photo | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Data ────────────────────────────────────────────────────────────────
|
||||||
|
const { data, isLoading } = useQuery<PhotosResponse>({
|
||||||
|
queryKey: ['photos'],
|
||||||
|
queryFn: () => api.get('/photos').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const photos = data?.photos ?? [];
|
||||||
|
const configured = data?.configured ?? true; // optimistic until loaded
|
||||||
|
|
||||||
|
// ── Upload ──────────────────────────────────────────────────────────────
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
const statuses: UploadStatus[] = files.map((f) => ({ file: f, state: 'pending' }));
|
||||||
|
setUploadQueue(statuses);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
files.forEach((f) => form.append('photos', f));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/photos/upload', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
setUploadQueue(statuses.map((s) => ({ ...s, state: 'done' })));
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.error ?? 'Upload failed';
|
||||||
|
setUploadQueue(statuses.map((s) => ({ ...s, state: 'error', message: msg })));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (photo: Photo) =>
|
||||||
|
api.delete(`/photos/file/${encodeURIComponent(photo.rel)}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
if (lightboxIndex !== null && lightboxIndex >= photos.length - 1) {
|
||||||
|
setLightboxIndex(null);
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drag & drop ─────────────────────────────────────────────────────────
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (files.length) triggerUpload(files);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (files.length) triggerUpload(files);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerUpload = (files: File[]) => {
|
||||||
|
setUploadOpen(true);
|
||||||
|
uploadMutation.mutate(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Lightbox ─────────────────────────────────────────────────────────────
|
||||||
|
const prevPhoto = () =>
|
||||||
|
setLightboxIndex((i) => (i === null ? null : (i - 1 + photos.length) % photos.length));
|
||||||
|
const nextPhoto = () =>
|
||||||
|
setLightboxIndex((i) => (i === null ? null : (i + 1) % photos.length));
|
||||||
|
|
||||||
|
const handleLightboxKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowLeft') prevPhoto();
|
||||||
|
if (e.key === 'ArrowRight') nextPhoto();
|
||||||
|
if (e.key === 'Escape') setLightboxIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload summary counts ─────────────────────────────────────────────────
|
||||||
|
const doneCount = uploadQueue.filter((s) => s.state === 'done').length;
|
||||||
|
const errorCount = uploadQueue.filter((s) => s.state === 'error').length;
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col h-full overflow-hidden relative"
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
{/* Drag overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isDragging && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 z-40 flex items-center justify-center bg-accent/20 border-4 border-dashed border-accent backdrop-blur-sm pointer-events-none"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<CloudUpload size={48} className="mx-auto mb-3 text-accent" />
|
||||||
|
<p className="text-xl font-bold text-accent">Drop photos to upload</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Header ───────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Photos</h1>
|
||||||
|
{data?.configured && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{data.count} photo{data.count !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{configured && (
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()} size="sm">
|
||||||
|
<Upload size={15} /> Upload Photos
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Upload progress banner ────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{uploadOpen && uploadQueue.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden border-b border-theme bg-surface-raised shrink-0"
|
||||||
|
>
|
||||||
|
<div className="px-6 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{uploadMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-primary">
|
||||||
|
Uploading {uploadQueue.length} photo{uploadQueue.length !== 1 ? 's' : ''}…
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : errorCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle size={16} className="text-red-500 shrink-0" />
|
||||||
|
<span className="text-sm text-red-500">
|
||||||
|
{errorCount} upload{errorCount !== 1 ? 's' : ''} failed
|
||||||
|
{doneCount > 0 && ` · ${doneCount} succeeded`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={16} className="text-green-500 shrink-0" />
|
||||||
|
<span className="text-sm text-primary">
|
||||||
|
{doneCount} photo{doneCount !== 1 ? 's' : ''} uploaded successfully
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setUploadOpen(false); setUploadQueue([]); }}
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-primary hover:bg-surface-raised shrink-0"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-file list — shown while pending or on error */}
|
||||||
|
{(uploadMutation.isPending || errorCount > 0) && (
|
||||||
|
<ul className="px-6 pb-3 space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{uploadQueue.map((s, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-xs text-secondary">
|
||||||
|
{s.state === 'pending' && (
|
||||||
|
<svg className="animate-spin h-3 w-3 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{s.state === 'done' && <CheckCircle2 size={12} className="text-green-500 shrink-0" />}
|
||||||
|
{s.state === 'error' && <AlertCircle size={12} className="text-red-500 shrink-0" />}
|
||||||
|
<span className="truncate">{s.file.name}</span>
|
||||||
|
<span className="text-muted shrink-0">({formatBytes(s.file.size)})</span>
|
||||||
|
{s.message && <span className="text-red-500 truncate">{s.message}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Content ───────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 p-6">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="aspect-square rounded-xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !data?.configured ? (
|
||||||
|
/* ── Not configured ─────────────────────────────────────────── */
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||||
|
<div className="text-5xl mb-4">📂</div>
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">Photo folder not configured</p>
|
||||||
|
<p className="text-secondary text-sm mb-6 max-w-sm">
|
||||||
|
Set a photo folder path in Settings, then come back to upload and manage your photos.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-raised border border-theme text-sm font-medium text-primary hover:bg-accent-light hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={15} /> Go to Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : photos.length === 0 ? (
|
||||||
|
/* ── Empty — drop zone ──────────────────────────────────────── */
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex flex-col items-center gap-4 p-10 rounded-2xl border-2 border-dashed border-theme hover:border-accent hover:bg-accent/5 transition-all cursor-pointer group"
|
||||||
|
>
|
||||||
|
<CloudUpload size={48} className="text-muted group-hover:text-accent transition-colors" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">Upload your first photos</p>
|
||||||
|
<p className="text-secondary text-sm">
|
||||||
|
Click to browse, or drag & drop images anywhere on this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
Supports JPG, PNG, WEBP, GIF, AVIF · Up to 50 MB per file
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Photo grid ─────────────────────────────────────────────── */
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-xs text-muted mb-4">
|
||||||
|
Drag & drop images anywhere on this page, or use the Upload button to add more photos.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={photo.rel}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="group relative aspect-square rounded-xl overflow-hidden bg-surface-raised cursor-pointer shadow-sm"
|
||||||
|
onClick={() => setLightboxIndex(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-200" />
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(photo); }}
|
||||||
|
className={clsx(
|
||||||
|
'absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-white',
|
||||||
|
'opacity-0 group-hover:opacity-100 transition-opacity duration-150',
|
||||||
|
'hover:bg-red-500'
|
||||||
|
)}
|
||||||
|
aria-label="Delete photo"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
{/* Filename on hover */}
|
||||||
|
<div className={clsx(
|
||||||
|
'absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black/60 backdrop-blur-sm',
|
||||||
|
'opacity-0 group-hover:opacity-100 transition-opacity duration-150'
|
||||||
|
)}>
|
||||||
|
<p className="text-white text-xs truncate">{photo.name}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lightbox ──────────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{lightboxIndex !== null && photos[lightboxIndex] && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setLightboxIndex(null)}
|
||||||
|
onKeyDown={handleLightboxKey}
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={(el) => el?.focus()}
|
||||||
|
>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
onClick={() => setLightboxIndex(null)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
|
||||||
|
{lightboxIndex + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); prevPhoto(); }}
|
||||||
|
className="absolute left-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<motion.img
|
||||||
|
key={photos[lightboxIndex].rel}
|
||||||
|
src={photos[lightboxIndex].url}
|
||||||
|
alt={photos[lightboxIndex].name}
|
||||||
|
className="max-h-[90vh] max-w-[90vw] object-contain rounded-xl shadow-2xl"
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); nextPhoto(); }}
|
||||||
|
className="absolute right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
|
||||||
|
{photos[lightboxIndex].name}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Delete confirmation ────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteTarget && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-surface rounded-2xl border border-theme shadow-xl p-6 max-w-sm w-full"
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-primary mb-1">Delete photo?</h2>
|
||||||
|
<p className="text-sm text-secondary mb-4">
|
||||||
|
<span className="font-medium text-primary">{deleteTarget.name}</span> will be permanently
|
||||||
|
deleted from your photo folder. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteMutation.mutate(deleteTarget)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,12 +184,14 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => set('weather_api_key', e.target.value)}
|
onChange={(e) => set('weather_api_key', e.target.value)}
|
||||||
placeholder="Your free API key from openweathermap.org"
|
placeholder="Your free API key from openweathermap.org"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Location (city name or zip)"
|
label="Location (city name or zip)"
|
||||||
value={form.weather_location ?? ''}
|
value={form.weather_location ?? ''}
|
||||||
onChange={(e) => set('weather_location', e.target.value)}
|
onChange={(e) => set('weather_location', e.target.value)}
|
||||||
placeholder="New York, US"
|
placeholder="New York, US"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-secondary block mb-1.5">Units</label>
|
<label className="text-sm font-medium text-secondary block mb-1.5">Units</label>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import mealsRouter from './routes/meals';
|
|||||||
import messagesRouter from './routes/messages';
|
import messagesRouter from './routes/messages';
|
||||||
import countdownsRouter from './routes/countdowns';
|
import countdownsRouter from './routes/countdowns';
|
||||||
import photosRouter from './routes/photos';
|
import photosRouter from './routes/photos';
|
||||||
|
import dashboardRouter from './routes/dashboard';
|
||||||
|
import weatherRouter from './routes/weather';
|
||||||
|
|
||||||
// Run DB migrations on startup — aborts if any migration fails
|
// Run DB migrations on startup — aborts if any migration fails
|
||||||
runMigrations();
|
runMigrations();
|
||||||
@@ -32,6 +34,8 @@ app.use('/api/meals', mealsRouter);
|
|||||||
app.use('/api/messages', messagesRouter);
|
app.use('/api/messages', messagesRouter);
|
||||||
app.use('/api/countdowns', countdownsRouter);
|
app.use('/api/countdowns', countdownsRouter);
|
||||||
app.use('/api/photos', photosRouter);
|
app.use('/api/photos', photosRouter);
|
||||||
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
app.use('/api/weather', weatherRouter);
|
||||||
|
|
||||||
// Serve built client — in Docker the client dist is copied here at build time
|
// Serve built client — in Docker the client dist is copied here at build time
|
||||||
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
|
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import db from '../db/db';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
const nowIso = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const in7days = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
// Today's meal
|
||||||
|
const meal_today = db.prepare('SELECT * FROM meals WHERE date = ?').get(today) ?? null;
|
||||||
|
|
||||||
|
// Upcoming events in the next 7 days
|
||||||
|
const upcoming_events = db.prepare(`
|
||||||
|
SELECT e.*, m.name AS member_name, m.color AS member_color
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN members m ON e.member_id = m.id
|
||||||
|
WHERE e.start_at >= ? AND e.start_at <= ?
|
||||||
|
ORDER BY e.start_at ASC
|
||||||
|
LIMIT 10
|
||||||
|
`).all(nowIso, in7days);
|
||||||
|
|
||||||
|
// Pending chores (up to 10 for preview)
|
||||||
|
const pending_chores = db.prepare(`
|
||||||
|
SELECT c.*, m.name AS member_name, m.color AS member_color,
|
||||||
|
(SELECT COUNT(*) FROM chore_completions cc WHERE cc.chore_id = c.id) AS completion_count
|
||||||
|
FROM chores c
|
||||||
|
LEFT JOIN members m ON c.member_id = m.id
|
||||||
|
WHERE c.status = 'pending'
|
||||||
|
ORDER BY c.due_date ASC NULLS LAST
|
||||||
|
LIMIT 10
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
// Unchecked shopping items count
|
||||||
|
const shoppingRow = db.prepare(
|
||||||
|
'SELECT COUNT(*) AS count FROM shopping_items WHERE checked = 0'
|
||||||
|
).get() as { count: number };
|
||||||
|
|
||||||
|
// Pinned messages (not expired)
|
||||||
|
const pinned_messages = db.prepare(`
|
||||||
|
SELECT msg.*, m.name AS member_name, m.color AS member_color
|
||||||
|
FROM messages msg
|
||||||
|
LEFT JOIN members m ON msg.member_id = m.id
|
||||||
|
WHERE msg.pinned = 1
|
||||||
|
AND (msg.expires_at IS NULL OR msg.expires_at > ?)
|
||||||
|
ORDER BY msg.created_at DESC
|
||||||
|
LIMIT 6
|
||||||
|
`).all(nowIso);
|
||||||
|
|
||||||
|
// Countdowns marked for dashboard
|
||||||
|
const countdowns = db.prepare(`
|
||||||
|
SELECT * FROM countdowns
|
||||||
|
WHERE show_on_dashboard = 1
|
||||||
|
ORDER BY target_date ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
meal_today,
|
||||||
|
upcoming_events,
|
||||||
|
pending_chores,
|
||||||
|
shopping_unchecked: (shoppingRow as any).count as number,
|
||||||
|
pinned_messages,
|
||||||
|
countdowns,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import db from '../db/db';
|
import db from '../db/db';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -24,38 +25,133 @@ function scanDir(dir: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
|
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
|
||||||
// This lets Unraid users bind-mount their photo library to /photos
|
|
||||||
// without having to change the settings in the UI.
|
|
||||||
function resolvePhotoFolder(): string {
|
function resolvePhotoFolder(): string {
|
||||||
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
|
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
|
||||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
|
||||||
return row?.value ?? '';
|
return row?.value ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', (_req, res) => {
|
// Resolve and validate a relative path inside the photo folder.
|
||||||
|
// rawRel may be percent-encoded (e.g. %2F for subdirectory slashes).
|
||||||
|
// Returns the absolute filepath or null if it would escape the folder.
|
||||||
|
function resolveFilePath(folder: string, rawRel: string): string | null {
|
||||||
|
let rel: string;
|
||||||
|
try { rel = decodeURIComponent(rawRel); } catch { rel = rawRel; }
|
||||||
|
rel = rel.replace(/\\/g, '/');
|
||||||
|
// Reject traversal attempts
|
||||||
|
if (rel.split('/').some((p) => p === '..')) return null;
|
||||||
|
const filepath = path.normalize(path.join(folder, rel));
|
||||||
|
const base = path.normalize(folder);
|
||||||
|
if (!filepath.startsWith(base + path.sep) && filepath !== base) return null;
|
||||||
|
return filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer storage — saves directly into the photo folder root
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
const folder = resolvePhotoFolder();
|
const folder = resolvePhotoFolder();
|
||||||
const files = scanDir(folder);
|
if (!folder) return cb(new Error('Photo folder not configured'), '');
|
||||||
res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
|
try { fs.mkdirSync(folder, { recursive: true }); } catch { /* already exists */ }
|
||||||
|
cb(null, folder);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
cb(null, `${base}_${Date.now()}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB per file
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (IMAGE_EXTS.has(path.extname(file.originalname).toLowerCase())) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Unsupported file type: ${path.extname(file.originalname) || file.originalname}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/file/:filename', (req, res) => {
|
// ── List all photos (recursive) ───────────────────────────────────────────
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const folder = resolvePhotoFolder();
|
||||||
|
const configured = !!folder;
|
||||||
|
if (!configured) return res.json({ configured: false, count: 0, photos: [] });
|
||||||
|
|
||||||
|
const norm = path.normalize(folder);
|
||||||
|
const files = scanDir(folder);
|
||||||
|
const photos = files.map((f) => {
|
||||||
|
const rel = path.relative(norm, f).replace(/\\/g, '/');
|
||||||
|
return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||||
|
});
|
||||||
|
res.json({ configured: true, folder, count: photos.length, photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Serve a photo by encoded relative path ────────────────────────────────
|
||||||
|
// Express 4 wildcard: req.params[0] captures everything after /file/
|
||||||
|
router.get('/file/*', (req, res) => {
|
||||||
const folder = resolvePhotoFolder();
|
const folder = resolvePhotoFolder();
|
||||||
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
||||||
|
|
||||||
// Security: prevent path traversal
|
const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
|
||||||
const filename = path.basename(req.params.filename);
|
if (!filepath) return res.status(403).json({ error: 'Forbidden' });
|
||||||
const filepath = path.join(folder, filename);
|
|
||||||
if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
|
|
||||||
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
||||||
res.sendFile(filepath);
|
res.sendFile(filepath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return all photos as a flat list with their relative paths for the slideshow
|
// ── Slideshow list ────────────────────────────────────────────────────────
|
||||||
router.get('/slideshow', (_req, res) => {
|
router.get('/slideshow', (_req, res) => {
|
||||||
const folder = resolvePhotoFolder();
|
const folder = resolvePhotoFolder();
|
||||||
|
if (!folder) return res.json({ count: 0, photos: [] });
|
||||||
|
|
||||||
|
const norm = path.normalize(folder);
|
||||||
const files = scanDir(folder);
|
const files = scanDir(folder);
|
||||||
const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
|
const photos = files.map((f) => {
|
||||||
res.json({ count: urls.length, urls });
|
const rel = path.relative(norm, f).replace(/\\/g, '/');
|
||||||
|
return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||||
|
});
|
||||||
|
res.json({ count: photos.length, photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Batch upload ──────────────────────────────────────────────────────────
|
||||||
|
router.post('/upload', (req, res) => {
|
||||||
|
const folder = resolvePhotoFolder();
|
||||||
|
if (!folder) {
|
||||||
|
return res.status(400).json({ error: 'Photo folder not configured. Set it in Settings first.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.array('photos', 200)(req, res, (err) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = path.normalize(folder);
|
||||||
|
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||||
|
const uploaded = files.map((f) => {
|
||||||
|
const rel = path.relative(norm, f.path).replace(/\\/g, '/');
|
||||||
|
return { name: f.filename, rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||||
|
});
|
||||||
|
res.status(201).json({ count: uploaded.length, uploaded });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete a photo ────────────────────────────────────────────────────────
|
||||||
|
router.delete('/file/*', (req, res) => {
|
||||||
|
const folder = resolvePhotoFolder();
|
||||||
|
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
||||||
|
|
||||||
|
const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
|
||||||
|
if (!filepath) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filepath);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to delete file' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import https from 'https';
|
||||||
|
import db from '../db/db';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function getSetting(key: string): string {
|
||||||
|
return (db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as any)?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const apiKey = getSetting('weather_api_key').trim();
|
||||||
|
const location = getSetting('weather_location').trim();
|
||||||
|
const units = getSetting('weather_units').trim() || 'imperial';
|
||||||
|
|
||||||
|
if (!apiKey || !location) {
|
||||||
|
return res.json({ configured: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://api.openweathermap.org/data/2.5/weather` +
|
||||||
|
`?q=${encodeURIComponent(location)}` +
|
||||||
|
`&appid=${encodeURIComponent(apiKey)}` +
|
||||||
|
`&units=${encodeURIComponent(units)}`;
|
||||||
|
|
||||||
|
https.get(url, (apiRes) => {
|
||||||
|
let raw = '';
|
||||||
|
apiRes.on('data', (chunk: Buffer) => { raw += chunk.toString(); });
|
||||||
|
apiRes.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (apiRes.statusCode !== 200) {
|
||||||
|
return res.json({ configured: true, error: data.message ?? 'Weather API error' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
configured: true,
|
||||||
|
city: data.name as string,
|
||||||
|
temp: Math.round(data.main.temp as number),
|
||||||
|
feels_like: Math.round(data.main.feels_like as number),
|
||||||
|
humidity: data.main.humidity as number,
|
||||||
|
description: (data.weather?.[0]?.description ?? '') as string,
|
||||||
|
icon: (data.weather?.[0]?.icon ?? '') as string,
|
||||||
|
units,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.json({ configured: true, error: 'Failed to parse weather response' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', (err: Error) => {
|
||||||
|
res.json({ configured: true, error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+17
-5
@@ -7,12 +7,24 @@ PGID=${PGID:-100}
|
|||||||
|
|
||||||
echo "[entrypoint] Starting Family Planner (PUID=${PUID}, PGID=${PGID})"
|
echo "[entrypoint] Starting Family Planner (PUID=${PUID}, PGID=${PGID})"
|
||||||
|
|
||||||
# Create the app user/group if they don't already exist at the requested IDs
|
# Resolve group: reuse existing group at PGID, or create a new one
|
||||||
if ! getent group appgroup > /dev/null 2>&1; then
|
if getent group "${PGID}" > /dev/null 2>&1; then
|
||||||
addgroup -g "${PGID}" appgroup
|
APP_GROUP=$(getent group "${PGID}" | cut -d: -f1)
|
||||||
|
echo "[entrypoint] Reusing existing group '${APP_GROUP}' (GID=${PGID})"
|
||||||
|
else
|
||||||
|
APP_GROUP=appgroup
|
||||||
|
addgroup -g "${PGID}" "${APP_GROUP}"
|
||||||
|
echo "[entrypoint] Created group '${APP_GROUP}' (GID=${PGID})"
|
||||||
fi
|
fi
|
||||||
if ! getent passwd appuser > /dev/null 2>&1; then
|
|
||||||
adduser -D -u "${PUID}" -G appgroup appuser
|
# Resolve user: reuse existing user at PUID, or create a new one
|
||||||
|
if getent passwd "${PUID}" > /dev/null 2>&1; then
|
||||||
|
APP_USER=$(getent passwd "${PUID}" | cut -d: -f1)
|
||||||
|
echo "[entrypoint] Reusing existing user '${APP_USER}' (UID=${PUID})"
|
||||||
|
else
|
||||||
|
APP_USER=appuser
|
||||||
|
adduser -D -u "${PUID}" -G "${APP_GROUP}" "${APP_USER}"
|
||||||
|
echo "[entrypoint] Created user '${APP_USER}' (UID=${PUID})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure /data is owned by the app user so SQLite can write
|
# Ensure /data is owned by the app user so SQLite can write
|
||||||
|
|||||||
Generated
+372
@@ -0,0 +1,372 @@
|
|||||||
|
{
|
||||||
|
"name": "family-planner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "family-planner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "8.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||||
|
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
|
"spawn-command": "0.0.2",
|
||||||
|
"supports-color": "^8.1.1",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.13.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "2.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||||
|
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.11"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/date-fns"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/spawn-command": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user