Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2408d37f8e | ||
|
|
f47c1c3a23 | ||
|
|
6e92648a41 | ||
|
|
6802ae08db | ||
|
|
b935ff209c | ||
|
|
3fb5ddc5dd | ||
|
|
ca536b5c33 | ||
|
|
ec2f3930ad |
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
|||||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||||
|
|
||||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||||
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
|||||||
```yaml
|
```yaml
|
||||||
# compose.yaml (sketch — final lives in /deploy)
|
# compose.yaml (sketch — final lives in /deploy)
|
||||||
services:
|
services:
|
||||||
famapp: # node:22-alpine, Next.js
|
famapp: # node:24-alpine, Next.js
|
||||||
famapp-db: # postgres:16
|
famapp-db: # postgres:16
|
||||||
authentik-server:
|
authentik-server:
|
||||||
authentik-worker:
|
authentik-worker:
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/ginnoir/famapp/compare/v0.4.10...v0.5.0) (2026-06-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mobile:** full mobile experience overhaul — bottom nav, touch targets, calendar mobile layout with day/week/agenda views and calendars bottom sheet, bidirectional swipe gestures on list items (right to complete, left to delete), iOS safe-area and keyboard support, input zoom prevention
|
||||||
|
- **mobile:** settings navigation replaced with 2-column icon grid (no horizontal scroll)
|
||||||
|
- **mobile:** remove floating action button in favour of persistent topbar quick-add button
|
||||||
|
|
||||||
|
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
|
||||||
|
|
||||||
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
|
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
|||||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||||
|
|
||||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||||
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
|||||||
```yaml
|
```yaml
|
||||||
# compose.yaml (sketch — final lives in /deploy)
|
# compose.yaml (sketch — final lives in /deploy)
|
||||||
services:
|
services:
|
||||||
famapp: # node:22-alpine, Next.js
|
famapp: # node:24-alpine, Next.js
|
||||||
famapp-db: # postgres:16
|
famapp-db: # postgres:16
|
||||||
authentik-server:
|
authentik-server:
|
||||||
authentik-worker:
|
authentik-worker:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
|
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
|
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
@@ -20,7 +20,7 @@ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
|||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** 22 LTS (`node --version` should print `v22.x.x`)
|
- **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
|
||||||
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
|
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
|
||||||
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
|
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.4.8",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
"db:seed": "node --env-file=.env scripts/seed.mjs",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"gen:icons": "node scripts/generate-icons.mjs",
|
"gen:icons": "node scripts/generate-icons.mjs",
|
||||||
"vapid:generate": "node scripts/vapid-generate.mjs",
|
"vapid:generate": "node scripts/vapid-generate.mjs",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@release-it/conventional-changelog": "^11.0.1",
|
"@release-it/conventional-changelog": "^11.0.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
|
"@fullcalendar/list": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@fullcalendar/interaction':
|
'@fullcalendar/interaction':
|
||||||
specifier: ^6.1.20
|
specifier: ^6.1.20
|
||||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
|
'@fullcalendar/list':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
'@fullcalendar/react':
|
'@fullcalendar/react':
|
||||||
specifier: ^6.1.20
|
specifier: ^6.1.20
|
||||||
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -73,7 +76,7 @@ importers:
|
|||||||
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3)
|
version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -89,7 +92,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
version: 21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
|
version: 21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
|
||||||
'@commitlint/config-conventional':
|
'@commitlint/config-conventional':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
version: 21.0.2
|
version: 21.0.2
|
||||||
@@ -101,7 +104,7 @@ importers:
|
|||||||
version: 1.59.1
|
version: 1.59.1
|
||||||
'@release-it/conventional-changelog':
|
'@release-it/conventional-changelog':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))
|
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -109,8 +112,8 @@ importers:
|
|||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0
|
version: 1.9.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.9.0
|
specifier: ^24.0.0
|
||||||
version: 22.19.17
|
version: 24.12.4
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.14
|
specifier: ^19.2.14
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -149,7 +152,7 @@ importers:
|
|||||||
version: 3.8.3
|
version: 3.8.3
|
||||||
release-it:
|
release-it:
|
||||||
specifier: ^20.2.0
|
specifier: ^20.2.0
|
||||||
version: 20.2.0(@types/node@22.19.17)
|
version: 20.2.0(@types/node@24.12.4)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -976,6 +979,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@fullcalendar/core': ~6.1.20
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/list@6.1.20':
|
||||||
|
resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
|
||||||
'@fullcalendar/react@6.1.20':
|
'@fullcalendar/react@6.1.20':
|
||||||
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
|
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1862,8 +1870,8 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
'@types/node@24.12.4':
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
|
||||||
|
|
||||||
'@types/normalize-package-data@2.4.4':
|
'@types/normalize-package-data@2.4.4':
|
||||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||||
@@ -4895,8 +4903,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
undici@7.24.5:
|
undici@7.24.5:
|
||||||
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
||||||
@@ -5347,11 +5355,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@commitlint/cli@21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
|
'@commitlint/cli@21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@commitlint/format': 21.0.1
|
'@commitlint/format': 21.0.1
|
||||||
'@commitlint/lint': 21.0.2
|
'@commitlint/lint': 21.0.2
|
||||||
'@commitlint/load': 21.0.2(@types/node@22.19.17)(typescript@5.9.3)
|
'@commitlint/load': 21.0.2(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
@@ -5396,14 +5404,14 @@ snapshots:
|
|||||||
'@commitlint/rules': 21.0.2
|
'@commitlint/rules': 21.0.2
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
|
|
||||||
'@commitlint/load@21.0.2(@types/node@22.19.17)(typescript@5.9.3)':
|
'@commitlint/load@21.0.2(@types/node@24.12.4)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@commitlint/config-validator': 21.0.1
|
'@commitlint/config-validator': 21.0.1
|
||||||
'@commitlint/execute-rule': 21.0.1
|
'@commitlint/execute-rule': 21.0.1
|
||||||
'@commitlint/resolve-extends': 21.0.1
|
'@commitlint/resolve-extends': 21.0.1
|
||||||
'@commitlint/types': 21.0.1
|
'@commitlint/types': 21.0.1
|
||||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||||
cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
|
cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
|
||||||
es-toolkit: 1.47.0
|
es-toolkit: 1.47.0
|
||||||
is-plain-obj: 4.1.0
|
is-plain-obj: 4.1.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
@@ -5810,6 +5818,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@fullcalendar/core': 6.1.20
|
'@fullcalendar/core': 6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.20)':
|
||||||
|
dependencies:
|
||||||
|
'@fullcalendar/core': 6.1.20
|
||||||
|
|
||||||
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fullcalendar/core': 6.1.20
|
'@fullcalendar/core': 6.1.20
|
||||||
@@ -5942,140 +5954,140 @@ snapshots:
|
|||||||
|
|
||||||
'@inquirer/ansi@2.0.7': {}
|
'@inquirer/ansi@2.0.7': {}
|
||||||
|
|
||||||
'@inquirer/checkbox@5.2.1(@types/node@22.19.17)':
|
'@inquirer/checkbox@5.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/confirm@6.0.12(@types/node@22.19.17)':
|
'@inquirer/confirm@6.0.12(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.1.9(@types/node@22.19.17)
|
'@inquirer/core': 11.1.9(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.5(@types/node@22.19.17)
|
'@inquirer/type': 4.0.5(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/core@11.1.9(@types/node@22.19.17)':
|
'@inquirer/core@11.1.9(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.5
|
'@inquirer/ansi': 2.0.5
|
||||||
'@inquirer/figures': 2.0.5
|
'@inquirer/figures': 2.0.5
|
||||||
'@inquirer/type': 4.0.5(@types/node@22.19.17)
|
'@inquirer/type': 4.0.5(@types/node@24.12.4)
|
||||||
cli-width: 4.1.0
|
cli-width: 4.1.0
|
||||||
fast-wrap-ansi: 0.2.0
|
fast-wrap-ansi: 0.2.0
|
||||||
mute-stream: 3.0.0
|
mute-stream: 3.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/core@11.2.1(@types/node@22.19.17)':
|
'@inquirer/core@11.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
cli-width: 4.1.0
|
cli-width: 4.1.0
|
||||||
fast-wrap-ansi: 0.2.0
|
fast-wrap-ansi: 0.2.0
|
||||||
mute-stream: 3.0.0
|
mute-stream: 3.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/editor@5.2.2(@types/node@22.19.17)':
|
'@inquirer/editor@5.2.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/external-editor': 3.0.3(@types/node@22.19.17)
|
'@inquirer/external-editor': 3.0.3(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/expand@5.1.1(@types/node@22.19.17)':
|
'@inquirer/expand@5.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/external-editor@3.0.3(@types/node@22.19.17)':
|
'@inquirer/external-editor@3.0.3(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
chardet: 2.1.1
|
chardet: 2.1.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/figures@2.0.5': {}
|
'@inquirer/figures@2.0.5': {}
|
||||||
|
|
||||||
'@inquirer/figures@2.0.7': {}
|
'@inquirer/figures@2.0.7': {}
|
||||||
|
|
||||||
'@inquirer/input@5.1.2(@types/node@22.19.17)':
|
'@inquirer/input@5.1.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/number@4.1.1(@types/node@22.19.17)':
|
'@inquirer/number@4.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/password@5.1.1(@types/node@22.19.17)':
|
'@inquirer/password@5.1.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/prompts@8.4.2(@types/node@22.19.17)':
|
'@inquirer/prompts@8.4.2(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/checkbox': 5.2.1(@types/node@22.19.17)
|
'@inquirer/checkbox': 5.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
|
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
|
||||||
'@inquirer/editor': 5.2.2(@types/node@22.19.17)
|
'@inquirer/editor': 5.2.2(@types/node@24.12.4)
|
||||||
'@inquirer/expand': 5.1.1(@types/node@22.19.17)
|
'@inquirer/expand': 5.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/input': 5.1.2(@types/node@22.19.17)
|
'@inquirer/input': 5.1.2(@types/node@24.12.4)
|
||||||
'@inquirer/number': 4.1.1(@types/node@22.19.17)
|
'@inquirer/number': 4.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/password': 5.1.1(@types/node@22.19.17)
|
'@inquirer/password': 5.1.1(@types/node@24.12.4)
|
||||||
'@inquirer/rawlist': 5.3.1(@types/node@22.19.17)
|
'@inquirer/rawlist': 5.3.1(@types/node@24.12.4)
|
||||||
'@inquirer/search': 4.2.1(@types/node@22.19.17)
|
'@inquirer/search': 4.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/select': 5.2.1(@types/node@22.19.17)
|
'@inquirer/select': 5.2.1(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/rawlist@5.3.1(@types/node@22.19.17)':
|
'@inquirer/rawlist@5.3.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/search@4.2.1(@types/node@22.19.17)':
|
'@inquirer/search@4.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/select@5.2.1(@types/node@22.19.17)':
|
'@inquirer/select@5.2.1(@types/node@24.12.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
'@inquirer/core': 11.2.1(@types/node@22.19.17)
|
'@inquirer/core': 11.2.1(@types/node@24.12.4)
|
||||||
'@inquirer/figures': 2.0.7
|
'@inquirer/figures': 2.0.7
|
||||||
'@inquirer/type': 4.0.7(@types/node@22.19.17)
|
'@inquirer/type': 4.0.7(@types/node@24.12.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/type@4.0.5(@types/node@22.19.17)':
|
'@inquirer/type@4.0.5(@types/node@24.12.4)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@inquirer/type@4.0.7(@types/node@22.19.17)':
|
'@inquirer/type@4.0.7(@types/node@24.12.4)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6431,7 +6443,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))':
|
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
|
||||||
concat-stream: 2.0.0
|
concat-stream: 2.0.0
|
||||||
@@ -6439,7 +6451,7 @@ snapshots:
|
|||||||
conventional-changelog-angular: 8.3.1
|
conventional-changelog-angular: 8.3.1
|
||||||
conventional-changelog-conventionalcommits: 9.3.1
|
conventional-changelog-conventionalcommits: 9.3.1
|
||||||
conventional-recommended-bump: 11.2.0
|
conventional-recommended-bump: 11.2.0
|
||||||
release-it: 20.2.0(@types/node@22.19.17)
|
release-it: 20.2.0(@types/node@24.12.4)
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- conventional-commits-filter
|
- conventional-commits-filter
|
||||||
@@ -6551,9 +6563,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
'@types/node@24.12.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 7.16.0
|
||||||
|
|
||||||
'@types/normalize-package-data@2.4.4': {}
|
'@types/normalize-package-data@2.4.4': {}
|
||||||
|
|
||||||
@@ -6571,7 +6583,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/set-cookie-parser@2.4.10':
|
'@types/set-cookie-parser@2.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
@@ -6579,7 +6591,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-push@3.6.4':
|
'@types/web-push@3.6.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7159,9 +7171,9 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
|
|
||||||
cosmiconfig-typescript-loader@6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
|
cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.4
|
||||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -8658,9 +8670,9 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
|
msw@2.14.3(@types/node@24.12.4)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
|
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
|
||||||
'@mswjs/interceptors': 0.41.8
|
'@mswjs/interceptors': 0.41.8
|
||||||
'@open-draft/deferred-promise': 3.0.0
|
'@open-draft/deferred-promise': 3.0.0
|
||||||
'@types/statuses': 2.0.6
|
'@types/statuses': 2.0.6
|
||||||
@@ -9233,9 +9245,9 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
release-it@20.2.0(@types/node@22.19.17):
|
release-it@20.2.0(@types/node@24.12.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/prompts': 8.4.2(@types/node@22.19.17)
|
'@inquirer/prompts': 8.4.2(@types/node@24.12.4)
|
||||||
'@octokit/rest': 22.0.1
|
'@octokit/rest': 22.0.1
|
||||||
'@phun-ky/typeof': 2.0.3
|
'@phun-ky/typeof': 2.0.3
|
||||||
async-retry: 1.3.3
|
async-retry: 1.3.3
|
||||||
@@ -9401,7 +9413,7 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shadcn@4.7.0(@types/node@22.19.17)(typescript@5.9.3):
|
shadcn@4.7.0(@types/node@24.12.4)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
@@ -9422,7 +9434,7 @@ snapshots:
|
|||||||
fuzzysort: 3.1.0
|
fuzzysort: 3.1.0
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
kleur: 4.1.5
|
kleur: 4.1.5
|
||||||
msw: 2.14.3(@types/node@22.19.17)(typescript@5.9.3)
|
msw: 2.14.3(@types/node@24.12.4)(typescript@5.9.3)
|
||||||
node-fetch: 3.3.2
|
node-fetch: 3.3.2
|
||||||
open: 11.0.0
|
open: 11.0.0
|
||||||
ora: 8.2.0
|
ora: 8.2.0
|
||||||
@@ -9860,7 +9872,7 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@7.24.5: {}
|
undici@7.24.5: {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
|
||||||
|
</style>
|
||||||
|
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
|
||||||
|
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||||
|
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||||
|
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 547 B After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,12 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<!-- Background -->
|
<rect width="512" height="512" rx="96" fill="#4338CA"/>
|
||||||
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
|
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||||
<!-- House body -->
|
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||||
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
|
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||||
<!-- Door -->
|
|
||||||
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
|
|
||||||
<!-- Left window -->
|
|
||||||
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
|
||||||
<!-- Right window -->
|
|
||||||
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 345 B |
@@ -4,23 +4,26 @@
|
|||||||
"description": "Family coordination app",
|
"description": "Family coordination app",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#4338CA",
|
||||||
"theme_color": "#4F46E5",
|
"theme_color": "#4338CA",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-384.png",
|
"src": "/icon-384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512.png",
|
"src": "/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512-maskable.png",
|
"src": "/icon-512-maskable.png",
|
||||||
|
|||||||
@@ -1,100 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Generates placeholder PNG icons for the famapp PWA.
|
* Rasterizes public/icon.svg into all required PNG icon sizes.
|
||||||
*
|
*
|
||||||
* Usage: node scripts/generate-icons.mjs
|
* Usage: node scripts/generate-icons.mjs
|
||||||
*
|
*
|
||||||
* Produces:
|
* Produces:
|
||||||
|
* public/icon-16.png
|
||||||
|
* public/icon-32.png
|
||||||
|
* public/icon-180.png (apple-touch-icon)
|
||||||
* public/icon-192.png
|
* public/icon-192.png
|
||||||
* public/icon-384.png
|
* public/icon-384.png
|
||||||
* public/icon-512.png
|
* public/icon-512.png
|
||||||
* public/icon-512-maskable.png (same image; OS applies its own mask)
|
* public/icon-512-maskable.png (square bg, content in safe zone)
|
||||||
* public/icon-180.png (apple-touch-icon)
|
|
||||||
*
|
|
||||||
* All images are solid indigo (#4F46E5) squares — replace with a real
|
|
||||||
* branded export from icon.svg when assets are finalised.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { deflateSync } from "zlib";
|
import sharp from "sharp";
|
||||||
import { writeFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { resolve, dirname } from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const publicDir = resolve(__dir, "../public");
|
const pub = join(__dirname, "..", "public");
|
||||||
|
|
||||||
// Build CRC-32 lookup table once.
|
const svg = readFileSync(join(pub, "icon.svg"));
|
||||||
const CRC_TABLE = new Uint32Array(256);
|
|
||||||
for (let i = 0; i < 256; i++) {
|
|
||||||
let c = i;
|
|
||||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
CRC_TABLE[i] = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function crc32(buf) {
|
const sizes = [
|
||||||
let crc = 0xffffffff;
|
{ name: "icon-16.png", size: 16 },
|
||||||
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
{ name: "icon-32.png", size: 32 },
|
||||||
return (crc ^ 0xffffffff) >>> 0;
|
{ name: "icon-180.png", size: 180 },
|
||||||
}
|
|
||||||
|
|
||||||
function u32(n) {
|
|
||||||
const b = Buffer.alloc(4);
|
|
||||||
b.writeUInt32BE(n, 0);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pngChunk(type, data) {
|
|
||||||
const typeBytes = Buffer.from(type, "ascii");
|
|
||||||
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
|
|
||||||
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
|
|
||||||
*/
|
|
||||||
function solidPNG(size, r, g, b) {
|
|
||||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
||||||
|
|
||||||
const ihdr = pngChunk(
|
|
||||||
"IHDR",
|
|
||||||
Buffer.concat([
|
|
||||||
u32(size),
|
|
||||||
u32(size),
|
|
||||||
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
|
|
||||||
const rowLen = 1 + size * 3;
|
|
||||||
const raw = Buffer.alloc(size * rowLen);
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
const base = y * rowLen;
|
|
||||||
raw[base] = 0;
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
raw[base + 1 + x * 3] = r;
|
|
||||||
raw[base + 2 + x * 3] = g;
|
|
||||||
raw[base + 3 + x * 3] = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const idat = pngChunk("IDAT", deflateSync(raw));
|
|
||||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
|
||||||
|
|
||||||
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indigo-600 (#4F46E5)
|
|
||||||
const [R, G, B] = [0x4f, 0x46, 0xe5];
|
|
||||||
|
|
||||||
const icons = [
|
|
||||||
{ name: "icon-192.png", size: 192 },
|
{ name: "icon-192.png", size: 192 },
|
||||||
{ name: "icon-384.png", size: 384 },
|
{ name: "icon-384.png", size: 384 },
|
||||||
{ name: "icon-512.png", size: 512 },
|
{ name: "icon-512.png", size: 512 },
|
||||||
{ name: "icon-512-maskable.png", size: 512 },
|
|
||||||
{ name: "icon-180.png", size: 180 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, size } of icons) {
|
for (const { name, size } of sizes) {
|
||||||
const out = resolve(publicDir, name);
|
await sharp(svg).resize(size, size).png().toFile(join(pub, name));
|
||||||
writeFileSync(out, solidPNG(size, R, G, B));
|
|
||||||
console.log(` ✓ public/${name} (${size}×${size})`);
|
console.log(` ✓ public/${name} (${size}×${size})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maskable: remove rounded corners so the background fills the full canvas,
|
||||||
|
// allowing the OS to apply any mask shape without clipping the icon corners.
|
||||||
|
const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', "");
|
||||||
|
|
||||||
|
await sharp(Buffer.from(maskableSvg))
|
||||||
|
.resize(512, 512)
|
||||||
|
.png()
|
||||||
|
.toFile(join(pub, "icon-512-maskable.png"));
|
||||||
|
console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`);
|
||||||
|
|||||||
@@ -725,7 +725,7 @@
|
|||||||
}
|
}
|
||||||
:where(html[data-nav="bottom"]) .scroll-area,
|
:where(html[data-nav="bottom"]) .scroll-area,
|
||||||
:where(html[data-nav="fab"]) .scroll-area {
|
:where(html[data-nav="fab"]) .scroll-area {
|
||||||
padding: 12px 14px 88px;
|
padding: 12px 14px 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-area::-webkit-scrollbar {
|
.scroll-area::-webkit-scrollbar {
|
||||||
@@ -1424,3 +1424,87 @@ select {
|
|||||||
.animate-emoji-float {
|
.animate-emoji-float {
|
||||||
animation: emojiFloat 1.8s ease-out both;
|
animation: emojiFloat 1.8s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Calendar min-height CSS variable ───────────────────────────── */
|
||||||
|
/* Used by calendar-shell so the grid fills the viewport correctly */
|
||||||
|
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
|
||||||
|
:root {
|
||||||
|
--cal-topbar-h: 57px;
|
||||||
|
--cal-nav-h: 0px;
|
||||||
|
}
|
||||||
|
:where(html[data-nav="bottom"]),
|
||||||
|
:where(html[data-nav="fab"]) {
|
||||||
|
--cal-nav-h: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile overrides (≤ 759 px) ────────────────────────────────── */
|
||||||
|
@media (max-width: 759px) {
|
||||||
|
/* Prevent iOS Safari from zooming into inputs (requires ≥ 16 px) */
|
||||||
|
.input,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topbar: tighten padding; avatar is redundant (Settings in bottom nav) */
|
||||||
|
.topbar {
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topbar-actions > .avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcut hints are meaningless on touch */
|
||||||
|
.kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eliminate 300 ms tap delay on interactive controls */
|
||||||
|
.btn,
|
||||||
|
.checkbox,
|
||||||
|
.nav-item,
|
||||||
|
.list-row,
|
||||||
|
.fab,
|
||||||
|
.bottom-nav button,
|
||||||
|
.bottom-nav a {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
|
||||||
|
.scroll-area {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox: keep the 18 px visual but expand the tap area to 44 px */
|
||||||
|
.checkbox {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.checkbox::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -13px; /* 18 + 2×13 = 44 px tap target */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button on list rows: always visible on touch (no hover state) */
|
||||||
|
.list-row-delete {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape phone: compress bottom nav to icon-only to reclaim vertical space */
|
||||||
|
@media (max-width: 759px) and (orientation: landscape) and (max-height: 500px) {
|
||||||
|
.bottom-nav {
|
||||||
|
height: 44px;
|
||||||
|
padding: 2px 8px env(safe-area-inset-bottom, 2px);
|
||||||
|
}
|
||||||
|
.bottom-nav .nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
:where(html[data-nav="bottom"]) .scroll-area {
|
||||||
|
padding-bottom: 52px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const jetbrains = JetBrains_Mono({
|
|||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#1F1B16",
|
themeColor: "#1F1B16",
|
||||||
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -54,6 +55,11 @@ export const metadata: Metadata = {
|
|||||||
title: "famapp",
|
title: "famapp",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/icon-32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
{ url: "/icon-16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
],
|
||||||
apple: "/icon-180.png",
|
apple: "/icon-180.png",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { DashboardMeta } from "@/app/d/actions";
|
|||||||
import { Sidebar } from "@/components/sidebar";
|
import { Sidebar } from "@/components/sidebar";
|
||||||
import { Topbar } from "@/components/topbar";
|
import { Topbar } from "@/components/topbar";
|
||||||
import { BottomNav } from "@/components/bottom-nav";
|
import { BottomNav } from "@/components/bottom-nav";
|
||||||
import { Fab } from "@/components/fab";
|
|
||||||
import { NavModeProvider } from "@/components/nav-mode-provider";
|
import { NavModeProvider } from "@/components/nav-mode-provider";
|
||||||
|
|
||||||
const BARE_PREFIXES = ["/s/", "/login"];
|
const BARE_PREFIXES = ["/s/", "/login"];
|
||||||
@@ -38,7 +37,6 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
|
|||||||
<div className="scroll-area">{children}</div>
|
<div className="scroll-area">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
<Fab />
|
|
||||||
<NavModeProvider navStyle={navStyle} />
|
<NavModeProvider navStyle={navStyle} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
|
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
|
||||||
|
const iconSize = size === "sm" ? 14 : 17;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
f
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="12,2 1,12 23,12" />
|
||||||
|
<rect x="3.5" y="11" width="17" height="12" />
|
||||||
|
<rect x="9.5" y="16" width="5" height="7" rx="1" style={{ fill: "var(--ink)" }} />
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,20 +81,26 @@ function SectionHashSync({ pathname }: { pathname: string | null }) {
|
|||||||
export function SettingsTabsMobile({ active }: { active: SectionId }) {
|
export function SettingsTabsMobile({ active }: { active: SectionId }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}>
|
<div className="grid grid-cols-2 gap-1.5 mb-1">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
aria-current={active === s.id ? "page" : undefined}
|
||||||
aria-selected={active === s.id}
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-2.5 rounded-[var(--r-md)] border-[0.5px] text-left text-[13px] font-medium transition-colors",
|
||||||
|
active === s.id
|
||||||
|
? "bg-[var(--card)] border-[var(--hair-2)] text-[var(--ink)] shadow-[var(--shadow-1)]"
|
||||||
|
: "border-transparent text-[var(--ink-soft)] hover:bg-[var(--shade)]",
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("s", s.id);
|
url.searchParams.set("s", s.id);
|
||||||
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
|
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.label.split(" ")[0]}
|
<NavIcon name={s.icon} className="size-4 shrink-0" />
|
||||||
|
<span>{s.label.split(" ")[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { householdMembers, households, users } from "@/modules/_core/schema";
|
import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||||
@@ -13,7 +14,7 @@ export interface CurrentSession {
|
|||||||
|
|
||||||
export async function getCurrentSession(): Promise<CurrentSession> {
|
export async function getCurrentSession(): Promise<CurrentSession> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Not authenticated");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { consume } from "@/lib/rate-limit";
|
import { consume } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
|
const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"];
|
||||||
const PUBLIC_PATHS = new Set(["/login"]);
|
const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]);
|
||||||
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
||||||
|
|
||||||
// Token-prefix length used as part of the rate-limit bucket key.
|
// Token-prefix length used as part of the rate-limit bucket key.
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import FullCalendar from "@fullcalendar/react";
|
|||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
|
import listPlugin from "@fullcalendar/list";
|
||||||
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||||
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||||
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useEffect, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -44,6 +45,8 @@ type EventDraft = {
|
|||||||
remindMinutesBefore: number | null;
|
remindMinutesBefore: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MobileView = "day" | "week" | "agenda";
|
||||||
|
|
||||||
const DEFAULT_COLOR = "#B85C3C";
|
const DEFAULT_COLOR = "#B85C3C";
|
||||||
|
|
||||||
const VIEW_MAP: Record<CalView, string> = {
|
const VIEW_MAP: Record<CalView, string> = {
|
||||||
@@ -52,8 +55,13 @@ const VIEW_MAP: Record<CalView, string> = {
|
|||||||
day: "timeGridDay",
|
day: "timeGridDay",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
|
||||||
|
day: "timeGridDay",
|
||||||
|
week: "timeGridWeek",
|
||||||
|
agenda: "listWeek",
|
||||||
|
};
|
||||||
|
|
||||||
function withAlpha(hex: string, alpha: number): string {
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
|
|
||||||
if (!hex.startsWith("#")) return hex;
|
if (!hex.startsWith("#")) return hex;
|
||||||
let h = hex.slice(1);
|
let h = hex.slice(1);
|
||||||
if (h.length === 3)
|
if (h.length === 3)
|
||||||
@@ -88,6 +96,17 @@ export function CalendarShell({
|
|||||||
);
|
);
|
||||||
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
|
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("day");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(max-width: 759px)");
|
||||||
|
const apply = () => setIsMobile(mq.matches);
|
||||||
|
apply();
|
||||||
|
mq.addEventListener("change", apply);
|
||||||
|
return () => mq.removeEventListener("change", apply);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
|
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
|
||||||
? lastCalendarId
|
? lastCalendarId
|
||||||
@@ -289,17 +308,10 @@ export function CalendarShell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Shared calendar list + new-calendar form rendered in both the desktop aside
|
||||||
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
|
// and the mobile bottom-sheet panel.
|
||||||
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
|
const calendarsPanelContent = (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<>
|
||||||
<h1 className="text-xl font-semibold">Calendar</h1>
|
|
||||||
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
|
||||||
<Plus />
|
|
||||||
<span className="sr-only">New event</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{calendarRows.map((calendar) => (
|
{calendarRows.map((calendar) => (
|
||||||
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
||||||
@@ -333,9 +345,7 @@ export function CalendarShell({
|
|||||||
<Select
|
<Select
|
||||||
value={calendar.visibility}
|
value={calendar.visibility}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateCalendar(calendar, {
|
updateCalendar(calendar, { visibility: value as "private" | "household" })
|
||||||
visibility: value as "private" | "household",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
||||||
@@ -403,35 +413,12 @@ export function CalendarShell({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<section className="fc-skin min-w-0 p-4">
|
const fcEvents = visibleEvents.map((event) => {
|
||||||
<FullCalendar
|
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
||||||
initialView={VIEW_MAP[defaultView]}
|
|
||||||
headerToolbar={{
|
|
||||||
left: "prev,next today",
|
|
||||||
center: "title",
|
|
||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
|
||||||
}}
|
|
||||||
buttonText={{
|
|
||||||
today: "Today",
|
|
||||||
month: "Month",
|
|
||||||
week: "Week",
|
|
||||||
day: "Day",
|
|
||||||
}}
|
|
||||||
dayHeaderFormat={{ weekday: "short" }}
|
|
||||||
selectable
|
|
||||||
editable
|
|
||||||
eventResizableFromStart
|
|
||||||
select={handleDateSelect}
|
|
||||||
eventClick={openExistingEvent}
|
|
||||||
eventDrop={moveEvent}
|
|
||||||
eventResize={moveEvent}
|
|
||||||
events={visibleEvents.map((event) => {
|
|
||||||
const color =
|
const color =
|
||||||
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
|
||||||
DEFAULT_COLOR;
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@@ -443,42 +430,22 @@ export function CalendarShell({
|
|||||||
textColor: "var(--ink-2)",
|
textColor: "var(--ink-2)",
|
||||||
extendedProps: { calendarId: event.calendarId, color },
|
extendedProps: { calendarId: event.calendarId, color },
|
||||||
};
|
};
|
||||||
})}
|
});
|
||||||
eventClassNames={(arg) => {
|
|
||||||
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
|
||||||
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
|
||||||
}}
|
|
||||||
height="auto"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedEvent && (
|
// Shared event form content — rendered inside both the desktop centered modal
|
||||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
|
// and the mobile bottom-sheet.
|
||||||
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
|
const eventFormContent = selectedEvent && (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{selectedEvent.id ? "Edit event" : "New event"}
|
|
||||||
</h2>
|
|
||||||
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
|
|
||||||
<span aria-hidden>×</span>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label htmlFor="event-title">Title</Label>
|
<Label htmlFor="event-title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
id="event-title"
|
id="event-title"
|
||||||
value={selectedEvent.title}
|
value={selectedEvent.title}
|
||||||
onChange={(event) =>
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
|
||||||
setSelectedEvent({ ...selectedEvent, title: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="event-calendar">Calendar</Label>
|
<Label htmlFor="event-calendar">Calendar</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedEvent.calendarId}
|
value={selectedEvent.calendarId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
|
||||||
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="event-calendar">
|
<SelectTrigger id="event-calendar">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
@@ -512,9 +479,7 @@ export function CalendarShell({
|
|||||||
id="event-end"
|
id="event-end"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={selectedEvent.endAt}
|
value={selectedEvent.endAt}
|
||||||
onChange={(event) =>
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
|
||||||
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,18 +487,14 @@ export function CalendarShell({
|
|||||||
<Input
|
<Input
|
||||||
id="event-location"
|
id="event-location"
|
||||||
value={selectedEvent.location}
|
value={selectedEvent.location}
|
||||||
onChange={(event) =>
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
|
||||||
setSelectedEvent({ ...selectedEvent, location: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="event-notes">Notes</Label>
|
<Label htmlFor="event-notes">Notes</Label>
|
||||||
<textarea
|
<textarea
|
||||||
id="event-notes"
|
id="event-notes"
|
||||||
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
value={selectedEvent.notes}
|
value={selectedEvent.notes}
|
||||||
onChange={(event) =>
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
|
||||||
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{!selectedEvent.id && (
|
{!selectedEvent.id && (
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
@@ -569,6 +530,164 @@ export function CalendarShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
|
||||||
|
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
|
||||||
|
>
|
||||||
|
{/* Desktop aside — hidden on mobile */}
|
||||||
|
<aside
|
||||||
|
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Calendar</h1>
|
||||||
|
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
||||||
|
<Plus />
|
||||||
|
<span className="sr-only">New event</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{calendarsPanelContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Calendar section */}
|
||||||
|
<section className="fc-skin flex min-w-0 flex-col">
|
||||||
|
{/* Mobile toolbar: view switcher + calendars button + new event */}
|
||||||
|
{isMobile && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 border-b px-3 py-2"
|
||||||
|
style={{ borderColor: "var(--hair)" }}
|
||||||
|
>
|
||||||
|
<div className="seg flex-1">
|
||||||
|
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
aria-selected={mobileView === v}
|
||||||
|
onClick={() => setMobileView(v)}
|
||||||
|
>
|
||||||
|
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowCalendarsPanel(true)}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
Cals
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
|
||||||
|
<Plus size={14} />
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<FullCalendar
|
||||||
|
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
|
||||||
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
|
||||||
|
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
|
||||||
|
headerToolbar={
|
||||||
|
isMobile
|
||||||
|
? { left: "prev,next", center: "title", right: "today" }
|
||||||
|
: {
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
|
||||||
|
dayHeaderFormat={{ weekday: "short" }}
|
||||||
|
selectable={!isMobile}
|
||||||
|
editable={!isMobile}
|
||||||
|
eventResizableFromStart={!isMobile}
|
||||||
|
select={handleDateSelect}
|
||||||
|
eventClick={openExistingEvent}
|
||||||
|
eventDrop={moveEvent}
|
||||||
|
eventResize={moveEvent}
|
||||||
|
events={fcEvents}
|
||||||
|
eventClassNames={(arg) => {
|
||||||
|
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
||||||
|
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
||||||
|
}}
|
||||||
|
height="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Mobile: calendars bottom sheet */}
|
||||||
|
{isMobile && showCalendarsPanel && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[45] bg-black/40"
|
||||||
|
onClick={() => setShowCalendarsPanel(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
|
||||||
|
style={{
|
||||||
|
bottom: "var(--cal-nav-h)",
|
||||||
|
maxHeight: "72dvh",
|
||||||
|
background: "var(--card)",
|
||||||
|
paddingBottom: "env(safe-area-inset-bottom)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b p-4"
|
||||||
|
style={{ borderColor: "var(--hair)" }}
|
||||||
|
>
|
||||||
|
<h3 className="serif text-[16px] font-medium">Calendars</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-icon"
|
||||||
|
aria-label="Close calendars panel"
|
||||||
|
onClick={() => setShowCalendarsPanel(false)}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{calendarsPanelContent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex bg-black/20"
|
||||||
|
style={
|
||||||
|
isMobile
|
||||||
|
? { alignItems: "flex-end" }
|
||||||
|
: { alignItems: "center", justifyContent: "center" }
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setSelectedEvent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
|
||||||
|
style={{
|
||||||
|
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
|
||||||
|
width: isMobile ? "100%" : undefined,
|
||||||
|
maxWidth: isMobile ? undefined : "512px",
|
||||||
|
maxHeight: "90dvh",
|
||||||
|
padding: "1rem",
|
||||||
|
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{selectedEvent.id ? "Edit event" : "New event"}
|
||||||
|
</h2>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
|
||||||
|
<X size={16} />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{eventFormContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -194,17 +194,100 @@ function ListItemRow({
|
|||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const swipeStart = useRef<number | null>(null);
|
const swipeOrigin = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const isScrolling = useRef(false);
|
||||||
|
const [swipeX, setSwipeX] = useState(0);
|
||||||
|
|
||||||
|
const THRESHOLD = 80; // px to commit the swipe action
|
||||||
|
const MAX_SWIPE = 100; // px maximum visual displacement
|
||||||
|
|
||||||
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
swipeOrigin.current = { x: e.clientX, y: e.clientY };
|
||||||
|
isScrolling.current = false;
|
||||||
|
setSwipeX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!swipeOrigin.current) return;
|
||||||
|
const dx = e.clientX - swipeOrigin.current.x;
|
||||||
|
const dy = Math.abs(e.clientY - swipeOrigin.current.y);
|
||||||
|
// If the gesture is primarily vertical, treat as scroll and abort swipe.
|
||||||
|
if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) {
|
||||||
|
isScrolling.current = true;
|
||||||
|
setSwipeX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isScrolling.current) return;
|
||||||
|
setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (swipeOrigin.current && !isScrolling.current) {
|
||||||
|
const dx = e.clientX - swipeOrigin.current.x;
|
||||||
|
if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete
|
||||||
|
if (dx < -THRESHOLD) onRemove(); // left → delete
|
||||||
|
}
|
||||||
|
swipeOrigin.current = null;
|
||||||
|
setSwipeX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel() {
|
||||||
|
swipeOrigin.current = null;
|
||||||
|
setSwipeX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background tint that deepens as the swipe approaches the threshold.
|
||||||
|
const swipeProgress = Math.abs(swipeX) / MAX_SWIPE;
|
||||||
|
const swipeBg =
|
||||||
|
swipeX > 16
|
||||||
|
? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))`
|
||||||
|
: swipeX < -16
|
||||||
|
? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`list-row ${item.done ? "checked" : ""} group/row`}
|
className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
|
||||||
onPointerDown={(event) => {
|
style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
|
||||||
swipeStart.current = event.clientX;
|
onPointerDown={handlePointerDown}
|
||||||
}}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={handlePointerUp}
|
||||||
const start = swipeStart.current;
|
onPointerCancel={handlePointerCancel}
|
||||||
if (start !== null && event.clientX - start < -60) onRemove();
|
>
|
||||||
swipeStart.current = null;
|
{/* Swipe affordance icons revealed behind the sliding content */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-3 flex items-center"
|
||||||
|
aria-hidden
|
||||||
|
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 12l5 5L20 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center"
|
||||||
|
aria-hidden
|
||||||
|
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sliding content row */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${swipeX}px)`,
|
||||||
|
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -231,20 +314,22 @@ function ListItemRow({
|
|||||||
</button>
|
</button>
|
||||||
<Input
|
<Input
|
||||||
aria-label={`${item.text} text`}
|
aria-label={`${item.text} text`}
|
||||||
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
|
className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
|
||||||
value={item.text}
|
value={item.text}
|
||||||
onChange={(event) => onEdit(event.target.value)}
|
onChange={(event) => onEdit(event.target.value)}
|
||||||
onBlur={onCommit}
|
onBlur={onCommit}
|
||||||
/>
|
/>
|
||||||
|
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={`Delete ${item.text}`}
|
aria-label={`Delete ${item.text}`}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
|
className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||