Compare commits

...

10 Commits

Author SHA1 Message Date
jason 0dbf6ea335 weather error indicator
Build and Push Docker Image / build (push) Successful in 15s
2026-03-30 16:44:13 -05:00
jason 4dcdaa474a weather error indicator
Build and Push Docker Image / build (push) Successful in 17s
2026-03-30 14:44:08 -05:00
jason a0c1ae9703 more roadmap features
Build and Push Docker Image / build (push) Successful in 16s
2026-03-30 14:34:10 -05:00
jason ba2a76f7dd ROJECT.md
Build and Push Docker Image / build (push) Successful in 7s
2026-03-30 13:55:58 -05:00
jason ed0a3f9389 photos phase 3
Build and Push Docker Image / build (push) Successful in 17s
2026-03-30 11:03:36 -05:00
jason 4672f70a60 photos phase 3
Build and Push Docker Image / build (push) Failing after 10s
2026-03-30 10:45:16 -05:00
jason 5431177b7a photos phase 3
Build and Push Docker Image / build (push) Successful in 35s
2026-03-30 10:27:50 -05:00
jason 155e7849e1 Fix runtime stage: install prod deps instead of copying node_modules
pnpm uses a symlink-based virtual store (.pnpm/) that breaks when
copied between Docker stages — Node can't resolve modules from the
copied tree, causing 'Cannot find module express' at startup.

Replace the broken COPY with a proper pnpm install --prod in the
runtime stage. Layer caching still applies: manifests + lockfile
are copied before source so the install layer is only rebuilt when
deps change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:12:38 -05:00
jason 84c9e5304e Fix entrypoint: reuse existing GID/UID instead of blindly creating
Alpine's built-in 'users' group owns GID 100 and 'nobody' owns UID 99.
The old check tested by name (appgroup/appuser) which always passed,
then hit 'addgroup: gid 100 in use' on creation.

Now checks by GID/UID via getent — reuses the existing group/user if
the ID is already taken, only creates new ones when the ID is free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:10:07 -05:00
jason 897b1c9bef Fix Dockerfile COPY instruction with invalid shell syntax
Removed the conditional COPY line that used 2>/dev/null || true —
shell operators are not valid in Dockerfile COPY instructions and
were being interpreted as literal paths. pnpm hoists all deps to
root node_modules via shamefully-hoist so the line was unnecessary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:00:15 -05:00
18 changed files with 6666 additions and 35 deletions
+25
View File
@@ -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
View File
@@ -51,12 +51,18 @@ FROM node:22-alpine AS runtime
# Install tini for proper PID 1 signal handling
RUN apk add --no-cache tini su-exec
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# No native deps — runtime only needs node_modules for pure-JS packages (express, cors, etc.)
# Copy them directly from the builder stage instead of re-installing
COPY --from=server-builder /build/node_modules ./node_modules
COPY --from=server-builder /build/apps/server/node_modules ./apps/server/node_modules 2>/dev/null || true
# Copy workspace manifests and lockfile — pnpm needs these to install correctly
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* .npmrc ./
COPY apps/server/package.json ./apps/server/
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 --from=server-builder /build/apps/server/dist ./apps/server/dist
+390
View File
@@ -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 35)
- 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`.
+3113
View File
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ import {
Image, Menu, X, ChevronRight,
} from 'lucide-react';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { Screensaver } from '@/components/screensaver/Screensaver';
interface NavItem {
to: string;
@@ -192,6 +193,9 @@ export function AppShell({ children }: AppShellProps) {
</AnimatePresence>
</main>
</div>
{/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */}
<Screensaver />
</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>
);
}
+473 -2
View File
@@ -1,3 +1,474 @@
export default function BoardPage() {
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 { useState } from 'react';
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>
);
}
+406 -2
View File
@@ -1,3 +1,407 @@
export default function CountdownsPage() {
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 { useState } from 'react';
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>
);
}
+418 -4
View File
@@ -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 (
<div className="p-6">
<h1 className="text-2xl font-bold text-primary mb-2">Good morning 👋</h1>
<p className="text-secondary">Your family dashboard is coming together. More widgets arriving in Phase 2.</p>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-primary flex items-center gap-2">
<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>
);
}
+443 -2
View File
@@ -1,3 +1,444 @@
export default function MealsPage() {
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 { useState, useMemo } from 'react';
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>
);
}
+470 -2
View File
@@ -1,3 +1,471 @@
export default function PhotosPage() {
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 { useState, useRef, useCallback } from 'react';
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 &amp; 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 &amp; 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>
);
}
+2
View File
@@ -184,12 +184,14 @@ export default function SettingsPage() {
onChange={(e) => set('weather_api_key', e.target.value)}
placeholder="Your free API key from openweathermap.org"
type="password"
autoComplete="new-password"
/>
<Input
label="Location (city name or zip)"
value={form.weather_location ?? ''}
onChange={(e) => set('weather_location', e.target.value)}
placeholder="New York, US"
autoComplete="off"
/>
<div>
<label className="text-sm font-medium text-secondary block mb-1.5">Units</label>
+4
View File
@@ -12,6 +12,8 @@ import mealsRouter from './routes/meals';
import messagesRouter from './routes/messages';
import countdownsRouter from './routes/countdowns';
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
runMigrations();
@@ -32,6 +34,8 @@ app.use('/api/meals', mealsRouter);
app.use('/api/messages', messagesRouter);
app.use('/api/countdowns', countdownsRouter);
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
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
+69
View File
@@ -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;
+109 -13
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import db from '../db/db';
import fs from 'fs';
import path from 'path';
import multer from 'multer';
const router = Router();
@@ -24,38 +25,133 @@ function scanDir(dir: string): string[] {
}
// 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 {
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;
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 files = scanDir(folder);
res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
if (!folder) return cb(new Error('Photo folder not configured'), '');
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();
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
// Security: prevent path traversal
const filename = path.basename(req.params.filename);
const filepath = path.join(folder, filename);
if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
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' });
res.sendFile(filepath);
});
// Return all photos as a flat list with their relative paths for the slideshow
// ── Slideshow list ────────────────────────────────────────────────────────
router.get('/slideshow', (_req, res) => {
const folder = resolvePhotoFolder();
if (!folder) return res.json({ count: 0, photos: [] });
const norm = path.normalize(folder);
const files = scanDir(folder);
const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
res.json({ count: urls.length, urls });
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({ 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;
+54
View File
@@ -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
View File
@@ -7,12 +7,24 @@ PGID=${PGID:-100}
echo "[entrypoint] Starting Family Planner (PUID=${PUID}, PGID=${PGID})"
# Create the app user/group if they don't already exist at the requested IDs
if ! getent group appgroup > /dev/null 2>&1; then
addgroup -g "${PGID}" appgroup
# Resolve group: reuse existing group at PGID, or create a new one
if getent group "${PGID}" > /dev/null 2>&1; then
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
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
# Ensure /data is owned by the app user so SQLite can write
+372
View File
@@ -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"
}
}
}
}