diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..967eaab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Git +.git +.gitignore + +# Node modules (installed fresh in Docker) +node_modules +apps/*/node_modules + +# Dev/test artifacts +apps/client/dist +apps/server/dist +apps/server/data + +# Env files (never bake secrets into the image) +.env +.env.* + +# OS / editor noise +.DS_Store +Thumbs.db +*.log + +# Agent / docs (not needed at runtime) +AGENTS.md +DEPLOYMENT-PROFILE.md +INSTALL.md +ROUTING-EXAMPLES.md +SKILLS.md +hubs/ +skills/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..901b931 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy this file to .env and fill in your values. +# docker-compose.yml reads these automatically. + +# Host port the app will be accessible on +PORT=3001 + +# Your timezone (used for correct date/time display) +TZ=America/New_York + +# Host path where the SQLite database is stored +DATA_PATH=/mnt/user/appdata/family-planner + +# Host path to your photo library (mounted read-only into the container) +PHOTOS_PATH=/mnt/user/Photos + +# File ownership inside the container +# Unraid default: nobody (99) + users (100) +PUID=99 +PGID=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7550611 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ── Dependencies ────────────────────────────────────────────────────────────── +node_modules/ +.pnpm-store/ + +# ── Build output ────────────────────────────────────────────────────────────── +apps/client/dist/ +apps/server/dist/ +*.tsbuildinfo + +# ── Database & runtime data ─────────────────────────────────────────────────── +apps/server/data/ +*.db +*.db-shm +*.db-wal + +# ── Environment files ───────────────────────────────────────────────────────── +.env +.env.local +.env.*.local + +# ── Claude Code local settings ──────────────────────────────────────────────── +.claude/settings.local.json + +# ── Editor & OS noise ───────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +Desktop.ini +*.swp +*.swo +.vscode/ +.idea/ + +# ── Logs ────────────────────────────────────────────────────────────────────── +*.log +npm-debug.log* +pnpm-debug.log* + +# ── Misc pnpm artifacts ─────────────────────────────────────────────────────── +.pnpm-approve-builds.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..bf2e764 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +shamefully-hoist=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fcb990b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,88 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1 — Build client +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS client-builder + +WORKDIR /build + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy workspace manifests first for better layer caching +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./ +COPY apps/client/package.json ./apps/client/ +COPY apps/server/package.json ./apps/server/ +COPY tsconfig.base.json ./ + +# Install all workspace deps +RUN pnpm install --frozen-lockfile + +# Copy client source and build +COPY apps/client ./apps/client/ +RUN pnpm --filter client build + + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2 — Build server +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS server-builder + +WORKDIR /build + +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./ +COPY apps/server/package.json ./apps/server/ +COPY apps/client/package.json ./apps/client/ +COPY tsconfig.base.json ./ + +# Install only server production deps + rebuild native modules for target arch +RUN pnpm install --frozen-lockfile + +COPY apps/server ./apps/server/ +RUN pnpm --filter server build + + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 3 — Production runtime +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +# Install tini for proper PID 1 signal handling +RUN apk add --no-cache tini su-exec + +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 compiled server output (includes dist/db/migrations/*.js compiled by tsc) +COPY --from=server-builder /build/apps/server/dist ./apps/server/dist + +# Copy built client into the path the server expects +COPY --from=client-builder /build/apps/client/dist ./apps/client/dist + +# ── Runtime configuration ───────────────────────────────────────────────── +ENV NODE_ENV=production \ + PORT=3001 \ + DATA_DIR=/data \ + PHOTOS_DIR=/photos \ + PUID=99 \ + PGID=100 \ + TZ=UTC \ + NODE_NO_WARNINGS=1 + +# /data — persistent: SQLite database +# /photos — bind-mount: read-only user photo library +VOLUME ["/data"] + +EXPOSE 3001 + +# Entrypoint: fix ownership then drop to PUID/PGID +COPY docker-entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"] +CMD ["node", "apps/server/dist/index.js"] diff --git a/UNRAID.md b/UNRAID.md new file mode 100644 index 0000000..3030aea --- /dev/null +++ b/UNRAID.md @@ -0,0 +1,325 @@ +# Unraid Install Guide — Family Planner + +Two installation methods are available: the **GUI** method using the Community Applications template, and the **CLI** method using the Unraid terminal. Both produce an identical container. Choose whichever you prefer. + +--- + +## Prerequisites + +- Unraid 6.10 or later +- Docker service enabled (Unraid Settings → Docker → Enable Docker: Yes) +- The Family Planner image published to a container registry (e.g. `ghcr.io/your-username/family-planner:latest`) +- At least one share for app data (e.g. `appdata`) — created automatically by Unraid if it does not exist + +--- + +## Paths and Variables Reference + +Understand these before installing. Both methods use the same values. + +### Volume Mounts + +| Container path | Host path (default) | Access | Required | Purpose | +|---|---|---|---|---| +| `/data` | `/mnt/user/appdata/family-planner` | read/write | **Yes** | SQLite database, migrations state | +| `/photos` | `/mnt/user/Photos` | read-only | No | Photo library for the slideshow screensaver | + +**`/data` must be writable.** This is where the database file (`family.db`) lives. If this volume is lost, all data is lost — back it up like any other appdata folder. + +**`/photos` is read-only.** The app scans this folder recursively for images (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`, `.bmp`). Point it at any existing share or subfolder on your array. You can also leave it unmapped and configure the path later inside the app under Settings → Photo Slideshow. + +### Port Mapping + +| Host port | Container port | Protocol | Purpose | +|---|---|---|---| +| `3001` (configurable) | `3001` | TCP | Web UI | + +Change the host port if `3001` is already in use on your server. The container port stays `3001`. + +### Environment Variables + +| Variable | Default | Required | Description | +|---|---|---|---| +| `TZ` | `America/New_York` | Recommended | Timezone for date/time display. Full list: [tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | +| `PUID` | `99` | No | UID the process runs as inside the container. Unraid's `nobody` = `99`. Run `id ` in the terminal to find yours. | +| `PGID` | `100` | No | GID the process runs as. Unraid's `users` group = `100`. | +| `PORT` | `3001` | No | Internal app port. Do not change unless you have a specific reason. | +| `DATA_DIR` | `/data` | No | Internal path to the data directory. Do not change. | +| `PHOTOS_DIR` | `/photos` | No | Internal path to the photos directory. Do not change. | +| `NODE_NO_WARNINGS` | `1` | No | Suppresses the Node.js experimental SQLite warning. Do not change. | + +**Finding your PUID/PGID** — open the Unraid terminal and run: +```bash +id nobody +# uid=99(nobody) gid=100(users) +``` +If you want files written with your personal user's ownership instead: +```bash +id your-username +# uid=1000(your-username) gid=100(users) +``` + +--- + +## Method 1 — GUI (Community Applications Template) + +### Step 1 — Copy the template file + +Open the Unraid terminal (Tools → Terminal) and run: + +```bash +cp /path/to/family-planner/unraid/family-planner.xml \ + /boot/config/plugins/dockerMan/templates-user/family-planner.xml +``` + +If you cloned the repo directly onto your server, the path will be wherever you placed it. Alternatively, paste the XML file content manually into a new file at that location. + +### Step 2 — Open Docker and add the container + +1. In the Unraid web UI, go to the **Docker** tab +2. Click **Add Container** +3. At the top, click the **Template** dropdown and select **Family Planner** from the user templates section +4. The form will pre-fill with all default values + +### Step 3 — Review and adjust each field + +Work through the form top to bottom: + +**Repository** +``` +ghcr.io/your-username/family-planner:latest +``` +Replace `your-username` with the actual GitHub username or registry path once the image is published. + +**Network Type** +``` +Bridge +``` +Leave as-is unless you have a specific reason to use host or a custom network. + +**Port Mappings** + +| Name | Container port | Host port | +|---|---|---| +| Web UI Port | `3001` | `3001` (change if needed) | + +**Path Mappings** + +| Name | Container path | Host path | Access | +|---|---|---|---| +| App Data | `/data` | `/mnt/user/appdata/family-planner` | Read/Write | +| Photos Path | `/photos` | `/mnt/user/Photos` | Read Only | + +To change the photos path: click the field and type the full path to your photos share. Common examples: +- `/mnt/user/Photos` +- `/mnt/user/Media/Family Photos` +- `/mnt/disk1/photos` (specific disk, bypasses cache) + +Leave the Photos Path blank or point it at an empty folder if you do not want the slideshow feature yet — you can configure it later in the app. + +**Variables** + +| Name | Value | +|---|---| +| TZ | Your timezone, e.g. `America/Chicago` | +| PUID | `99` (or your personal UID) | +| PGID | `100` | +| PORT | `3001` (leave as-is) | + +### Step 4 — Apply + +Click **Apply** at the bottom of the form. Unraid will: +1. Pull the image +2. Create the container +3. Start it automatically + +### Step 5 — Verify + +Click the container row to expand it, then click **WebUI** (or navigate to `http://YOUR-SERVER-IP:3001` in your browser). The Family Planner dashboard should load. + +To check container logs: +1. Click the container icon (the colored square to the left of the container name) +2. Select **Logs** +3. You should see: + ``` + [db] Running 1 pending migration(s)... + [db] ✓ Applied: 001_initial + [db] Migrations complete. + Family Planner running on http://0.0.0.0:3001 + ``` + +--- + +## Method 2 — CLI (Terminal Script) + +This method runs a shell script from the Unraid terminal. It pulls the image, creates the container, and verifies it started correctly — all in one step. + +### Step 1 — Open the Unraid terminal + +Tools → Terminal (or SSH into your server). + +### Step 2 — Run the installer + +**Option A — If you have the repo on the server:** +```bash +bash /path/to/family-planner/unraid/install.sh +``` + +**Option B — Override any defaults inline before running:** +```bash +HOST_PORT=3001 \ +DATA_PATH=/mnt/user/appdata/family-planner \ +PHOTOS_PATH=/mnt/user/Photos \ +PUID=99 \ +PGID=100 \ +TZ=America/New_York \ +bash /path/to/family-planner/unraid/install.sh +``` + +**Option C — Once the image is published, pull and run in one step:** +```bash +curl -fsSL https://raw.githubusercontent.com/your-username/family-planner/main/unraid/install.sh | bash +``` +Or with custom values: +```bash +HOST_PORT=8080 PHOTOS_PATH=/mnt/user/Media/Photos \ + curl -fsSL https://raw.githubusercontent.com/your-username/family-planner/main/unraid/install.sh | bash +``` + +### Step 3 — Confirm the settings prompt + +The script will display a summary of the values it will use and ask for confirmation: +``` +[INFO] Family Planner — Unraid Installer + + Container : family-planner + Image : ghcr.io/your-username/family-planner:latest + Port : 3001 → 3001 + Data path : /mnt/user/appdata/family-planner + Photos : /mnt/user/Photos (read-only) + PUID/PGID : 99/100 + TZ : America/New_York + +Proceed with these settings? [Y/n] +``` +Press **Enter** (or type `Y`) to proceed. + +### Step 4 — Wait for completion + +The script will: +1. Stop and remove any existing container named `family-planner` +2. Create the data directory if it does not exist +3. Pull the latest image +4. Start the container +5. Wait up to 30 seconds for a successful health check + +On success you will see: +``` +[OK] Family Planner is up! +[OK] Installation complete. + + Open in browser : http://192.168.1.X:3001 + View logs : docker logs -f family-planner + Stop : docker stop family-planner +``` + +### Step 5 — Verify manually (optional) + +```bash +# Check the container is running +docker ps | grep family-planner + +# Tail live logs +docker logs -f family-planner + +# Hit the API to confirm the app is responding +curl -s http://localhost:3001/api/settings | python3 -m json.tool +``` + +--- + +## Updating + +### GUI + +1. In the Docker tab, click the container icon and select **Check for Updates** +2. If an update is available, click **Update** — Unraid will pull the new image and recreate the container preserving your volume mappings + +### CLI + +```bash +# Pull the new image +docker pull ghcr.io/your-username/family-planner:latest + +# Stop and remove the old container (data is safe — it lives in /data volume) +docker stop family-planner +docker rm family-planner + +# Re-run the install script with the same settings +bash /path/to/family-planner/unraid/install.sh +``` + +Or as a one-liner: +```bash +docker pull ghcr.io/your-username/family-planner:latest \ + && docker stop family-planner \ + && docker rm family-planner \ + && bash /path/to/family-planner/unraid/install.sh +``` + +> **Database migrations run automatically on startup.** When a new version adds schema changes, the migration runner applies them the first time the updated container starts. Your existing data is preserved. + +--- + +## Backup + +The entire application state lives in one file: + +``` +/mnt/user/appdata/family-planner/family.db +``` + +Back this up with Unraid's built-in **Appdata Backup** plugin, or manually: + +```bash +# One-time backup +cp /mnt/user/appdata/family-planner/family.db \ + /mnt/user/Backups/family-planner-$(date +%Y%m%d).db + +# Restore (stop the container first) +docker stop family-planner +cp /mnt/user/Backups/family-planner-20260101.db \ + /mnt/user/appdata/family-planner/family.db +docker start family-planner +``` + +--- + +## Troubleshooting + +**Container exits immediately** +```bash +docker logs family-planner +``` +Look for `[db] ✗ Failed:` — a migration failure aborts startup. This usually means the `/data` volume is not writable or the database file is corrupted. + +**Cannot access the web UI** +- Confirm the container is running: `docker ps | grep family-planner` +- Check the host port is not blocked by Unraid's firewall or already in use: `ss -tlnp | grep 3001` +- Try accessing via the server's LAN IP directly: `http://192.168.1.X:3001` + +**Photos not showing in slideshow** +- Verify the `/photos` volume is mapped and the path exists: `docker exec family-planner ls /photos` +- Check the path in the app: Settings → Photo Slideshow → Photo Folder Path +- Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`, `.bmp` + +**Wrong timezone / dates** +- Set `TZ` to a valid tz database name, e.g. `America/Los_Angeles`, `Europe/London`, `Asia/Tokyo` +- Full list: `docker exec family-planner cat /usr/share/zoneinfo/tzdata.zi | head -20` or see [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) +- Recreate the container after changing `TZ` — it is read at startup + +**File permission errors in logs** +- The entrypoint script sets ownership of `/data` to `PUID:PGID` on every start +- If you see permission errors, check that `PUID`/`PGID` match the owner of your appdata share +- Run `ls -la /mnt/user/appdata/family-planner` to see current ownership +- Run `id nobody` and `id your-username` to compare UIDs diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 0000000..5ece71a --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,20 @@ + + + + + + + Family Planner + + + +
+ + + diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 0000000..fd3c9b1 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,33 @@ +{ + "name": "client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.28.4", + "axios": "^1.6.7", + "clsx": "^2.1.0", + "date-fns": "^3.3.1", + "framer-motion": "^11.0.14", + "lucide-react": "^0.359.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.37", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.2.0" + } +} diff --git a/apps/client/postcss.config.js b/apps/client/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx new file mode 100644 index 0000000..39d7ee0 --- /dev/null +++ b/apps/client/src/App.tsx @@ -0,0 +1,33 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AppShell } from '@/components/layout/AppShell'; +import Dashboard from '@/pages/Dashboard'; +import CalendarPage from '@/pages/Calendar'; +import ShoppingPage from '@/pages/Shopping'; +import ChoresPage from '@/pages/Chores'; +import MealsPage from '@/pages/Meals'; +import BoardPage from '@/pages/Board'; +import CountdownsPage from '@/pages/Countdowns'; +import PhotosPage from '@/pages/Photos'; +import SettingsPage from '@/pages/Settings'; +import MembersPage from '@/pages/Members'; + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/client/src/components/layout/AppShell.tsx b/apps/client/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..c5548d2 --- /dev/null +++ b/apps/client/src/components/layout/AppShell.tsx @@ -0,0 +1,197 @@ +import { ReactNode, useState } from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + LayoutDashboard, Calendar, ShoppingCart, CheckSquare, + UtensilsCrossed, MessageSquare, Timer, Settings, + Image, Menu, X, ChevronRight, +} from 'lucide-react'; +import { ThemeToggle } from '@/components/ui/ThemeToggle'; + +interface NavItem { + to: string; + icon: ReactNode; + label: string; + end?: boolean; +} + +const NAV_ITEMS: NavItem[] = [ + { to: '/', icon: , label: 'Dashboard', end: true }, + { to: '/calendar', icon: , label: 'Calendar' }, + { to: '/shopping', icon: , label: 'Shopping' }, + { to: '/chores', icon: , label: 'Chores' }, + { to: '/meals', icon: , label: 'Meals' }, + { to: '/board', icon: , label: 'Board' }, + { to: '/countdowns',icon: , label: 'Countdowns'}, + { to: '/photos', icon: , label: 'Photos' }, + { to: '/settings', icon: , label: 'Settings' }, +]; + +function SidebarLink({ item, collapsed }: { item: NavItem; collapsed: boolean }) { + return ( + + clsx( + 'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium', + 'transition-all duration-150', + isActive + ? 'bg-accent text-white shadow-sm' + : 'text-secondary hover:bg-surface-raised hover:text-primary' + ) + } + > + {item.icon} + + {!collapsed && ( + + {item.label} + + )} + + + ); +} + +interface AppShellProps { + children: ReactNode; +} + +export function AppShell({ children }: AppShellProps) { + const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const location = useLocation(); + + // Close mobile menu on nav + // (handled via useEffect would add complexity; NavLink click is enough) + + return ( +
+ {/* ── Desktop Sidebar ──────────────────────────────────────────── */} + + {/* Logo / Header */} +
+ + FP + + + {!collapsed && ( + + Family Planner + + )} + +
+ + {/* Nav */} + + + {/* Bottom: theme + collapse */} +
+ {!collapsed && } + +
+
+ + {/* ── Mobile Overlay Drawer ────────────────────────────────────── */} + + {mobileOpen && ( + <> + setMobileOpen(false)} + /> + +
+ Family Planner + +
+ +
+ +
+
+ + )} +
+ + {/* ── Main Content ─────────────────────────────────────────────── */} +
+ {/* Top bar (mobile only) */} +
+ + Family Planner + +
+ + {/* Page content */} +
+ + + {children} + + +
+
+
+ ); +} diff --git a/apps/client/src/components/ui/Avatar.tsx b/apps/client/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..0d508a8 --- /dev/null +++ b/apps/client/src/components/ui/Avatar.tsx @@ -0,0 +1,37 @@ +import { clsx } from 'clsx'; + +interface AvatarProps { + name: string; + color: string; + size?: 'xs' | 'sm' | 'md' | 'lg'; + className?: string; +} + +const sizeMap = { + xs: 'h-6 w-6 text-xs', + sm: 'h-8 w-8 text-sm', + md: 'h-10 w-10 text-base', + lg: 'h-12 w-12 text-lg', +}; + +export function Avatar({ name, color, size = 'md', className }: AvatarProps) { + const initials = name + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + + return ( + + {initials} + + ); +} diff --git a/apps/client/src/components/ui/Badge.tsx b/apps/client/src/components/ui/Badge.tsx new file mode 100644 index 0000000..7201560 --- /dev/null +++ b/apps/client/src/components/ui/Badge.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; +import { clsx } from 'clsx'; + +interface BadgeProps { + children: ReactNode; + color?: string; + className?: string; +} + +export function Badge({ children, color, className }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/apps/client/src/components/ui/Button.tsx b/apps/client/src/components/ui/Button.tsx new file mode 100644 index 0000000..54cc677 --- /dev/null +++ b/apps/client/src/components/ui/Button.tsx @@ -0,0 +1,51 @@ +import { forwardRef, ButtonHTMLAttributes } from 'react'; +import { clsx } from 'clsx'; + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type Size = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + loading?: boolean; +} + +const variantClasses: Record = { + primary: 'bg-accent text-white hover:opacity-90 active:opacity-80 shadow-sm', + secondary: 'bg-surface-raised border border-theme text-primary hover:bg-accent-light hover:text-accent', + ghost: 'text-secondary hover:bg-surface-raised hover:text-primary', + danger: 'bg-red-500 text-white hover:bg-red-600 active:bg-red-700 shadow-sm', +}; + +const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-sm gap-1.5', + md: 'px-4 py-2 text-sm gap-2', + lg: 'px-5 py-2.5 text-base gap-2', +}; + +export const Button = forwardRef( + ({ variant = 'primary', size = 'md', loading, className, children, disabled, ...props }, ref) => ( + + ) +); +Button.displayName = 'Button'; diff --git a/apps/client/src/components/ui/Input.tsx b/apps/client/src/components/ui/Input.tsx new file mode 100644 index 0000000..dfd17f3 --- /dev/null +++ b/apps/client/src/components/ui/Input.tsx @@ -0,0 +1,38 @@ +import { forwardRef, InputHTMLAttributes } from 'react'; +import { clsx } from 'clsx'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export const Input = forwardRef( + ({ label, error, hint, className, id, ...props }, ref) => { + const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-'); + return ( +
+ {label && ( + + )} + + {hint && !error &&

{hint}

} + {error &&

{error}

} +
+ ); + } +); +Input.displayName = 'Input'; diff --git a/apps/client/src/components/ui/Modal.tsx b/apps/client/src/components/ui/Modal.tsx new file mode 100644 index 0000000..19f3232 --- /dev/null +++ b/apps/client/src/components/ui/Modal.tsx @@ -0,0 +1,83 @@ +import { ReactNode, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X } from 'lucide-react'; +import { clsx } from 'clsx'; +import { useThemeStore } from '@/store/themeStore'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +} + +const sizeClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-2xl', +}; + +export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) { + // Ensure dark class is on root so modal portal inherits theme + const mode = useThemeStore((s) => s.mode); + + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [open, onClose]); + + useEffect(() => { + document.body.style.overflow = open ? 'hidden' : ''; + return () => { document.body.style.overflow = ''; }; + }, [open]); + + return createPortal( + + {open && ( +
+ {/* Backdrop */} + + {/* Panel */} + + {title && ( +
+

{title}

+ +
+ )} +
{children}
+
+
+ )} +
, + document.body + ); +} diff --git a/apps/client/src/components/ui/ThemeToggle.tsx b/apps/client/src/components/ui/ThemeToggle.tsx new file mode 100644 index 0000000..3bbef4d --- /dev/null +++ b/apps/client/src/components/ui/ThemeToggle.tsx @@ -0,0 +1,56 @@ +import { Sun, Moon } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { useThemeStore } from '@/store/themeStore'; +import { clsx } from 'clsx'; + +interface ThemeToggleProps { + className?: string; + showLabel?: boolean; +} + +export function ThemeToggle({ className, showLabel }: ThemeToggleProps) { + const { mode, toggleMode } = useThemeStore(); + const isDark = mode === 'dark'; + + return ( + + ); +} diff --git a/apps/client/src/index.css b/apps/client/src/index.css new file mode 100644 index 0000000..556b200 --- /dev/null +++ b/apps/client/src/index.css @@ -0,0 +1,65 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ─── Base token defaults (light) — overridden by JS applyTheme() ──── */ +:root { + --color-bg: #f8fafc; + --color-surface: #ffffff; + --color-surface-raised: #f1f5f9; + --color-border: #e2e8f0; + --color-text-primary: #0f172a; + --color-text-secondary: #475569; + --color-text-muted: #94a3b8; + --color-accent: #6366f1; + --color-accent-light: #e0e7ff; +} + +/* ─── Smooth theme transitions on every surface ───────────────────── */ +*, *::before, *::after { + transition-property: background-color, color, border-color; + transition-duration: 200ms; + transition-timing-function: ease; +} + +/* But NOT on animations / transforms */ +[data-no-transition], [data-no-transition] * { + transition: none !important; +} + +/* ─── Base body ───────────────────────────────────────────────────── */ +body { + background-color: var(--color-bg); + color: var(--color-text-primary); + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ─── Scrollbar styling ────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); } + +/* ─── Focus ring ───────────────────────────────────────────────────── */ +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* ─── Utility layer ────────────────────────────────────────────────── */ +@layer utilities { + .bg-surface { background-color: var(--color-surface); } + .bg-surface-raised { background-color: var(--color-surface-raised); } + .bg-app { background-color: var(--color-bg); } + .border-theme { border-color: var(--color-border); } + .text-primary { color: var(--color-text-primary); } + .text-secondary { color: var(--color-text-secondary); } + .text-muted { color: var(--color-text-muted); } + .text-accent { color: var(--color-accent); } + .bg-accent { background-color: var(--color-accent); } + .bg-accent-light { background-color: var(--color-accent-light); } + .ring-accent { --tw-ring-color: var(--color-accent); } +} diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts new file mode 100644 index 0000000..291d793 --- /dev/null +++ b/apps/client/src/lib/api.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; + +export const api = axios.create({ baseURL: '/api' }); + +// ── Types ───────────────────────────────────────────────────────────────── +export interface Member { + id: number; + name: string; + color: string; + avatar: string | null; + created_at: string; +} + +export interface CalendarEvent { + id: number; + title: string; + description: string | null; + start_at: string; + end_at: string; + all_day: number; + recurrence: string | null; + member_id: number | null; + color: string | null; + created_at: string; +} + +export interface ShoppingList { + id: number; + name: string; + created_at: string; +} + +export interface ShoppingItem { + id: number; + list_id: number; + name: string; + quantity: string | null; + checked: number; + member_id: number | null; + sort_order: number; + created_at: string; +} + +export interface Chore { + id: number; + title: string; + description: string | null; + member_id: number | null; + member_name: string | null; + member_color: string | null; + recurrence: string; + status: string; + due_date: string | null; + completion_count: number; + created_at: string; +} + +export interface Meal { + id: number; + date: string; + title: string; + description: string | null; + recipe_url: string | null; + created_at: string; +} + +export interface Message { + id: number; + member_id: number | null; + member_name: string | null; + member_color: string | null; + body: string; + color: string; + emoji: string | null; + pinned: number; + expires_at: string | null; + created_at: string; +} + +export interface Countdown { + id: number; + title: string; + target_date: string; + emoji: string | null; + color: string; + show_on_dashboard: number; + event_id: number | null; + created_at: string; +} + +export interface AppSettings { + theme: string; + accent: string; + photo_folder: string; + slideshow_speed: string; + slideshow_order: string; + idle_timeout: string; + time_format: string; + date_format: string; + weather_api_key: string; + weather_location: string; + weather_units: string; +} diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx new file mode 100644 index 0000000..2efc88c --- /dev/null +++ b/apps/client/src/main.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; +import { initTheme } from './store/themeStore'; + +// Apply persisted theme tokens before first render +initTheme(); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/apps/client/src/pages/Board.tsx b/apps/client/src/pages/Board.tsx new file mode 100644 index 0000000..ce0c8e3 --- /dev/null +++ b/apps/client/src/pages/Board.tsx @@ -0,0 +1,3 @@ +export default function BoardPage() { + return

Message Board

Phase 4

; +} diff --git a/apps/client/src/pages/Calendar.tsx b/apps/client/src/pages/Calendar.tsx new file mode 100644 index 0000000..542bbfb --- /dev/null +++ b/apps/client/src/pages/Calendar.tsx @@ -0,0 +1,3 @@ +export default function CalendarPage() { + return

Calendar

Phase 2

; +} diff --git a/apps/client/src/pages/Chores.tsx b/apps/client/src/pages/Chores.tsx new file mode 100644 index 0000000..68bd759 --- /dev/null +++ b/apps/client/src/pages/Chores.tsx @@ -0,0 +1,3 @@ +export default function ChoresPage() { + return

Chores

Phase 2

; +} diff --git a/apps/client/src/pages/Countdowns.tsx b/apps/client/src/pages/Countdowns.tsx new file mode 100644 index 0000000..9258b2b --- /dev/null +++ b/apps/client/src/pages/Countdowns.tsx @@ -0,0 +1,3 @@ +export default function CountdownsPage() { + return

Countdowns

Phase 4

; +} diff --git a/apps/client/src/pages/Dashboard.tsx b/apps/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9c5d562 --- /dev/null +++ b/apps/client/src/pages/Dashboard.tsx @@ -0,0 +1,8 @@ +export default function Dashboard() { + return ( +
+

Good morning 👋

+

Your family dashboard is coming together. More widgets arriving in Phase 2.

+
+ ); +} diff --git a/apps/client/src/pages/Meals.tsx b/apps/client/src/pages/Meals.tsx new file mode 100644 index 0000000..d23877e --- /dev/null +++ b/apps/client/src/pages/Meals.tsx @@ -0,0 +1,3 @@ +export default function MealsPage() { + return

Meal Planner

Phase 2

; +} diff --git a/apps/client/src/pages/Members.tsx b/apps/client/src/pages/Members.tsx new file mode 100644 index 0000000..21b5100 --- /dev/null +++ b/apps/client/src/pages/Members.tsx @@ -0,0 +1,234 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Plus, Pencil, Trash2, ArrowLeft } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { api, type Member } from '@/lib/api'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { Avatar } from '@/components/ui/Avatar'; + +const PRESET_COLORS = [ + '#6366f1', '#14b8a6', '#f43f5e', '#f59e0b', '#64748b', + '#8b5cf6', '#ec4899', '#10b981', '#f97316', '#06b6d4', +]; + +interface MemberFormData { + name: string; + color: string; +} + +function MemberForm({ + initial, + onSubmit, + onCancel, + loading, +}: { + initial?: MemberFormData; + onSubmit: (data: MemberFormData) => void; + onCancel: () => void; + loading: boolean; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [color, setColor] = useState(initial?.color ?? PRESET_COLORS[0]); + + return ( +
+ setName(e.target.value)} + placeholder="Family member's name" + autoFocus + /> +
+

Color

+
+ {PRESET_COLORS.map((c) => ( +
+
+ setColor(e.target.value)} + className="h-8 w-12 cursor-pointer rounded border border-theme bg-transparent" + /> + {color} +
+
+ + {/* Preview */} + {name && ( +
+ + {name} +
+ )} + +
+ + +
+
+ ); +} + +export default function MembersPage() { + const qc = useQueryClient(); + const [addOpen, setAddOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: members = [], isLoading } = useQuery({ + queryKey: ['members'], + queryFn: () => api.get('/members').then((r) => r.data), + }); + + const createMutation = useMutation({ + mutationFn: (data: MemberFormData) => api.post('/members', data).then((r) => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['members'] }); setAddOpen(false); }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: MemberFormData }) => + api.put(`/members/${id}`, data).then((r) => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['members'] }); setEditTarget(null); }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => api.delete(`/members/${id}`), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['members'] }); setDeleteTarget(null); }, + }); + + return ( +
+ {/* Header */} +
+ + + +
+

Family Members

+

{members.length} member{members.length !== 1 ? 's' : ''}

+
+ +
+ + {/* Member list */} + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : members.length === 0 ? ( +
+
👨‍👩‍👧‍👦
+

No family members yet

+

Add your family members to assign chores, events, and more.

+ +
+ ) : ( +
+ + {members.map((member) => ( + + +
+

{member.name}

+

{member.color}

+
+
+ + +
+
+ ))} +
+
+ )} + + {/* Add modal */} + setAddOpen(false)} title="Add Family Member"> + createMutation.mutate(data)} + onCancel={() => setAddOpen(false)} + loading={createMutation.isPending} + /> + + + {/* Edit modal */} + setEditTarget(null)} title="Edit Family Member"> + {editTarget && ( + updateMutation.mutate({ id: editTarget.id, data })} + onCancel={() => setEditTarget(null)} + loading={updateMutation.isPending} + /> + )} + + + {/* Delete confirm modal */} + setDeleteTarget(null)} title="Remove Member" size="sm"> + {deleteTarget && ( +
+
+ + {deleteTarget.name} +
+

+ Removing this member won't delete their assigned chores or events — those will simply become unassigned. +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/client/src/pages/Photos.tsx b/apps/client/src/pages/Photos.tsx new file mode 100644 index 0000000..30e144d --- /dev/null +++ b/apps/client/src/pages/Photos.tsx @@ -0,0 +1,3 @@ +export default function PhotosPage() { + return

Photo Slideshow

Phase 3

; +} diff --git a/apps/client/src/pages/Settings.tsx b/apps/client/src/pages/Settings.tsx new file mode 100644 index 0000000..d8c1bc7 --- /dev/null +++ b/apps/client/src/pages/Settings.tsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Save, Folder, Clock, Image, Cloud, Users } from 'lucide-react'; +import { api, type AppSettings } from '@/lib/api'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { ThemeToggle } from '@/components/ui/ThemeToggle'; +import { useThemeStore, ACCENT_TOKENS, type AccentColor } from '@/store/themeStore'; +import { clsx } from 'clsx'; +import { Link } from 'react-router-dom'; + +function Section({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+
+ {icon} +

{title}

+
+
{children}
+
+ ); +} + +export default function SettingsPage() { + const qc = useQueryClient(); + const { accent, setAccent } = useThemeStore(); + + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: () => api.get('/settings').then((r) => r.data), + }); + + const [form, setForm] = useState>({}); + useEffect(() => { if (settings) setForm(settings); }, [settings]); + + const mutation = useMutation({ + mutationFn: (patch: Partial) => api.patch('/settings', patch).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), + }); + + const set = (key: keyof AppSettings, value: string) => setForm((f) => ({ ...f, [key]: value })); + const save = () => mutation.mutate(form as AppSettings); + + return ( +
+
+
+

Settings

+

Configure your family dashboard

+
+ +
+ + {/* ── Appearance ─────────────────────────────────────────────── */} +
}> +
+

Theme Mode

+ +
+
+

Accent Color

+
+ {(Object.keys(ACCENT_TOKENS) as AccentColor[]).map((key) => { + const { base, label } = ACCENT_TOKENS[key]; + return ( + + ); + })} +
+
+
+ + {/* ── Family Members ─────────────────────────────────────────── */} +
}> +

+ Add, edit, or remove family members. Members are used throughout the app to assign chores, events, and shopping items. +

+ + + +
+ + {/* ── Photo Slideshow ────────────────────────────────────────── */} +
}> + set('photo_folder', e.target.value)} + placeholder="C:\Users\YourName\Pictures\Family" + hint="Absolute path to the folder containing your photos. Subfolders are included." + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {/* ── Date & Time ────────────────────────────────────────────── */} +
}> +
+
+ + +
+
+ + +
+
+
+ + {/* ── Weather ────────────────────────────────────────────────── */} +
}> + set('weather_api_key', e.target.value)} + placeholder="Your free API key from openweathermap.org" + type="password" + /> + set('weather_location', e.target.value)} + placeholder="New York, US" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/client/src/pages/Shopping.tsx b/apps/client/src/pages/Shopping.tsx new file mode 100644 index 0000000..237759f --- /dev/null +++ b/apps/client/src/pages/Shopping.tsx @@ -0,0 +1,3 @@ +export default function ShoppingPage() { + return

Shopping

Phase 2

; +} diff --git a/apps/client/src/store/settingsStore.ts b/apps/client/src/store/settingsStore.ts new file mode 100644 index 0000000..84efa73 --- /dev/null +++ b/apps/client/src/store/settingsStore.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import axios from 'axios'; + +export interface AppSettings { + theme: string; + accent: string; + photo_folder: string; + slideshow_speed: string; + slideshow_order: string; + idle_timeout: string; + time_format: string; + date_format: string; + weather_api_key: string; + weather_location: string; + weather_units: string; +} + +interface SettingsState { + settings: AppSettings | null; + loading: boolean; + fetch: () => Promise; + update: (patch: Partial) => Promise; +} + +export const useSettingsStore = create((set, get) => ({ + settings: null, + loading: false, + fetch: async () => { + set({ loading: true }); + const { data } = await axios.get('/api/settings'); + set({ settings: data, loading: false }); + }, + update: async (patch) => { + const { data } = await axios.patch('/api/settings', patch); + set({ settings: data }); + // Sync theme/accent to theme store + if (patch.theme || patch.accent) { + const { useThemeStore } = await import('./themeStore'); + if (patch.theme) useThemeStore.getState().setMode(patch.theme as any); + if (patch.accent) useThemeStore.getState().setAccent(patch.accent as any); + } + }, +})); diff --git a/apps/client/src/store/themeStore.ts b/apps/client/src/store/themeStore.ts new file mode 100644 index 0000000..4ec29a1 --- /dev/null +++ b/apps/client/src/store/themeStore.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type ThemeMode = 'light' | 'dark'; +export type AccentColor = 'indigo' | 'teal' | 'rose' | 'amber' | 'slate'; + +interface ThemeState { + mode: ThemeMode; + accent: AccentColor; + setMode: (mode: ThemeMode) => void; + setAccent: (accent: AccentColor) => void; + toggleMode: () => void; +} + +export const ACCENT_TOKENS: Record = { + indigo: { base: '#6366f1', light: '#e0e7ff', label: 'Indigo' }, + teal: { base: '#14b8a6', light: '#ccfbf1', label: 'Teal' }, + rose: { base: '#f43f5e', light: '#ffe4e6', label: 'Rose' }, + amber: { base: '#f59e0b', light: '#fef3c7', label: 'Amber' }, + slate: { base: '#64748b', light: '#f1f5f9', label: 'Slate' }, +}; + +function applyTheme(mode: ThemeMode, accent: AccentColor) { + const root = document.documentElement; + const { base, light } = ACCENT_TOKENS[accent]; + + // Toggle dark class on + root.classList.toggle('dark', mode === 'dark'); + + // Accent tokens (same in both modes) + root.style.setProperty('--color-accent', base); + root.style.setProperty('--color-accent-light', light); + + // Surface tokens + if (mode === 'dark') { + root.style.setProperty('--color-bg', '#0f172a'); + root.style.setProperty('--color-surface', '#1e293b'); + root.style.setProperty('--color-surface-raised', '#263548'); + root.style.setProperty('--color-border', '#334155'); + root.style.setProperty('--color-text-primary', '#f1f5f9'); + root.style.setProperty('--color-text-secondary', '#94a3b8'); + root.style.setProperty('--color-text-muted', '#64748b'); + } else { + root.style.setProperty('--color-bg', '#f8fafc'); + root.style.setProperty('--color-surface', '#ffffff'); + root.style.setProperty('--color-surface-raised', '#f1f5f9'); + root.style.setProperty('--color-border', '#e2e8f0'); + root.style.setProperty('--color-text-primary', '#0f172a'); + root.style.setProperty('--color-text-secondary', '#475569'); + root.style.setProperty('--color-text-muted', '#94a3b8'); + } +} + +export const useThemeStore = create()( + persist( + (set, get) => ({ + mode: 'light', + accent: 'indigo', + setMode: (mode) => { + set({ mode }); + applyTheme(mode, get().accent); + }, + setAccent: (accent) => { + set({ accent }); + applyTheme(get().mode, accent); + }, + toggleMode: () => { + const next = get().mode === 'light' ? 'dark' : 'light'; + set({ mode: next }); + applyTheme(next, get().accent); + }, + }), + { name: 'fp-theme' } + ) +); + +/** Call once at app boot to hydrate CSS tokens from persisted state */ +export function initTheme() { + const { mode, accent } = useThemeStore.getState(); + applyTheme(mode, accent); +} diff --git a/apps/client/tailwind.config.ts b/apps/client/tailwind.config.ts new file mode 100644 index 0000000..07dc11c --- /dev/null +++ b/apps/client/tailwind.config.ts @@ -0,0 +1,35 @@ +import type { Config } from 'tailwindcss'; + +export default { + darkMode: 'class', + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + // Map CSS custom properties → Tailwind utilities + bg: 'var(--color-bg)', + surface: 'var(--color-surface)', + border: 'var(--color-border)', + 'text-primary': 'var(--color-text-primary)', + 'text-secondary': 'var(--color-text-secondary)', + accent: 'var(--color-accent)', + 'accent-light': 'var(--color-accent-light)', + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + transitionProperty: { + theme: 'background-color, color, border-color, fill, stroke', + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out', + 'slide-up': 'slideUp 0.3s ease-out', + }, + keyframes: { + fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } }, + slideUp: { '0%': { opacity: '0', transform: 'translateY(8px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } }, + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json new file mode 100644 index 0000000..5a93f6d --- /dev/null +++ b/apps/client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "noEmit": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src"] +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts new file mode 100644 index 0000000..e982b67 --- /dev/null +++ b/apps/client/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': path.resolve(__dirname, './src') }, + }, + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:3001', changeOrigin: true }, + }, + }, +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..119c31a --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "server", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.3", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.11", + "@types/node": "^22.0.0", + "tsx": "^4.7.1", + "typescript": "^5.3.3" + } +} diff --git a/apps/server/src/db/db.ts b/apps/server/src/db/db.ts new file mode 100644 index 0000000..e6cc5e9 --- /dev/null +++ b/apps/server/src/db/db.ts @@ -0,0 +1,39 @@ +import { DatabaseSync, type StatementSync } from 'node:sqlite'; +import path from 'path'; +import fs from 'fs'; + +const DATA_DIR = process.env.DATA_DIR ?? path.join(__dirname, '../../data'); +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + +const _db = new DatabaseSync(path.join(DATA_DIR, 'family.db')); + +_db.exec('PRAGMA journal_mode = WAL'); +_db.exec('PRAGMA foreign_keys = ON'); + +/** + * Transaction wrapper matching better-sqlite3's API: + * const fn = db.transaction((args) => { ... }); + * fn(args); // runs inside BEGIN / COMMIT, rolls back on throw + */ +function transaction( + fn: (...args: TArgs) => TReturn +): (...args: TArgs) => TReturn { + return (...args: TArgs): TReturn => { + _db.exec('BEGIN'); + try { + const result = fn(...args); + _db.exec('COMMIT'); + return result; + } catch (err) { + try { _db.exec('ROLLBACK'); } catch { /* ignore rollback errors */ } + throw err; + } + }; +} + +// Re-export db with the transaction helper attached, preserving full DatabaseSync interface +const db = Object.assign(_db, { transaction }); + +export type Db = typeof db; +export type Statement = StatementSync; +export default db; diff --git a/apps/server/src/db/migrations/001_initial.ts b/apps/server/src/db/migrations/001_initial.ts new file mode 100644 index 0000000..ac3c172 --- /dev/null +++ b/apps/server/src/db/migrations/001_initial.ts @@ -0,0 +1,120 @@ +export const id = '001_initial'; + +export const up = ` + -- ─── Family Members ─────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6366f1', + avatar TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── App Settings (key/value store) ─────────────────────────────── + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- ─── Calendar Events ────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + recurrence TEXT, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, + color TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── Shopping Lists ─────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS shopping_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS shopping_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + quantity TEXT, + checked INTEGER NOT NULL DEFAULT 0, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── Chores ─────────────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS chores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, + recurrence TEXT NOT NULL DEFAULT 'none', + status TEXT NOT NULL DEFAULT 'pending', + due_date TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS chore_completions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, + completed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── Meal Planner (one meal per day — dinner) ───────────────────── + CREATE TABLE IF NOT EXISTS meals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + recipe_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(date) + ); + + -- ─── Message Board ──────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, + body TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#fef08a', + emoji TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── Countdowns ─────────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS countdowns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + target_date TEXT NOT NULL, + emoji TEXT, + color TEXT NOT NULL DEFAULT '#6366f1', + show_on_dashboard INTEGER NOT NULL DEFAULT 1, + event_id INTEGER REFERENCES events(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ─── Seed default settings ──────────────────────────────────────── + INSERT OR IGNORE INTO settings (key, value) VALUES + ('theme', 'light'), + ('accent', 'indigo'), + ('photo_folder', ''), + ('slideshow_speed', '6000'), + ('slideshow_order', 'random'), + ('idle_timeout', '120000'), + ('time_format', '12h'), + ('date_format', 'MM/DD/YYYY'), + ('weather_api_key', ''), + ('weather_location',''), + ('weather_units', 'imperial'); + + INSERT OR IGNORE INTO shopping_lists (id, name) VALUES (1, 'Groceries'); +`; diff --git a/apps/server/src/db/runner.ts b/apps/server/src/db/runner.ts new file mode 100644 index 0000000..773d070 --- /dev/null +++ b/apps/server/src/db/runner.ts @@ -0,0 +1,123 @@ +/** + * Migration runner + * + * How it works: + * 1. Creates a `_migrations` table on first run. + * 2. Loads all migration modules from ./migrations/ in filename order. + * 3. Skips any migration whose `id` is already recorded in `_migrations`. + * 4. Executes pending migrations inside individual transactions. + * 5. Records each successful migration with a timestamp. + * + * Adding a new migration: + * - Create `apps/server/src/db/migrations/NNN_description.ts` + * - Export `id` (string, matches filename) and `up` (SQL string). + * - Optionally export `down` (SQL string) for rollback support. + * - The runner picks it up automatically on next startup. + */ + +import db from './db'; +import path from 'path'; +import fs from 'fs'; + +interface Migration { + id: string; + up: string; + down?: string; +} + +function bootstrap() { + db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} + +function loadMigrations(): Migration[] { + const dir = path.join(__dirname, 'migrations'); + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.ts') || f.endsWith('.js')) + .sort() + .map((file) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(path.join(dir, file)) as Migration; + if (!mod.id || !mod.up) { + throw new Error(`Migration ${file} must export 'id' and 'up'`); + } + return mod; + }); +} + +function getApplied(): Set { + const rows = db.prepare('SELECT id FROM _migrations').all() as { id: string }[]; + return new Set(rows.map((r) => r.id)); +} + +export function runMigrations() { + bootstrap(); + + const migrations = loadMigrations(); + const applied = getApplied(); + const pending = migrations.filter((m) => !applied.has(m.id)); + + if (pending.length === 0) { + console.log('[db] All migrations up to date.'); + return; + } + + console.log(`[db] Running ${pending.length} pending migration(s)...`); + + for (const migration of pending) { + const apply = db.transaction(() => { + db.exec(migration.up); + db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id); + }); + + try { + apply(); + console.log(`[db] ✓ Applied: ${migration.id}`); + } catch (err) { + console.error(`[db] ✗ Failed: ${migration.id}`, err); + throw err; // Abort startup on migration failure + } + } + + console.log('[db] Migrations complete.'); +} + +export function rollback(targetId: string) { + const migrations = loadMigrations(); + const applied = getApplied(); + + // Find all applied migrations after targetId in reverse order + const toRollback = migrations + .filter((m) => applied.has(m.id) && m.id > targetId) + .reverse(); + + if (toRollback.length === 0) { + console.log('[db] Nothing to roll back.'); + return; + } + + for (const migration of toRollback) { + if (!migration.down) { + console.warn(`[db] ⚠ No down migration for: ${migration.id} — skipping`); + continue; + } + const revert = db.transaction(() => { + db.exec(migration.down!); + db.prepare('DELETE FROM _migrations WHERE id = ?').run(migration.id); + }); + try { + revert(); + console.log(`[db] ✓ Rolled back: ${migration.id}`); + } catch (err) { + console.error(`[db] ✗ Rollback failed: ${migration.id}`, err); + throw err; + } + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..b29f1bb --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,45 @@ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { runMigrations } from './db/runner'; + +import membersRouter from './routes/members'; +import settingsRouter from './routes/settings'; +import eventsRouter from './routes/events'; +import shoppingRouter from './routes/shopping'; +import choresRouter from './routes/chores'; +import mealsRouter from './routes/meals'; +import messagesRouter from './routes/messages'; +import countdownsRouter from './routes/countdowns'; +import photosRouter from './routes/photos'; + +// Run DB migrations on startup — aborts if any migration fails +runMigrations(); + +const app = express(); +const PORT = process.env.PORT ?? 3001; +const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173'; + +app.use(cors({ origin: CLIENT_ORIGIN })); +app.use(express.json()); + +app.use('/api/members', membersRouter); +app.use('/api/settings', settingsRouter); +app.use('/api/events', eventsRouter); +app.use('/api/shopping', shoppingRouter); +app.use('/api/chores', choresRouter); +app.use('/api/meals', mealsRouter); +app.use('/api/messages', messagesRouter); +app.use('/api/countdowns', countdownsRouter); +app.use('/api/photos', photosRouter); + +// Serve built client — in Docker the client dist is copied here at build time +const CLIENT_DIST = path.join(__dirname, '../../client/dist'); +app.use(express.static(CLIENT_DIST)); +app.get('*', (_req, res) => { + res.sendFile(path.join(CLIENT_DIST, 'index.html')); +}); + +app.listen(PORT, () => { + console.log(`Family Planner running on http://0.0.0.0:${PORT}`); +}); diff --git a/apps/server/src/routes/chores.ts b/apps/server/src/routes/chores.ts new file mode 100644 index 0000000..b1d51d9 --- /dev/null +++ b/apps/server/src/routes/chores.ts @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (req, res) => { + const { member_id } = req.query; + let query = ` + 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 + `; + if (member_id) { + query += ' WHERE c.member_id = ?'; + return res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all(member_id as string)); + } + res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all()); +}); + +router.post('/', (req, res) => { + const { title, description, member_id, recurrence, due_date } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Title is required' }); + const result = db + .prepare('INSERT INTO chores (title, description, member_id, recurrence, due_date) VALUES (?, ?, ?, ?, ?)') + .run(title.trim(), description ?? null, member_id ?? null, recurrence ?? 'none', due_date ?? null); + res.status(201).json(db.prepare('SELECT * FROM chores WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.put('/:id', (req, res) => { + const existing = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Chore not found' }); + const { title, description, member_id, recurrence, status, due_date } = req.body; + db.prepare('UPDATE chores SET title=?, description=?, member_id=?, recurrence=?, status=?, due_date=? WHERE id=?').run( + title?.trim() ?? existing.title, + description !== undefined ? description : existing.description, + member_id !== undefined ? member_id : existing.member_id, + recurrence ?? existing.recurrence, + status ?? existing.status, + due_date !== undefined ? due_date : existing.due_date, + req.params.id + ); + res.json(db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id)); +}); + +router.delete('/:id', (req, res) => { + const result = db.prepare('DELETE FROM chores WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Chore not found' }); + res.status(204).end(); +}); + +router.post('/:id/complete', (req, res) => { + const { member_id } = req.body; + const chore = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any; + if (!chore) return res.status(404).json({ error: 'Chore not found' }); + db.prepare('INSERT INTO chore_completions (chore_id, member_id) VALUES (?, ?)').run(req.params.id, member_id ?? chore.member_id); + db.prepare("UPDATE chores SET status = 'done' WHERE id = ?").run(req.params.id); + res.json({ success: true }); +}); + +router.get('/:id/completions', (req, res) => { + res.json( + db.prepare(` + SELECT cc.*, m.name as member_name FROM chore_completions cc + LEFT JOIN members m ON cc.member_id = m.id + WHERE cc.chore_id = ? ORDER BY cc.completed_at DESC + `).all(req.params.id) + ); +}); + +export default router; diff --git a/apps/server/src/routes/countdowns.ts b/apps/server/src/routes/countdowns.ts new file mode 100644 index 0000000..1e121e3 --- /dev/null +++ b/apps/server/src/routes/countdowns.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (_req, res) => { + res.json( + db.prepare(` + SELECT c.*, e.title as event_title + FROM countdowns c + LEFT JOIN events e ON c.event_id = e.id + WHERE c.target_date >= date('now') + ORDER BY c.target_date ASC + `).all() + ); +}); + +router.post('/', (req, res) => { + const { title, target_date, emoji, color, show_on_dashboard, event_id } = req.body; + if (!title?.trim() || !target_date) return res.status(400).json({ error: 'title and target_date are required' }); + const result = db + .prepare('INSERT INTO countdowns (title, target_date, emoji, color, show_on_dashboard, event_id) VALUES (?, ?, ?, ?, ?, ?)') + .run(title.trim(), target_date, emoji ?? null, color ?? '#6366f1', show_on_dashboard !== false ? 1 : 0, event_id ?? null); + res.status(201).json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.put('/:id', (req, res) => { + const existing = db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Countdown not found' }); + const { title, target_date, emoji, color, show_on_dashboard } = req.body; + db.prepare('UPDATE countdowns SET title=?, target_date=?, emoji=?, color=?, show_on_dashboard=? WHERE id=?').run( + title?.trim() ?? existing.title, + target_date ?? existing.target_date, + emoji !== undefined ? emoji : existing.emoji, + color ?? existing.color, + show_on_dashboard !== undefined ? (show_on_dashboard ? 1 : 0) : existing.show_on_dashboard, + req.params.id + ); + res.json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id)); +}); + +router.delete('/:id', (req, res) => { + const result = db.prepare('DELETE FROM countdowns WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Countdown not found' }); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts new file mode 100644 index 0000000..3dcf2cb --- /dev/null +++ b/apps/server/src/routes/events.ts @@ -0,0 +1,60 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (req, res) => { + const { start, end } = req.query as { start?: string; end?: string }; + let query = 'SELECT * FROM events'; + const params: string[] = []; + if (start && end) { + query += ' WHERE start_at >= ? AND start_at <= ?'; + params.push(start, end); + } + query += ' ORDER BY start_at ASC'; + res.json(db.prepare(query).all(...params)); +}); + +router.get('/:id', (req, res) => { + const row = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Event not found' }); + res.json(row); +}); + +router.post('/', (req, res) => { + const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body; + if (!title?.trim() || !start_at || !end_at) + return res.status(400).json({ error: 'title, start_at, and end_at are required' }); + const result = db + .prepare(`INSERT INTO events (title, description, start_at, end_at, all_day, recurrence, member_id, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(title.trim(), description ?? null, start_at, end_at, all_day ? 1 : 0, recurrence ?? null, member_id ?? null, color ?? null); + res.status(201).json(db.prepare('SELECT * FROM events WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.put('/:id', (req, res) => { + const existing = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Event not found' }); + const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body; + db.prepare(`UPDATE events SET title=?, description=?, start_at=?, end_at=?, all_day=?, recurrence=?, member_id=?, color=? WHERE id=?`) + .run( + title?.trim() ?? existing.title, + description !== undefined ? description : existing.description, + start_at ?? existing.start_at, + end_at ?? existing.end_at, + all_day !== undefined ? (all_day ? 1 : 0) : existing.all_day, + recurrence !== undefined ? recurrence : existing.recurrence, + member_id !== undefined ? member_id : existing.member_id, + color !== undefined ? color : existing.color, + req.params.id + ); + res.json(db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id)); +}); + +router.delete('/:id', (req, res) => { + const result = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Event not found' }); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/src/routes/meals.ts b/apps/server/src/routes/meals.ts new file mode 100644 index 0000000..5af4486 --- /dev/null +++ b/apps/server/src/routes/meals.ts @@ -0,0 +1,37 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +// Get meals for a date range (e.g. ?start=2024-01-01&end=2024-01-31) +router.get('/', (req, res) => { + const { start, end } = req.query as { start?: string; end?: string }; + if (start && end) { + return res.json(db.prepare('SELECT * FROM meals WHERE date >= ? AND date <= ? ORDER BY date ASC').all(start, end)); + } + res.json(db.prepare('SELECT * FROM meals ORDER BY date ASC').all()); +}); + +router.get('/:date', (req, res) => { + const row = db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date); + if (!row) return res.status(404).json({ error: 'No meal for this date' }); + res.json(row); +}); + +router.put('/:date', (req, res) => { + const { title, description, recipe_url } = req.body as { title: string; description?: string; recipe_url?: string }; + if (!title?.trim()) return res.status(400).json({ error: 'Title is required' }); + db.prepare(` + INSERT INTO meals (date, title, description, recipe_url) VALUES (?, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET title=excluded.title, description=excluded.description, recipe_url=excluded.recipe_url + `).run(req.params.date, title.trim(), description ?? null, recipe_url ?? null); + res.json(db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date)); +}); + +router.delete('/:date', (req, res) => { + const result = db.prepare('DELETE FROM meals WHERE date = ?').run(req.params.date); + if (result.changes === 0) return res.status(404).json({ error: 'No meal for this date' }); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/src/routes/members.ts b/apps/server/src/routes/members.ts new file mode 100644 index 0000000..7814501 --- /dev/null +++ b/apps/server/src/routes/members.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (_req, res) => { + const rows = db.prepare('SELECT * FROM members ORDER BY name ASC').all(); + res.json(rows); +}); + +router.get('/:id', (req, res) => { + const row = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Member not found' }); + res.json(row); +}); + +router.post('/', (req, res) => { + const { name, color, avatar } = req.body as { name: string; color?: string; avatar?: string }; + if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); + const result = db + .prepare('INSERT INTO members (name, color, avatar) VALUES (?, ?, ?)') + .run(name.trim(), color ?? '#6366f1', avatar ?? null); + const created = db.prepare('SELECT * FROM members WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(created); +}); + +router.put('/:id', (req, res) => { + const { name, color, avatar } = req.body as { name?: string; color?: string; avatar?: string }; + const existing = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Member not found' }); + db.prepare('UPDATE members SET name = ?, color = ?, avatar = ? WHERE id = ?').run( + name?.trim() ?? existing.name, + color ?? existing.color, + avatar !== undefined ? avatar : existing.avatar, + req.params.id + ); + const updated = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id); + res.json(updated); +}); + +router.delete('/:id', (req, res) => { + const result = db.prepare('DELETE FROM members WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Member not found' }); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts new file mode 100644 index 0000000..585bd36 --- /dev/null +++ b/apps/server/src/routes/messages.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (_req, res) => { + res.json( + 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.expires_at IS NULL OR msg.expires_at > datetime('now') + ORDER BY msg.pinned DESC, msg.created_at DESC + `).all() + ); +}); + +router.post('/', (req, res) => { + const { member_id, body, color, emoji, pinned, expires_at } = req.body; + if (!body?.trim()) return res.status(400).json({ error: 'Body is required' }); + const result = db + .prepare('INSERT INTO messages (member_id, body, color, emoji, pinned, expires_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(member_id ?? null, body.trim(), color ?? '#fef08a', emoji ?? null, pinned ? 1 : 0, expires_at ?? null); + res.status(201).json(db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.patch('/:id', (req, res) => { + const existing = db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Message not found' }); + const { body, color, emoji, pinned, expires_at } = req.body; + db.prepare('UPDATE messages SET body=?, color=?, emoji=?, pinned=?, expires_at=? WHERE id=?').run( + body?.trim() ?? existing.body, + color ?? existing.color, + emoji !== undefined ? emoji : existing.emoji, + pinned !== undefined ? (pinned ? 1 : 0) : existing.pinned, + expires_at !== undefined ? expires_at : existing.expires_at, + req.params.id + ); + res.json(db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id)); +}); + +router.delete('/:id', (req, res) => { + const result = db.prepare('DELETE FROM messages WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Message not found' }); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/src/routes/photos.ts b/apps/server/src/routes/photos.ts new file mode 100644 index 0000000..3b5d2f0 --- /dev/null +++ b/apps/server/src/routes/photos.ts @@ -0,0 +1,61 @@ +import { Router } from 'express'; +import db from '../db/db'; +import fs from 'fs'; +import path from 'path'; + +const router = Router(); + +const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp']); + +function scanDir(dir: string): string[] { + if (!dir || !fs.existsSync(dir)) return []; + const results: string[] = []; + function recurse(current: string) { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) recurse(full); + else if (IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) results.push(full); + } + } + recurse(dir); + return results; +} + +// 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) => { + const folder = resolvePhotoFolder(); + const files = scanDir(folder); + res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) }); +}); + +router.get('/file/:filename', (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' }); + 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 +router.get('/slideshow', (_req, res) => { + const folder = resolvePhotoFolder(); + 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 }); +}); + +export default router; diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts new file mode 100644 index 0000000..0edc5f6 --- /dev/null +++ b/apps/server/src/routes/settings.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +router.get('/', (_req, res) => { + const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[]; + const settings = Object.fromEntries(rows.map((r) => [r.key, r.value])); + res.json(settings); +}); + +router.patch('/', (req, res) => { + const updates = req.body as Record; + const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'); + const updateMany = db.transaction((pairs: [string, string][]) => { + for (const [k, v] of pairs) upsert.run(k, String(v)); + }); + updateMany(Object.entries(updates)); + const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[]; + res.json(Object.fromEntries(rows.map((r) => [r.key, r.value]))); +}); + +export default router; diff --git a/apps/server/src/routes/shopping.ts b/apps/server/src/routes/shopping.ts new file mode 100644 index 0000000..4cea441 --- /dev/null +++ b/apps/server/src/routes/shopping.ts @@ -0,0 +1,67 @@ +import { Router } from 'express'; +import db from '../db/db'; + +const router = Router(); + +// ── Lists ────────────────────────────────────────────────────────────────── +router.get('/lists', (_req, res) => { + res.json(db.prepare('SELECT * FROM shopping_lists ORDER BY name ASC').all()); +}); + +router.post('/lists', (req, res) => { + const { name } = req.body as { name: string }; + if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); + const result = db.prepare('INSERT INTO shopping_lists (name) VALUES (?)').run(name.trim()); + res.status(201).json(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.delete('/lists/:id', (req, res) => { + const result = db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'List not found' }); + res.status(204).end(); +}); + +// ── Items ────────────────────────────────────────────────────────────────── +router.get('/lists/:listId/items', (req, res) => { + res.json( + db.prepare('SELECT * FROM shopping_items WHERE list_id = ? ORDER BY sort_order ASC, id ASC').all(req.params.listId) + ); +}); + +router.post('/lists/:listId/items', (req, res) => { + const { name, quantity, member_id } = req.body as { name: string; quantity?: string; member_id?: number }; + if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); + const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM shopping_items WHERE list_id = ?').get(req.params.listId) as any)?.m ?? 0; + const result = db + .prepare('INSERT INTO shopping_items (list_id, name, quantity, member_id, sort_order) VALUES (?, ?, ?, ?, ?)') + .run(req.params.listId, name.trim(), quantity ?? null, member_id ?? null, maxOrder + 1); + res.status(201).json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(result.lastInsertRowid)); +}); + +router.patch('/items/:id', (req, res) => { + const existing = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id) as any; + if (!existing) return res.status(404).json({ error: 'Item not found' }); + const { name, quantity, checked, member_id, sort_order } = req.body; + db.prepare('UPDATE shopping_items SET name=?, quantity=?, checked=?, member_id=?, sort_order=? WHERE id=?').run( + name?.trim() ?? existing.name, + quantity !== undefined ? quantity : existing.quantity, + checked !== undefined ? (checked ? 1 : 0) : existing.checked, + member_id !== undefined ? member_id : existing.member_id, + sort_order !== undefined ? sort_order : existing.sort_order, + req.params.id + ); + res.json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id)); +}); + +router.delete('/items/:id', (req, res) => { + const result = db.prepare('DELETE FROM shopping_items WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Item not found' }); + res.status(204).end(); +}); + +router.delete('/lists/:listId/items/checked', (req, res) => { + db.prepare('DELETE FROM shopping_items WHERE list_id = ? AND checked = 1').run(req.params.listId); + res.status(204).end(); +}); + +export default router; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..5a9b7b7 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..de3f24f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + family-planner: + build: + context: . + dockerfile: Dockerfile + container_name: family-planner + restart: unless-stopped + + ports: + - "${PORT:-3001}:3001" + + environment: + # App + PORT: 3001 + TZ: ${TZ:-America/New_York} + + # File paths inside the container + DATA_DIR: /data + PHOTOS_DIR: /photos + + # Drop privileges to this UID/GID (Unraid: nobody=99, users=100) + PUID: ${PUID:-99} + PGID: ${PGID:-100} + + volumes: + # Persistent database storage — map to wherever you keep appdata + - ${DATA_PATH:-./data}:/data + + # Your photo library — mount read-only + # Change the left side to your actual photos folder path + - ${PHOTOS_PATH:-./photos}:/photos:ro + + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/settings"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..de28b57 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +# Resolve PUID/PGID (Unraid default: nobody=99, users=100) +PUID=${PUID:-99} +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 +fi +if ! getent passwd appuser > /dev/null 2>&1; then + adduser -D -u "${PUID}" -G appgroup appuser +fi + +# Ensure /data is owned by the app user so SQLite can write +mkdir -p /data +chown -R "${PUID}:${PGID}" /data + +# Drop privileges and exec the CMD +exec su-exec "${PUID}:${PGID}" "$@" diff --git a/package.json b/package.json new file mode 100644 index 0000000..a9fcf45 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "family-planner", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "concurrently \"pnpm --filter server dev\" \"pnpm --filter client dev\"", + "build": "pnpm --filter server build && pnpm --filter client build", + "start": "pnpm --filter server start" + }, + "devDependencies": { + "concurrently": "^8.2.2" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8237d56 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3174 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + + apps/client: + dependencies: + '@tanstack/react-query': + specifier: ^5.28.4 + version: 5.95.2(react@18.3.1) + axios: + specifier: ^1.6.7 + version: 1.14.0 + clsx: + specifier: ^2.1.0 + version: 2.1.1 + date-fns: + specifier: ^3.3.1 + version: 3.6.0 + framer-motion: + specifier: ^11.0.14 + version: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: ^0.359.0 + version: 0.359.0(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.22.3 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^4.5.2 + version: 4.5.7(@types/react@18.3.28)(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.2.66 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.22 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) + autoprefixer: + specifier: ^10.4.18 + version: 10.4.27(postcss@8.5.8) + postcss: + specifier: ^8.4.37 + version: 8.5.8 + tailwindcss: + specifier: ^3.4.1 + version: 3.4.19(tsx@4.21.0) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vite: + specifier: ^5.2.0 + version: 5.4.21(@types/node@22.19.15) + + apps/server: + dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.6 + express: + specifier: ^4.18.3 + version: 4.22.1 + multer: + specifier: ^1.4.5-lts.1 + version: 1.4.5-lts.2 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/multer': + specifier: ^1.4.11 + version: 1.4.13 + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} + + '@tanstack/react-query@5.95.2': + resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + + baseline-browser-mapping@2.10.12: + resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001782: + resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.359.0: + resolution: {integrity: sha512-bxVL+rM/wacjpT0BKShA6r5IIKb6LCRg+ltFG9pnnIwaRX8kK3hq8v5JwMpT7RC6XeqB5cSaaV6GapPWWmtliw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@1.4.5-lts.2: + resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} + engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@tanstack/query-core@5.95.2': {} + + '@tanstack/react-query@5.95.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.95.2 + react: 18.3.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.15 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.15 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.15 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 22.19.15 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/multer@1.4.13': + dependencies: + '@types/express': 4.17.25 + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.19.15 + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.15 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.15 + '@types/send': 0.17.6 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.15))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.19.15) + transitivePeerDependencies: + - supports-color + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: {} + + arg@5.0.2: {} + + array-flatten@1.1.1: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001782 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + baseline-browser-mapping@2.10.12: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001782: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.23 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.2 + + date-fns@3.6.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.328: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isarray@1.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash@4.17.23: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.359.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@1.4.5-lts.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + negotiator@0.6.3: {} + + node-releases@2.0.36: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.13: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.8): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.8 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.8 + tsx: 4.21.0 + + postcss-nested@6.2.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + require-directory@2.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + spawn-command@0.0.2: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.19(tsx@4.21.0): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0) + postcss-nested: 6.2.0(postcss@8.5.8) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + vite@5.4.21(@types/node@22.19.15): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.0 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zustand@4.5.7(@types/react@18.3.28)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8ab3e17 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'apps/*' diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..da734d9 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/unraid/family-planner.xml b/unraid/family-planner.xml new file mode 100644 index 0000000..b90a6fd --- /dev/null +++ b/unraid/family-planner.xml @@ -0,0 +1,113 @@ + + + + + family-planner + ghcr.io/your-username/family-planner:latest + https://ghcr.io/your-username/family-planner + bridge + sh + false + + + A sleek, modern family dashboard with a shared calendar, chore assignments, + shopping lists, dinner meal planner, message board, countdown timers, + and a full-screen photo slideshow screensaver. + Includes dark/light mode with accent color selection. + + + Productivity: Tools: + http://[IP]:[PORT:3001]/ + + + https://raw.githubusercontent.com/your-username/family-planner/main/unraid/icon.png + + --restart=unless-stopped + + + 3001 + + + /mnt/user/appdata/family-planner + + /mnt/user/Photos + + + 99 + + 100 + + America/New_York + + 3001 + + diff --git a/unraid/install.sh b/unraid/install.sh new file mode 100644 index 0000000..541a9bb --- /dev/null +++ b/unraid/install.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# Family Planner — Unraid CLI Install Script +# +# Run from the Unraid terminal: +# bash /path/to/install.sh +# +# Or pipe directly (once the image is published): +# curl -fsSL https://raw.githubusercontent.com/your-username/family-planner/main/unraid/install.sh | bash +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +# ── Configurable defaults (edit these or they will be prompted) ─────────────── +IMAGE="${IMAGE:-ghcr.io/your-username/family-planner:latest}" +CONTAINER_NAME="${CONTAINER_NAME:-family-planner}" +HOST_PORT="${HOST_PORT:-3001}" +DATA_PATH="${DATA_PATH:-/mnt/user/appdata/family-planner}" +PHOTOS_PATH="${PHOTOS_PATH:-/mnt/user/Photos}" +PUID="${PUID:-99}" +PGID="${PGID:-100}" +TZ="${TZ:-America/New_York}" + +# ── Helper ──────────────────────────────────────────────────────────────────── +info() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +success() { echo -e "\033[1;32m[OK]\033[0m $*"; } +warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; } +error() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; } + +# ── Preflight ───────────────────────────────────────────────────────────────── +command -v docker &>/dev/null || error "Docker is not installed or not in PATH." + +info "Family Planner — Unraid Installer" +echo "" +echo " Container : $CONTAINER_NAME" +echo " Image : $IMAGE" +echo " Port : $HOST_PORT → 3001" +echo " Data path : $DATA_PATH" +echo " Photos : $PHOTOS_PATH (read-only)" +echo " PUID/PGID : $PUID/$PGID" +echo " TZ : $TZ" +echo "" +read -rp "Proceed with these settings? [Y/n] " confirm +confirm="${confirm:-Y}" +[[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; } + +# ── Stop and remove existing container if present ───────────────────────────── +if docker inspect "$CONTAINER_NAME" &>/dev/null; then + warn "Container '$CONTAINER_NAME' already exists — stopping and removing it." + docker stop "$CONTAINER_NAME" &>/dev/null || true + docker rm "$CONTAINER_NAME" &>/dev/null || true +fi + +# ── Ensure data directory exists ────────────────────────────────────────────── +mkdir -p "$DATA_PATH" +success "Data directory ready: $DATA_PATH" + +# ── Pull the latest image ───────────────────────────────────────────────────── +info "Pulling image: $IMAGE" +docker pull "$IMAGE" + +# ── Run the container ───────────────────────────────────────────────────────── +info "Starting container..." +docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "${HOST_PORT}:3001" \ + -v "${DATA_PATH}:/data" \ + -v "${PHOTOS_PATH}:/photos:ro" \ + -e "PUID=${PUID}" \ + -e "PGID=${PGID}" \ + -e "TZ=${TZ}" \ + -e "PORT=3001" \ + -e "DATA_DIR=/data" \ + -e "PHOTOS_DIR=/photos" \ + "$IMAGE" + +# ── Verify startup ──────────────────────────────────────────────────────────── +info "Waiting for container to become healthy..." +for i in $(seq 1 15); do + if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3001/api/settings &>/dev/null; then + success "Family Planner is up!" + break + fi + sleep 2 + if [ "$i" -eq 15 ]; then + warn "Health check timed out. Check logs with: docker logs $CONTAINER_NAME" + fi +done + +echo "" +success "Installation complete." +echo "" +echo " Open in browser : http://$(hostname -I | awk '{print $1}'):${HOST_PORT}" +echo " View logs : docker logs -f $CONTAINER_NAME" +echo " Stop : docker stop $CONTAINER_NAME" +echo " Update image : docker pull $IMAGE && docker stop $CONTAINER_NAME && bash $(realpath "$0")" +echo ""