commit a7cd81fe8151870b3594bd6b058c11c7e26c6a67 Author: root Date: Tue Feb 17 18:31:12 2026 +0100 Initial release: TidyQuest v0.1.0 TidyQuest is a self-hosted web application that gamifies household chores using RPG mechanics. Complete tasks, earn coins, unlock achievements, and compete with your family on the leaderboard. Features: - 🎯 Health-based task tracking with visual decay over time - 💰 Coin system with customizable rewards - 🔥 Daily/weekly streak tracking - 🏆 Family leaderboard (week/month/quarter/year) - 🎖️ Automatic achievement unlocking - 📅 Calendar view for upcoming tasks - 🌍 Multilingual support (EN/FR/DE/ES) - 📱 Optional Telegram notifications - 🏖️ Vacation mode (pause task decay) - 🐳 Docker deployment (single container) Tech Stack: - Frontend: React 19 + Vite + TypeScript - Backend: Node.js + Express + TypeScript - Database: SQLite3 with WAL mode - Auth: JWT + bcrypt - Deployment: Docker Compose Includes: - 8 room types with 60+ predefined tasks - 10 preset rewards - 12 achievement types - Complete API documentation - Security best practices guide - Comprehensive README with examples License: GNU Affero General Public License v3.0 (AGPL-3.0) This ensures TidyQuest remains free and open-source forever, preventing proprietary SaaS forks. Any modified hosted version must share source code. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c7892c --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# TidyQuest Environment Variables +# Copy this file to .env and fill in your values + +# Server Configuration +NODE_ENV=production +PORT=3000 + +# Security - REQUIRED in production +# Generate a secure random string (at least 32 characters) +# Example: openssl rand -base64 32 +JWT_SECRET=change-this-to-a-secure-random-string-min-32-chars + +# Database (handled by Docker volume, no config needed) +# Database file: ./data/tidyquest.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5b7ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +data/*.db +data/*.db-* +.env +*.log +.DS_Store +projet/ +PROGRESS.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..6f1c3f0 --- /dev/null +++ b/API.md @@ -0,0 +1,1053 @@ +# 🌐 TidyQuest API Documentation + +Complete reference for all TidyQuest REST API endpoints. + +**Base URL**: `http://localhost:3020/api` + +**Authentication**: All endpoints (except `/auth/*`) require a JWT token in the `Authorization` header: + +```http +Authorization: Bearer +``` + +--- + +## 📑 Table of Contents + +- [Authentication](#authentication) +- [Rooms](#rooms) +- [Tasks](#tasks) +- [Dashboard](#dashboard) +- [Leaderboard](#leaderboard) +- [History](#history) +- [Users](#users) +- [Rewards](#rewards) +- [Achievements](#achievements) +- [Data Export/Import](#data-exportimport) +- [Error Responses](#error-responses) + +--- + +## 🔐 Authentication + +### Register + +Create a new user account. + +**Endpoint**: `POST /api/auth/register` + +**Request Body**: +```json +{ + "username": "john", + "password": "securepass123", + "displayName": "John Doe", + "avatarColor": "#F97316", + "language": "en" +} +``` + +**Response** (201): +```json +{ + "user": { + "id": 1, + "username": "john", + "displayName": "John Doe", + "role": "admin", + "avatarColor": "#F97316", + "avatarType": "letter", + "coins": 0, + "currentStreak": 0, + "language": "en", + "createdAt": "2026-02-17T10:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "john", + "password": "securepass123", + "displayName": "John Doe", + "avatarColor": "#F97316", + "language": "en" + }' +``` + +--- + +### Login + +Authenticate and receive a JWT token. + +**Endpoint**: `POST /api/auth/login` + +**Request Body**: +```json +{ + "username": "john", + "password": "securepass123" +} +``` + +**Response** (200): +```json +{ + "user": { ... }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "john", "password": "securepass123"}' +``` + +--- + +### Get Current User + +Get authenticated user's profile. + +**Endpoint**: `GET /api/auth/me` + +**Headers**: `Authorization: Bearer ` + +**Response** (200): +```json +{ + "id": 1, + "username": "john", + "displayName": "John Doe", + "role": "admin", + "avatarColor": "#F97316", + "avatarType": "letter", + "avatarPreset": null, + "avatarPhotoUrl": null, + "coins": 150, + "currentStreak": 7, + "goalCoins": 500, + "goalStartAt": "2026-02-01T00:00:00.000Z", + "goalEndAt": "2026-02-28T23:59:59.000Z", + "isVacationMode": false, + "language": "en", + "createdAt": "2026-01-15T08:00:00.000Z" +} +``` + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/auth/me \ + -H "Authorization: Bearer " +``` + +--- + +## 🏠 Rooms + +### List All Rooms + +**Endpoint**: `GET /api/rooms` + +**Response** (200): +```json +[ + { + "id": 1, + "name": "Kitchen", + "roomType": "kitchen", + "color": "#FFE4C4", + "accentColor": "#FFA500", + "photoUrl": null, + "sortOrder": 1, + "health": 75, + "createdAt": "2026-02-10T12:00:00.000Z" + } +] +``` + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/rooms \ + -H "Authorization: Bearer " +``` + +--- + +### Get Room by ID + +**Endpoint**: `GET /api/rooms/:id` + +**Response** (200): +```json +{ + "id": 1, + "name": "Kitchen", + "roomType": "kitchen", + "color": "#FFE4C4", + "accentColor": "#FFA500", + "photoUrl": null, + "sortOrder": 1, + "createdAt": "2026-02-10T12:00:00.000Z" +} +``` + +--- + +### Create Room + +**Endpoint**: `POST /api/rooms` (Admin only) + +**Request Body**: +```json +{ + "name": "Master Bedroom", + "roomType": "bedroom", + "color": "#E6F3FF", + "accentColor": "#4A90E2" +} +``` + +**Response** (201): +```json +{ + "id": 5, + "name": "Master Bedroom", + "roomType": "bedroom", + ... +} +``` + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/rooms \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Master Bedroom", + "roomType": "bedroom", + "color": "#E6F3FF", + "accentColor": "#4A90E2" + }' +``` + +--- + +### Update Room + +**Endpoint**: `PUT /api/rooms/:id` + +**Request Body**: +```json +{ + "name": "Main Kitchen", + "sortOrder": 2 +} +``` + +**Response** (200): +```json +{ + "id": 1, + "name": "Main Kitchen", + "sortOrder": 2, + ... +} +``` + +--- + +### Delete Room + +**Endpoint**: `DELETE /api/rooms/:id` (Admin only) + +**Response** (200): +```json +{ + "success": true +} +``` + +--- + +### Get Default Tasks for Room Type + +**Endpoint**: `GET /api/rooms/defaults/:roomType` + +Available room types: `kitchen`, `bedroom`, `bathroom`, `living`, `office`, `garage`, `laundry`, `garden` + +**Response** (200): +```json +[ + { + "name": "Wash Dishes", + "frequencyDays": 1, + "effort": 2, + "translationKey": "kitchen.wash_dishes", + "iconKey": "wash_dishes" + }, + { + "name": "Clean Fridge", + "frequencyDays": 14, + "effort": 3, + "translationKey": "kitchen.clean_fridge", + "iconKey": "fridge" + } +] +``` + +--- + +## ✅ Tasks + +### List Tasks for a Room + +**Endpoint**: `GET /api/rooms/:roomId/tasks` + +**Response** (200): +```json +[ + { + "id": 1, + "roomId": 1, + "name": "Wash Dishes", + "notes": "Use dishwasher for big loads", + "frequencyDays": 1, + "effort": 2, + "health": 85, + "lastCompletedAt": "2026-02-17T08:00:00.000Z", + "translationKey": "kitchen.wash_dishes", + "iconKey": "wash_dishes", + "createdAt": "2026-02-10T12:00:00.000Z" + } +] +``` + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/rooms/1/tasks \ + -H "Authorization: Bearer " +``` + +--- + +### Create Task + +**Endpoint**: `POST /api/rooms/:roomId/tasks` (Admin only) + +**Request Body**: +```json +{ + "name": "Deep Clean Oven", + "notes": "Use oven cleaner spray", + "frequencyDays": 30, + "effort": 5, + "translationKey": "kitchen.deep_clean_oven", + "iconKey": "oven" +} +``` + +**Response** (201): +```json +{ + "id": 15, + "roomId": 1, + "name": "Deep Clean Oven", + ... +} +``` + +--- + +### Update Task + +**Endpoint**: `PUT /api/tasks/:id` (Admin only) + +**Request Body**: +```json +{ + "frequencyDays": 7, + "effort": 3 +} +``` + +**Response** (200): +```json +{ + "id": 15, + "frequencyDays": 7, + "effort": 3, + ... +} +``` + +--- + +### Delete Task + +**Endpoint**: `DELETE /api/tasks/:id` (Admin only) + +**Response** (200): +```json +{ + "success": true +} +``` + +--- + +### Complete Task + +Mark a task as completed, earn coins, update streak. + +**Endpoint**: `POST /api/tasks/:id/complete` + +**Response** (200): +```json +{ + "completion": { + "id": 123, + "taskId": 1, + "userId": 1, + "completedAt": "2026-02-17T14:30:00.000Z", + "coinsEarned": 10 + }, + "user": { + "id": 1, + "coins": 160, + "currentStreak": 8, + "lastActiveDate": "2026-02-17", + ... + }, + "newAchievements": [ + { + "id": "streak_7", + "name": "On Fire", + "description": "Complete tasks for 7 days in a row", + "icon": "🔥" + } + ] +} +``` + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/tasks/1/complete \ + -H "Authorization: Bearer " +``` + +--- + +## 📊 Dashboard + +Get aggregated view: house health, today's quests, upcoming tasks, recent activity, goals, pending rewards. + +**Endpoint**: `GET /api/dashboard` + +**Response** (200): +```json +{ + "houseHealth": 78, + "todayQuests": { + "completed": 5, + "total": 12 + }, + "nextTasks": [ + { + "id": 3, + "name": "Vacuum Living Room", + "roomName": "Living Room", + "health": 45, + "effort": 3, + "dueIn": "2 hours" + } + ], + "recentActivity": [ + { + "id": 120, + "taskName": "Wash Dishes", + "userName": "John Doe", + "completedAt": "2026-02-17T14:00:00.000Z", + "coinsEarned": 10 + } + ], + "myGoals": [ + { + "id": 1, + "title": "February Goal", + "goalCoins": 500, + "currentCoins": 160, + "progress": 32, + "startAt": "2026-02-01T00:00:00.000Z", + "endAt": "2026-02-28T23:59:59.000Z" + } + ], + "pendingRewardRequests": 2 +} +``` + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/dashboard \ + -H "Authorization: Bearer " +``` + +--- + +## 🏆 Leaderboard + +Get family rankings by period. + +**Endpoint**: `GET /api/leaderboard?period=` + +**Query Params**: +- `period`: `week`, `month`, `quarter`, or `year` + +**Response** (200): +```json +{ + "period": "week", + "leaderboard": [ + { + "userId": 1, + "displayName": "John Doe", + "avatarColor": "#F97316", + "avatarType": "letter", + "avatarPreset": null, + "avatarPhotoUrl": null, + "coinsEarned": 150, + "tasksCompleted": 25, + "rank": 1 + }, + { + "userId": 2, + "displayName": "Jane Doe", + "coinsEarned": 120, + "tasksCompleted": 20, + "rank": 2 + } + ] +} +``` + +**cURL Example**: +```bash +curl -X GET "http://localhost:3020/api/leaderboard?period=week" \ + -H "Authorization: Bearer " +``` + +--- + +## 📜 History + +Get paginated activity log of all task completions. + +**Endpoint**: `GET /api/history?limit=&offset=` + +**Query Params**: +- `limit`: Number of records (default: 20) +- `offset`: Skip N records (default: 0) + +**Response** (200): +```json +{ + "history": [ + { + "id": 120, + "taskName": "Wash Dishes", + "roomName": "Kitchen", + "userName": "John Doe", + "completedAt": "2026-02-17T14:00:00.000Z", + "coinsEarned": 10 + } + ], + "total": 500, + "limit": 20, + "offset": 0 +} +``` + +**cURL Example**: +```bash +curl -X GET "http://localhost:3020/api/history?limit=50&offset=100" \ + -H "Authorization: Bearer " +``` + +--- + +## 👥 Users + +### List All Users + +**Endpoint**: `GET /api/users` (Admin only) + +**Response** (200): +```json +[ + { + "id": 1, + "username": "john", + "displayName": "John Doe", + "role": "admin", + "coins": 160, + "currentStreak": 8, + ... + } +] +``` + +--- + +### Create User + +**Endpoint**: `POST /api/users` (Admin only) + +**Request Body**: +```json +{ + "username": "alice", + "password": "alicepass", + "displayName": "Alice", + "role": "child", + "avatarColor": "#FFB6C1", + "language": "en" +} +``` + +**Response** (201): +```json +{ + "id": 3, + "username": "alice", + "displayName": "Alice", + "role": "child", + ... +} +``` + +--- + +### Update User Profile + +**Endpoint**: `PUT /api/users/:id/profile` + +**Request Body**: +```json +{ + "displayName": "John Smith", + "avatarType": "character", + "avatarPreset": "cat", + "language": "fr" +} +``` + +**Response** (200): +```json +{ + "id": 1, + "displayName": "John Smith", + "avatarType": "character", + "avatarPreset": "cat", + ... +} +``` + +--- + +### Upload Avatar Photo + +**Endpoint**: `POST /api/users/:id/avatar-upload` + +**Headers**: +- `Content-Type: multipart/form-data` + +**Request Body**: +``` +FormData: + - avatar: (image/*, max 2MB) +``` + +**Response** (200): +```json +{ + "id": 1, + "avatarType": "photo", + "avatarPhotoUrl": "/api/avatars/user-1-1708176000000.jpg", + ... +} +``` + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/users/1/avatar-upload \ + -H "Authorization: Bearer " \ + -F "avatar=@/path/to/photo.jpg" +``` + +--- + +### Update User Settings + +**Endpoint**: `PUT /api/users/:id/settings` (Admin only, self only) + +**Request Body**: +```json +{ + "isVacationMode": true, + "language": "es" +} +``` + +**Response** (200): +```json +{ + "id": 1, + "isVacationMode": true, + "vacationStartDate": "2026-02-17T15:00:00.000Z", + "language": "es", + ... +} +``` + +--- + +### Get Coins Configuration + +**Endpoint**: `GET /api/users/coins-config` + +**Response** (200): +```json +{ + "coinsByEffort": { + "1": 5, + "2": 10, + "3": 15, + "4": 20, + "5": 25 + } +} +``` + +--- + +### Update Coins Configuration + +**Endpoint**: `PUT /api/users/coins-config` (Admin only) + +**Request Body**: +```json +{ + "coinsByEffort": { + "1": 10, + "2": 20, + "3": 30, + "4": 40, + "5": 50 + } +} +``` + +Or reset to defaults: +```json +{ + "useDefault": true +} +``` + +**Response** (200): +```json +{ + "coinsByEffort": { ... } +} +``` + +--- + +## 🎁 Rewards + +### List Rewards + +**Endpoint**: `GET /api/rewards` + +**Response** (200): +```json +{ + "rewards": [ + { + "id": 1, + "title": "Movie Night Pick", + "description": "Choose the family movie", + "costCoins": 40, + "isActive": true, + "isPreset": true, + "createdBy": null, + "createdAt": "2026-02-10T00:00:00.000Z" + } + ], + "myRedemptions": [ + { + "id": 10, + "rewardId": 1, + "rewardTitle": "Movie Night Pick", + "costCoins": 40, + "status": "requested", + "redeemedAt": "2026-02-17T18:00:00.000Z" + } + ] +} +``` + +--- + +### Create Reward + +**Endpoint**: `POST /api/rewards` (Admin only) + +**Request Body**: +```json +{ + "title": "Extra Dessert", + "description": "Second serving at dinner", + "costCoins": 25 +} +``` + +**Response** (201): +```json +{ + "id": 8, + "title": "Extra Dessert", + "costCoins": 25, + "isActive": true, + "createdBy": 1, + ... +} +``` + +--- + +### Redeem Reward + +**Endpoint**: `POST /api/rewards/:id/redeem` + +**Response** (200): +```json +{ + "redemption": { + "id": 12, + "rewardId": 1, + "userId": 2, + "costCoins": 40, + "status": "requested", + "redeemedAt": "2026-02-17T19:00:00.000Z" + }, + "newCoinsBalance": 110 +} +``` + +**Errors**: +- `400`: Insufficient coins +- `404`: Reward not found or inactive + +--- + +### Approve/Reject Redemption + +**Endpoint**: `PUT /api/rewards/redemptions/:id` (Admin only) + +**Request Body**: +```json +{ + "status": "approved" +} +``` + +Status options: `approved`, `rejected` + +**Response** (200): +```json +{ + "id": 12, + "status": "approved", + ... +} +``` + +**Note**: If rejected, coins are refunded to user. + +--- + +### Cancel Redemption + +**Endpoint**: `POST /api/rewards/redemptions/:id/cancel` (Self only) + +**Response** (200): +```json +{ + "id": 12, + "status": "cancelled", + ... +} +``` + +**Note**: Coins are refunded. + +--- + +## 🎖️ Achievements + +### Get Achievements + +**Endpoint**: `GET /api/achievements` + +**Response** (200): +```json +{ + "myAchievements": [ + { + "id": "tasks_10", + "name": "Cleaner", + "description": "Complete 10 tasks", + "icon": "✨", + "unlockedAt": "2026-02-15T10:00:00.000Z" + } + ], + "allAchievements": [ + { + "id": "tasks_1", + "name": "Starter", + "description": "Complete your first task", + "icon": "🌟", + "unlocked": true + }, + { + "id": "streak_30", + "name": "Legend", + "description": "Maintain a 30-day streak", + "icon": "👑", + "unlocked": false + } + ] +} +``` + +**Note**: `allAchievements` only returned for admins. + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/achievements \ + -H "Authorization: Bearer " +``` + +--- + +## 📤 Data Export/Import + +### Export Data + +**Endpoint**: `GET /api/data/export` (Admin only) + +**Response** (200): +```json +{ + "version": "1.0", + "exportedAt": "2026-02-17T20:00:00.000Z", + "users": [...], + "rooms": [...], + "tasks": [...], + "taskCompletions": [...], + "rewards": [...], + "rewardRedemptions": [...], + "appSettings": [...] +} +``` + +**cURL Example**: +```bash +curl -X GET http://localhost:3020/api/data/export \ + -H "Authorization: Bearer " \ + -o backup.json +``` + +--- + +### Import Data + +**Endpoint**: `POST /api/data/import` (Admin only) + +**Request Body**: Full JSON export (same structure as export) + +**Response** (200): +```json +{ + "success": true, + "imported": { + "users": 3, + "rooms": 5, + "tasks": 42, + "taskCompletions": 250, + "rewards": 10, + "rewardRedemptions": 15 + } +} +``` + +**Warning**: This will CLEAR and REPLACE all data. + +**cURL Example**: +```bash +curl -X POST http://localhost:3020/api/data/import \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d @backup.json +``` + +--- + +## ❌ Error Responses + +All errors follow this format: + +```json +{ + "error": "Descriptive error message" +} +``` + +**Common HTTP Status Codes**: + +| Code | Meaning | Example | +|------|---------|---------| +| 400 | Bad Request | Missing required field | +| 401 | Unauthorized | Invalid or missing JWT token | +| 403 | Forbidden | Not admin or not owner | +| 404 | Not Found | Resource doesn't exist | +| 409 | Conflict | Username already exists | +| 500 | Server Error | Database or internal error | + +--- + +## 🔔 Rate Limiting + +Currently **no rate limiting** is implemented. For production deployments, consider using a reverse proxy (Nginx, Caddy) with rate limiting configured. + +--- + +## 📝 Notes + +- All timestamps are in ISO 8601 format (UTC) +- JWT tokens expire after **30 days** +- Pagination uses `limit` and `offset` query parameters +- File uploads (avatars) limited to **2MB** +- Accepted image types: `image/*` (MIME type check) + +--- + +**Need help?** Open an issue on [GitHub Issues](https://github.com/mellow-fox/TidyQuest/issues) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4645b87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS frontend-build +WORKDIR /app/client +COPY client/package*.json ./ +RUN npm ci +COPY client/ ./ +RUN npm run build + +FROM node:22-alpine AS server-build +WORKDIR /app/server +COPY server/package*.json ./ +RUN npm ci +COPY server/ ./ +RUN npx tsc + +FROM node:22-alpine +WORKDIR /app +COPY server/package*.json ./server/ +RUN cd server && npm ci --omit=dev +COPY --from=server-build /app/server/dist ./server/dist +COPY --from=frontend-build /app/client/dist ./client/dist +RUN mkdir -p /app/data +EXPOSE 3000 +CMD ["node", "server/dist/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cec57c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,679 @@ +TidyQuest - Household Chore Gamification App +Copyright (C) 2026 TidyQuest Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +================================================================================ + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8220b5 --- /dev/null +++ b/README.md @@ -0,0 +1,287 @@ +# 🏠 TidyQuest + +> Transform household chores into an epic family adventure + +**TidyQuest** is a self-hosted web application that gamifies housework using RPG mechanics. Complete tasks, earn coins, unlock achievements, and compete with your family on the leaderboard. + +![Version](https://img.shields.io/badge/version-0.1.0-orange.svg) +![Docker](https://img.shields.io/badge/docker-ready-brightgreen.svg) +![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) + +--- + +## ✨ What is TidyQuest? + +TidyQuest turns boring chores into quests: +- **🎯 Health Bars**: Each task has a visual health indicator that decays over time +- **💰 Coins & Rewards**: Earn coins by completing tasks, redeem for family rewards +- **🔥 Streaks**: Build daily/weekly streaks to stay motivated +- **🏆 Leaderboard**: Compete with family members for top position +- **🎖️ Achievements**: Unlock badges for milestones (100 tasks, 30-day streak, etc.) +- **📅 Calendar View**: See upcoming due dates at a glance +- **🌍 Multilingual**: English, French, German, Spanish +- **📱 Telegram Notifications**: Optional reminders for due tasks and rewards + +Perfect for families who want to: +- Make chores fun for kids +- Track household responsibilities +- Encourage teamwork with gamification +- Build consistent cleaning habits + +--- + +## 🚀 Quick Start + +### Prerequisites +- Docker & Docker Compose +- 100MB disk space +- Port 3020 available + +### Installation (3 steps) + +1. **Clone the repository** + ```bash + git clone https://github.com/mellow-fox/TidyQuest.git + cd TidyQuest + ``` + +2. **Configure environment** + ```bash + cp .env.example .env + # Edit .env and set a secure JWT_SECRET + # Generate one: openssl rand -base64 32 + ``` + +3. **Launch with Docker** + ```bash + docker compose up -d --build + ``` + + Access at **http://localhost:3020** + +### First Login +On first launch, the database is empty. Create an admin account via the **Register** page. + +--- + +## 🎮 Features + +### For Everyone +- ✅ **Complete Tasks**: Mark chores as done, earn coins +- 💎 **Redeem Rewards**: Request rewards like "Movie Night Pick" or "Extra Game Time" +- 📊 **Track Progress**: See your current streak, coins, and achievements +- 🏠 **Room Management**: Organize tasks by room (Kitchen, Bedroom, Bathroom, etc.) + +### For Admins +- 👥 **User Management**: Create family members (admin/member/child roles) +- 📝 **Task CRUD**: Create, edit, delete tasks with custom frequencies (1-365 days) +- 🎁 **Reward System**: Approve/reject reward requests, manage catalog +- ⚙️ **Global Settings**: Configure coins-per-effort, Telegram notifications +- 🏖️ **Vacation Mode**: Pause task health decay during family vacations +- 📤 **Backup/Restore**: Export full database as JSON + +### Built-in Defaults +- **8 Room Types**: Kitchen, Bedroom, Bathroom, Living Room, Office, Garage, Laundry, Garden +- **60+ Predefined Tasks**: Common household chores with realistic frequencies +- **10 Preset Rewards**: Movie night, ice cream, stay up late, game time, etc. +- **12 Achievements**: Unlocked automatically based on activity + +--- + +## 🛠️ Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Frontend** | React 19 + Vite + TypeScript | +| **Backend** | Node.js + Express + TypeScript | +| **Database** | SQLite3 (with WAL mode) | +| **Auth** | JWT + bcrypt | +| **Deployment** | Docker (single container, ~300MB) | +| **Routing** | React Router v7 | +| **Styling** | Custom CSS (no framework) | + +**Zero external dependencies** for core functionality. Optional Telegram integration for notifications. + +--- + +## 📂 Project Structure + +``` +TidyQuest/ +├── client/ # React frontend +│ ├── src/ +│ │ ├── components/ # UI components +│ │ ├── hooks/ # useAuth, useApi, useTranslation +│ │ ├── i18n/ # Translation files (EN/FR/DE/ES) +│ │ └── App.tsx +│ └── package.json +├── server/ # Express backend +│ ├── src/ +│ │ ├── routes/ # API endpoints +│ │ ├── middleware/ # JWT auth +│ │ ├── utils/ # Health calculation, notifications +│ │ └── database.ts # SQLite setup +│ └── package.json +├── data/ # Persistent storage (Docker volume) +│ ├── tidyquest.db # SQLite database +│ └── avatars/ # User-uploaded photos +├── Dockerfile # Multi-stage build +├── docker-compose.yml +├── .env.example # Configuration template +├── SECURITY.md # Security best practices +└── API.md # API documentation +``` + +--- + +## 🔧 Configuration + +### Environment Variables + +See `.env.example` for all options. Key variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `JWT_SECRET` | **Yes** (prod) | Secret for signing JWT tokens (min 32 chars) | +| `NODE_ENV` | No | `production` or `development` | +| `PORT` | No | Server port (default: 3000) | + +### Telegram Notifications (Optional) + +1. Create a Telegram bot via [@BotFather](https://t.me/botfather) +2. Get your chat ID via [@userinfobot](https://t.me/userinfobot) +3. Configure in app Settings page (admin only) + +Notification types: +- 🕐 **Daily Due Tasks**: Sent at configured time (default 09:00) +- 🎁 **Reward Requests**: Notify admin when child requests reward +- 🎖️ **Achievements**: Celebrate unlocked achievements + +--- + +## 📖 Usage + +### Creating Rooms +1. Go to **Rooms** page +2. Click **Add Room** +3. Select room type (e.g., Kitchen) +4. Choose from 60+ default tasks or create custom ones + +### Completing Tasks +1. Open a room +2. Click ✅ on a task row +3. Earn coins (5-25 based on effort level 1-5) +4. Health bar resets to 100% + +### Redeeming Rewards +1. Go to **Rewards** page +2. Click **Redeem** on a reward (coins deducted immediately) +3. Admin approves/rejects request +4. If rejected, coins are refunded + +### Vacation Mode +Admin can enable in **Settings** to pause all health decay. Useful for family trips. + +--- + +## 🔒 Security + +**Before exposing publicly**, review `SECURITY.md` for: +- Setting strong JWT_SECRET +- Using HTTPS reverse proxy (Caddy/Nginx) +- Database backup strategy +- Telegram token protection + +⚠️ **Never expose TidyQuest directly to the internet over HTTP**. + +--- + +## 🔄 Updating + +```bash +cd TidyQuest +git pull +docker compose down +docker compose up -d --build +``` + +Your data persists in the `./data` volume. + +--- + +## 🗄️ Backup & Restore + +### Manual Backup (via UI) +Admin → Settings → Export Data → Download JSON + +### File System Backup +```bash +cp data/tidyquest.db backups/tidyquest-$(date +%Y%m%d).db +``` + +### Restore +Admin → Settings → Import Data → Upload JSON + +--- + +## 🌐 API Documentation + +See `API.md` for full endpoint documentation with examples. + +Quick overview: +- **Auth**: `/api/auth/register`, `/api/auth/login` +- **Rooms**: `/api/rooms` (CRUD) +- **Tasks**: `/api/rooms/:id/tasks` (CRUD + complete) +- **Dashboard**: `/api/dashboard` (aggregated view) +- **Leaderboard**: `/api/leaderboard?period=week|month|quarter|year` +- **Rewards**: `/api/rewards` (CRUD + redeem) +- **Achievements**: `/api/achievements` + +All endpoints require `Authorization: Bearer ` (except auth routes). + +--- + +## 🤝 Contributing + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +--- + +## 📜 License + +This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. + +**What this means:** +- ✅ You can use, modify, and distribute TidyQuest freely +- ✅ You can run it commercially (for your business/family) +- ⚠️ If you modify and **host** TidyQuest as a service (even privately), you **must** share your source code +- ⚠️ Any derivative work must also be licensed under AGPL-3.0 + +**Why AGPL?** To ensure TidyQuest remains free and open-source forever, preventing proprietary SaaS forks. + +See the `LICENSE` file for full details. + +--- + +## 🙏 Acknowledgments + +- Inspired by RPG mechanics from classic games +- Built with love for families who want to make chores fun +- Uses [Nunito](https://fonts.google.com/specimen/Nunito) font (bundled locally) + +--- + +## 📞 Support + +- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/mellow-fox/TidyQuest/issues) +- 💬 **Discussions**: [GitHub Discussions](https://github.com/mellow-fox/TidyQuest/discussions) +- 🔒 **Security Issues**: See `SECURITY.md` for responsible disclosure + +--- + +Made with ❤️ for families everywhere diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c947b90 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,167 @@ +# Security Policy + +## 🔒 Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + +--- + +## 🛡️ Security Best Practices + +### Before Deploying to Production + +#### 1. **Set a Strong JWT Secret** + +**⚠️ CRITICAL**: The JWT secret is used to sign authentication tokens. A weak or default secret compromises all user sessions. + +**How to set it:** +```bash +# Generate a secure random secret +openssl rand -base64 32 + +# Add to .env file +echo "JWT_SECRET=" > .env +``` + +**In docker-compose.yml:** +```yaml +environment: + - JWT_SECRET=${JWT_SECRET} +``` + +**Never commit your `.env` file to version control.** + +--- + +#### 2. **Use HTTPS with a Reverse Proxy** + +TidyQuest should **never** be exposed directly to the internet over HTTP. + +**Recommended setup:** +- Use Caddy, Nginx, or Traefik as a reverse proxy +- Obtain a valid TLS certificate (Let's Encrypt) +- Forward port 443 → TidyQuest container port 3000 + +**Example with Caddy:** +``` +tidyquest.yourdomain.com { + reverse_proxy localhost:3020 +} +``` + +--- + +#### 3. **Telegram Bot Token Protection** + +**⚠️ Known Risk**: Telegram bot tokens are stored **unencrypted** in the SQLite database. + +**Mitigation:** +- Restrict file system access to the `./data/` directory +- Use proper file permissions: `chmod 600 data/tidyquest.db` +- Never expose the database file publicly +- Regularly back up and encrypt database backups + +**Future improvement (planned for v0.2)**: Encrypt sensitive settings using AES-256. + +--- + +#### 4. **Database Backups** + +**Location**: `./data/tidyquest.db` + +**Backup strategy:** +```bash +# Manual backup +cp data/tidyquest.db backups/tidyquest-$(date +%Y%m%d).db + +# Automated daily backup (crontab) +0 3 * * * cd /path/to/tidyquest && cp data/tidyquest.db backups/tidyquest-$(date +\%Y\%m\%d).db +``` + +**Encrypt backups before storing offsite:** +```bash +gpg -c backups/tidyquest-20260217.db +``` + +--- + +#### 5. **User Avatar Uploads** + +**Current validation:** +- MIME type check: only `image/*` allowed +- File size limit: 2MB + +**Additional hardening (recommended):** +- Serve avatars from a separate domain/subdomain +- Implement Content-Security-Policy headers +- Consider using image processing library to re-encode uploads + +--- + +#### 6. **Network Exposure** + +**Default setup (Docker Compose):** +- Port `3020:3000` is exposed on all interfaces (`0.0.0.0`) + +**For local-only access**, bind to localhost: +```yaml +ports: + - "127.0.0.1:3020:3000" +``` + +**For VPN/LAN access**, ensure firewall rules are configured. + +--- + +## 🚨 Reporting a Vulnerability + +If you discover a security vulnerability in TidyQuest, please: + +1. **Do NOT open a public GitHub issue** +2. Email the maintainer directly (see GitHub profile) +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if applicable) + +**Response time**: We aim to acknowledge reports within 48 hours and provide a fix within 7 days for critical issues. + +--- + +## 📋 Security Checklist Before Going Public + +- [ ] JWT_SECRET set via environment variable (not default value) +- [ ] HTTPS reverse proxy configured +- [ ] Database file permissions restricted (`chmod 600`) +- [ ] Regular backup strategy in place +- [ ] Firewall rules configured (only allow necessary ports) +- [ ] Docker image updated to latest version +- [ ] OS and dependencies up to date +- [ ] Telegram bot token kept secret (if using notifications) +- [ ] `.env` file in `.gitignore` (verified not committed) + +--- + +## 🔐 Authentication & Session Management + +- **Algorithm**: JWT (JSON Web Tokens) +- **Token expiry**: 30 days +- **Password hashing**: bcrypt (10 rounds) +- **Authorization**: Role-based (admin, member, child) + +**Session invalidation**: Currently, tokens remain valid until expiry. Manual revocation is not implemented (planned for v0.2). + +--- + +## 📚 Additional Resources + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [Docker Security Best Practices](https://docs.docker.com/engine/security/) +- [SQLite Security Considerations](https://www.sqlite.org/security.html) + +--- + +**Last updated**: 2026-02-17 diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/client/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..060b4d0 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + TidyQuest + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..3b2fa4b --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,3315 @@ +{ + "name": "client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@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.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..8eb8cd7 --- /dev/null +++ b/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..f6ae229 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..6e1f8e9 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,382 @@ +import { useState, useEffect, useCallback } from 'react'; +import { BrowserRouter, Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom'; +import { useAuth } from './hooks/useAuth'; +import { api } from './hooks/useApi'; +import { Sidebar } from './components/layout/Sidebar'; +import { PageHeader } from './components/layout/PageHeader'; +import { ConfettiEffect } from './components/shared/ConfettiEffect'; +import Dashboard from './components/pages/Dashboard'; +import { RoomsList } from './components/pages/RoomsList'; +import { RoomDetail } from './components/pages/RoomDetail'; +import { Calendar } from './components/pages/Calendar'; +import { Leaderboard } from './components/pages/Leaderboard'; +import { History } from './components/pages/History'; +import { Settings } from './components/pages/Settings'; +import { Profile } from './components/pages/Profile'; +import { Login } from './components/pages/Login'; +import { Register } from './components/pages/Register'; +import { Achievements } from './components/pages/Achievements'; +import { Rewards } from './components/pages/Rewards'; +import { useTranslation } from './hooks/useTranslation'; + +function AppContent() { + const { user, loading, login, register, logout, refreshUser } = useAuth(); + const browserLang = typeof navigator !== 'undefined' ? navigator.language.slice(0, 2) : 'en'; + const { t: tBrowser } = useTranslation(browserLang); + const [authView, setAuthView] = useState<'login' | 'register'>('login'); + const [dashboardData, setDashboardData] = useState(null); + const [rooms, setRooms] = useState([]); + const [family, setFamily] = useState([]); + const [familySettings, setFamilySettings] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [leaderboardPeriod, setLeaderboardPeriod] = useState<'week' | 'month' | 'quarter' | 'year'>('week'); + const [completions, setCompletions] = useState([]); + const [coinsByEffort, setCoinsByEffort] = useState>({ 1: 5, 2: 10, 3: 15, 4: 20, 5: 25 }); + const [achievementsData, setAchievementsData] = useState(null); + const [rewardsData, setRewardsData] = useState<{ rewards: any[]; mine: any[] }>({ rewards: [], mine: [] }); + const [theme, setTheme] = useState<'orange' | 'blue' | 'rose' | 'night'>(() => { + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('tidyquest_theme') : null; + return (saved === 'blue' || saved === 'rose' || saved === 'night') ? saved : 'orange'; + }); + const [confetti, setConfetti] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + + const loadDashboard = useCallback(async () => { + try { + const [dash, lb, hist, users, coinCfg, ach] = await Promise.all([ + api.dashboard(), + api.leaderboard(leaderboardPeriod), + api.history(50, 0), + api.getUsers(), + api.getCoinsConfig(), + api.achievements(), + ]); + setDashboardData(dash); + setRooms(await api.getRooms()); + setLeaderboard(lb); + setFamily(lb); + setFamilySettings(users); + setCompletions(hist.history || []); + setCoinsByEffort(coinCfg.coinsByEffort || { 1: 5, 2: 10, 3: 15, 4: 20, 5: 25 }); + setAchievementsData(ach); + } catch { /* not logged in */ } + }, [leaderboardPeriod]); + + useEffect(() => { + if (user) loadDashboard(); + }, [user, loadDashboard]); + + // Refresh data when navigating between pages + useEffect(() => { + if (user) { + loadDashboard(); + loadRewards(); + } + }, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps + + const loadRewards = useCallback(async () => { + try { + const data = await api.getRewards(); + setRewardsData(data); + } catch { + setRewardsData({ rewards: [], mine: [] }); + } + }, []); + + useEffect(() => { + if (user) loadRewards(); + }, [user, loadRewards]); + + useEffect(() => { + const html = document.documentElement; + if (theme === 'orange') { + html.removeAttribute('data-theme'); + } else { + html.setAttribute('data-theme', theme); + } + localStorage.setItem('tidyquest_theme', theme); + }, [theme]); + + useEffect(() => { + document.title = 'TidyQuest'; + }, []); + + useEffect(() => { + if (user && (location.pathname === '/login' || location.pathname === '/register')) { + navigate('/', { replace: true }); + } + }, [user, location.pathname, navigate]); + + const handleCompleteTask = async (taskId: number) => { + try { + setConfetti(true); + await api.completeTask(taskId); + await refreshUser(); + await loadDashboard(); + setTimeout(() => setConfetti(false), 2200); + } catch (err) { + setConfetti(false); + console.error('Failed to complete task:', err); + } + }; + + const handleLeaderboardPeriodChange = async (period: 'week' | 'month' | 'quarter' | 'year') => { + setLeaderboardPeriod(period); + const lb = await api.leaderboard(period); + setLeaderboard(lb); + }; + + const handleExport = async () => { + const data = await api.exportData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'tidyquest-backup.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const text = await file.text(); + const data = JSON.parse(text); + await api.importData(data); + await loadDashboard(); + }; + input.click(); + }; + + if (loading) { + return ( +
+
{tBrowser('common.loading')}...
+
+ ); + } + + if (!user) { + if (authView === 'register') { + return setAuthView('login')} />; + } + return setAuthView('register')} />; + } + + const { t } = useTranslation(user.language); + const localeMap: Record = { en: 'en-US', fr: 'fr-FR', de: 'de-DE', es: 'es-ES' }; + const today = new Date().toLocaleDateString(localeMap[user.language] || 'en-US', { weekday: 'long', month: 'long', day: 'numeric' }); + + // Build flat tasks list for calendar + const allTasks = rooms.flatMap((r: any) => + (r.tasks || []).map((t: any) => ({ ...t, roomName: r.name, roomType: r.roomType })) + ); + + return ( +
+ + +
+ + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + {dashboardData && ( + navigate(`/rooms/${id}`)} + onNavigateToActivity={() => navigate('/activity')} + onRewardRequestAction={async (id, status) => { + await api.updateRedemptionStatus(id, status); + await Promise.all([loadDashboard(), loadRewards()]); + }} + /> + )} + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + navigate(`/rooms/${id}`)} + isAdmin={user.role === 'admin'} + onCreateRoom={async (data) => { + await api.createRoom({ name: data.name, roomType: data.roomType, color: data.color, accentColor: data.accentColor, tasks: data.tasks }); + setRooms(await api.getRooms()); + }} + onDeleteRoom={async (roomId) => { + await api.deleteRoom(roomId); + setRooms(await api.getRooms()); + }} + /> + + } /> + + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + + + } /> + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + { await refreshUser(); }} onLogout={logout} /> + + } /> + + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + { + await api.updateSettings(user.id, { isVacationMode: enabled }); + await refreshUser(); + }} + onUpdateRole={async (targetUserId, role) => { + await api.updateUserRole(targetUserId, role); + setFamilySettings(await api.getUsers()); + await refreshUser(); + }} + onAddMember={async (member) => { + await api.createUser(member); + setFamilySettings(await api.getUsers()); + }} + onDeleteUser={async (targetUserId) => { + await api.deleteUser(targetUserId); + setFamilySettings(await api.getUsers()); + await refreshUser(); + await loadDashboard(); + }} + onUpdateMemberProfile={async (memberUserId, profile) => { + await api.updateProfile(memberUserId, profile); + setFamilySettings(await api.getUsers()); + if (memberUserId === user.id) { + await refreshUser(); + } + }} + onChangePassword={async (targetUserId, payload) => { + await api.updatePassword(targetUserId, payload); + }} + coinsByEffort={coinsByEffort} + onSaveCoinsByEffort={async (values) => { + const updated = await api.updateCoinsConfig({ coinsByEffort: values }); + setCoinsByEffort(updated.coinsByEffort); + }} + onResetCoinsByEffort={async () => { + const updated = await api.updateCoinsConfig({ useDefault: true }); + setCoinsByEffort(updated.coinsByEffort); + }} + theme={theme} + onChangeTheme={setTheme} + onExport={handleExport} + onImport={handleImport} + /> + + } /> + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + + + } /> + + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + { + await api.redeemReward(rewardId); + await refreshUser(); + await loadDashboard(); + await loadRewards(); + }} + onCancel={async (redemptionId) => { + await api.cancelRedemption(redemptionId); + await refreshUser(); + await loadDashboard(); + await loadRewards(); + }} + /> + + } /> + } /> + +
+
+ ); +} + +function RoomDetailWrapper({ rooms, user, onCompleteTask, onRefresh }: { rooms: any[]; user: any; onCompleteTask: (id: number) => void; onRefresh: () => void }) { + const navigate = useNavigate(); + const { t, roomDisplayName } = useTranslation(user?.language || 'en'); + const id = parseInt(window.location.pathname.split('/').pop() || '0'); + const room = rooms.find((r) => r.id === id); + + if (!room) { + return ( +
+ {t('rooms.roomNotFound')} + +
+ ); + } + + return ( + <> + navigate('/rewards')} onStreakClick={() => navigate('/achievements')} /> + navigate('/rooms')} onRefresh={onRefresh} /> + + ); +} + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/client/src/assets/fonts/Nunito-400.ttf b/client/src/assets/fonts/Nunito-400.ttf new file mode 100644 index 0000000..6401d72 Binary files /dev/null and b/client/src/assets/fonts/Nunito-400.ttf differ diff --git a/client/src/assets/fonts/Nunito-500.ttf b/client/src/assets/fonts/Nunito-500.ttf new file mode 100644 index 0000000..dd75bae Binary files /dev/null and b/client/src/assets/fonts/Nunito-500.ttf differ diff --git a/client/src/assets/fonts/Nunito-600.ttf b/client/src/assets/fonts/Nunito-600.ttf new file mode 100644 index 0000000..69fdf83 Binary files /dev/null and b/client/src/assets/fonts/Nunito-600.ttf differ diff --git a/client/src/assets/fonts/Nunito-700.ttf b/client/src/assets/fonts/Nunito-700.ttf new file mode 100644 index 0000000..063f39a Binary files /dev/null and b/client/src/assets/fonts/Nunito-700.ttf differ diff --git a/client/src/assets/fonts/Nunito-800.ttf b/client/src/assets/fonts/Nunito-800.ttf new file mode 100644 index 0000000..8c4ffa4 Binary files /dev/null and b/client/src/assets/fonts/Nunito-800.ttf differ diff --git a/client/src/assets/fonts/Nunito-900.ttf b/client/src/assets/fonts/Nunito-900.ttf new file mode 100644 index 0000000..17633ef Binary files /dev/null and b/client/src/assets/fonts/Nunito-900.ttf differ diff --git a/client/src/assets/fonts/nunito.css b/client/src/assets/fonts/nunito.css new file mode 100644 index 0000000..ee11cfc --- /dev/null +++ b/client/src/assets/fonts/nunito.css @@ -0,0 +1,42 @@ +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDLshRTM.ttf) format('truetype'); +} +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhRTM.ttf) format('truetype'); +} +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDGUmRTM.ttf) format('truetype'); +} +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDFwmRTM.ttf) format('truetype'); +} +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDDsmRTM.ttf) format('truetype'); +} +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url(https://fonts.gstatic.com/s/nunito/v32/XRXI3I6Li01BKofiOc5wtlZ2di8HDBImRTM.ttf) format('truetype'); +} diff --git a/client/src/components/icons/AvatarPresets.tsx b/client/src/components/icons/AvatarPresets.tsx new file mode 100644 index 0000000..b06d125 --- /dev/null +++ b/client/src/components/icons/AvatarPresets.tsx @@ -0,0 +1,260 @@ +interface PresetProps { + size?: number; +} + +function Cat({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function Dog({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + ); +} + +function Bunny({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +function Bear({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + ); +} + +function Fox({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + ); +} + +function Owl({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + ); +} + +function Panda({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + ); +} + +function Penguin({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + ); +} + +function Unicorn({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function Frog({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + ); +} + +function Koala({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + ); +} + +function Hedgehog({ size = 38 }: PresetProps) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +export const AVATAR_PRESETS: Record; label: string; color: string }> = { + cat: { component: Cat, label: 'Cat', color: '#FFD4A8' }, + dog: { component: Dog, label: 'Dog', color: '#D4A574' }, + bunny: { component: Bunny, label: 'Bunny', color: '#F5F0EB' }, + bear: { component: Bear, label: 'Bear', color: '#C4956A' }, + fox: { component: Fox, label: 'Fox', color: '#F97316' }, + owl: { component: Owl, label: 'Owl', color: '#A0714F' }, + panda: { component: Panda, label: 'Panda', color: '#E5E7EB' }, + penguin: { component: Penguin, label: 'Penguin', color: '#374151' }, + unicorn: { component: Unicorn, label: 'Unicorn', color: '#F5F0EB' }, + frog: { component: Frog, label: 'Frog', color: '#6DBE6D' }, + koala: { component: Koala, label: 'Koala', color: '#9CA3AF' }, + hedgehog: { component: Hedgehog, label: 'Hedgehog', color: '#8B6F5E' }, +}; + +export function AvatarPresetIcon({ presetId, size = 38 }: { presetId: string; size?: number }) { + const preset = AVATAR_PRESETS[presetId]; + if (!preset) return null; + const Component = preset.component; + return ; +} diff --git a/client/src/components/icons/NavIcons.tsx b/client/src/components/icons/NavIcons.tsx new file mode 100644 index 0000000..d2e504b --- /dev/null +++ b/client/src/components/icons/NavIcons.tsx @@ -0,0 +1,187 @@ +interface NavIconProps { + active: boolean; +} + +export function HomeIcon({ active }: NavIconProps) { + return ( + + + + + ); +} + +export function RoomsIcon({ active }: NavIconProps) { + return ( + + + + + + + ); +} + +export function TrophyIcon({ active }: NavIconProps) { + return ( + + + + + + + + ); +} + +export function CalendarIcon({ active }: NavIconProps) { + return ( + + + + + + + + + ); +} + +export function ActivityIcon({ active }: NavIconProps) { + return ( + + + + + + + ); +} + +export function AchievementsIcon({ active }: NavIconProps) { + return ( + + + + + + ); +} + +export function SettingsIcon({ active }: NavIconProps) { + return ( + + + + + ); +} + +export function RewardsIcon({ active }: NavIconProps) { + return ( + + + + + + + + ); +} diff --git a/client/src/components/icons/RoomIcons.tsx b/client/src/components/icons/RoomIcons.tsx new file mode 100644 index 0000000..bcc9518 --- /dev/null +++ b/client/src/components/icons/RoomIcons.tsx @@ -0,0 +1,141 @@ +import React from 'react'; + +export function KitchenIcon() { + return ( + + + + + + + + + ); +} + +export function BedroomIcon() { + return ( + + + + + + + + + ); +} + +export function BathroomIcon() { + return ( + + + + + + + + + ); +} + +export function LivingRoomIcon() { + return ( + + + + + + + + + ); +} + +export function OfficeIcon() { + return ( + + + + + + + + + ); +} + +export function GarageIcon() { + return ( + + + + + + + + ); +} + +export function LaundryIcon() { + return ( + + + + + + + + + ); +} + +export function OtherRoomIcon() { + return ( + + + + + + + ); +} + +const ROOM_ICON_MAP: Record React.JSX.Element> = { + kitchen: KitchenIcon, + bedroom: BedroomIcon, + bathroom: BathroomIcon, + living: LivingRoomIcon, + livingroom: LivingRoomIcon, + living_room: LivingRoomIcon, + office: OfficeIcon, + garage: GarageIcon, + laundry: LaundryIcon, + other: OtherRoomIcon, +}; + +const ROOM_NAME_KEYWORDS: Array<{ keyword: string; key: string }> = [ + { keyword: 'kitchen', key: 'kitchen' }, + { keyword: 'bedroom', key: 'bedroom' }, + { keyword: 'bathroom', key: 'bathroom' }, + { keyword: 'living', key: 'living' }, + { keyword: 'office', key: 'office' }, + { keyword: 'garage', key: 'garage' }, + { keyword: 'laundry', key: 'laundry' }, +]; + +export function getRoomIcon(roomType: string): () => React.JSX.Element { + const lower = roomType.toLowerCase(); + + if (ROOM_ICON_MAP[lower]) { + return ROOM_ICON_MAP[lower]; + } + + for (const entry of ROOM_NAME_KEYWORDS) { + if (lower.includes(entry.keyword)) { + return ROOM_ICON_MAP[entry.key]; + } + } + + return OtherRoomIcon; +} diff --git a/client/src/components/icons/TaskIcons.tsx b/client/src/components/icons/TaskIcons.tsx new file mode 100644 index 0000000..baed91e --- /dev/null +++ b/client/src/components/icons/TaskIcons.tsx @@ -0,0 +1,831 @@ +export type TaskIconKey = + | 'sparkle' | 'dishes' | 'sink' | 'stove' | 'counter' | 'fridge' | 'oven' + | 'microwave' | 'kettle' | 'rangehood' | 'pantry' | 'cabinet' + | 'bed' | 'pillow' | 'sheets' | 'mattress' | 'nightstand' + | 'toilet' | 'shower' | 'mirror' | 'towel' | 'drain' | 'bathmat' + | 'showerhead' | 'toothbrush' | 'grout' + | 'vacuum' | 'mop' | 'dust' | 'sweep' + | 'window' | 'curtain' | 'lightswitch' + | 'sofa' | 'tv' | 'lamp' | 'furniture' | 'rug' + | 'desk' | 'keyboard' | 'monitor' | 'cable' | 'chair' + | 'trash' | 'broom' + | 'washer' | 'dryer' | 'detergent' | 'iron' | 'clothes' + | 'tools' | 'cobweb' | 'chemicals' | 'garagedoor' + | 'shelf' | 'drawer' | 'closet'; + +export const TASK_ICON_OPTIONS: Array<{ key: TaskIconKey; label: string }> = [ + { key: 'sparkle', label: 'Sparkle' }, + { key: 'dishes', label: 'Dishes' }, + { key: 'sink', label: 'Sink' }, + { key: 'stove', label: 'Stove' }, + { key: 'counter', label: 'Counter' }, + { key: 'fridge', label: 'Fridge' }, + { key: 'oven', label: 'Oven' }, + { key: 'microwave', label: 'Microwave' }, + { key: 'kettle', label: 'Kettle' }, + { key: 'rangehood', label: 'Range Hood' }, + { key: 'pantry', label: 'Pantry' }, + { key: 'cabinet', label: 'Cabinet' }, + { key: 'bed', label: 'Bed' }, + { key: 'pillow', label: 'Pillow' }, + { key: 'sheets', label: 'Sheets' }, + { key: 'mattress', label: 'Mattress' }, + { key: 'nightstand', label: 'Nightstand' }, + { key: 'toilet', label: 'Toilet' }, + { key: 'shower', label: 'Shower' }, + { key: 'mirror', label: 'Mirror' }, + { key: 'towel', label: 'Towel' }, + { key: 'drain', label: 'Drain' }, + { key: 'bathmat', label: 'Bath Mat' }, + { key: 'showerhead', label: 'Showerhead' }, + { key: 'toothbrush', label: 'Toothbrush' }, + { key: 'grout', label: 'Grout' }, + { key: 'vacuum', label: 'Vacuum' }, + { key: 'mop', label: 'Mop' }, + { key: 'dust', label: 'Dust' }, + { key: 'sweep', label: 'Sweep' }, + { key: 'window', label: 'Window' }, + { key: 'curtain', label: 'Curtain' }, + { key: 'lightswitch', label: 'Switch' }, + { key: 'sofa', label: 'Sofa' }, + { key: 'tv', label: 'TV' }, + { key: 'lamp', label: 'Lamp' }, + { key: 'furniture', label: 'Furniture' }, + { key: 'rug', label: 'Rug' }, + { key: 'desk', label: 'Desk' }, + { key: 'keyboard', label: 'Keyboard' }, + { key: 'monitor', label: 'Monitor' }, + { key: 'cable', label: 'Cable' }, + { key: 'chair', label: 'Chair' }, + { key: 'trash', label: 'Trash' }, + { key: 'broom', label: 'Broom' }, + { key: 'washer', label: 'Washer' }, + { key: 'dryer', label: 'Dryer' }, + { key: 'detergent', label: 'Detergent' }, + { key: 'iron', label: 'Iron' }, + { key: 'clothes', label: 'Clothes' }, + { key: 'tools', label: 'Tools' }, + { key: 'cobweb', label: 'Cobweb' }, + { key: 'chemicals', label: 'Chemicals' }, + { key: 'garagedoor', label: 'Garage' }, + { key: 'shelf', label: 'Shelf' }, + { key: 'drawer', label: 'Drawer' }, + { key: 'closet', label: 'Closet' }, +]; + +// Kawaii color palettes per icon +const P: Record = { + // s=stroke, b=background, f=fill accent + sparkle: { s: '#F97316', b: '#FFF1E5', f: '#FBBF24' }, + dishes: { s: '#3B82F6', b: '#EFF6FF', f: '#93C5FD' }, + sink: { s: '#0EA5E9', b: '#E0F2FE', f: '#7DD3FC' }, + stove: { s: '#EF4444', b: '#FEF2F2', f: '#FCA5A5' }, + counter: { s: '#8B5CF6', b: '#F5F3FF', f: '#C4B5FD' }, + fridge: { s: '#06B6D4', b: '#ECFEFF', f: '#67E8F9' }, + oven: { s: '#F97316', b: '#FFF7ED', f: '#FDBA74' }, + microwave: { s: '#6366F1', b: '#EEF2FF', f: '#A5B4FC' }, + kettle: { s: '#EC4899', b: '#FDF2F8', f: '#F9A8D4' }, + rangehood: { s: '#64748B', b: '#F1F5F9', f: '#CBD5E1' }, + pantry: { s: '#D97706', b: '#FFFBEB', f: '#FDE68A' }, + cabinet: { s: '#92400E', b: '#FEF3C7', f: '#FDE68A' }, + bed: { s: '#8B5CF6', b: '#F5F3FF', f: '#DDD6FE' }, + pillow: { s: '#A78BFA', b: '#F5F3FF', f: '#EDE9FE' }, + sheets: { s: '#06B6D4', b: '#ECFEFF', f: '#A5F3FC' }, + mattress: { s: '#9333EA', b: '#FAF5FF', f: '#D8B4FE' }, + nightstand: { s: '#B45309', b: '#FFFBEB', f: '#FCD34D' }, + toilet: { s: '#3B82F6', b: '#EFF6FF', f: '#BFDBFE' }, + shower: { s: '#0EA5E9', b: '#E0F2FE', f: '#7DD3FC' }, + mirror: { s: '#6366F1', b: '#EEF2FF', f: '#C7D2FE' }, + towel: { s: '#EC4899', b: '#FDF2F8', f: '#FBCFE8' }, + drain: { s: '#64748B', b: '#F1F5F9', f: '#94A3B8' }, + bathmat: { s: '#14B8A6', b: '#F0FDFA', f: '#99F6E4' }, + showerhead: { s: '#0284C7', b: '#E0F2FE', f: '#7DD3FC' }, + toothbrush: { s: '#10B981', b: '#ECFDF5', f: '#6EE7B7' }, + grout: { s: '#78716C', b: '#F5F5F4', f: '#D6D3D1' }, + vacuum: { s: '#EF4444', b: '#FEF2F2', f: '#FCA5A5' }, + mop: { s: '#3B82F6', b: '#EFF6FF', f: '#93C5FD' }, + dust: { s: '#F59E0B', b: '#FFFBEB', f: '#FDE68A' }, + sweep: { s: '#16A34A', b: '#F0FDF4', f: '#86EFAC' }, + window: { s: '#0EA5E9', b: '#E0F2FE', f: '#BAE6FD' }, + curtain: { s: '#EC4899', b: '#FDF2F8', f: '#FBCFE8' }, + lightswitch:{ s: '#F59E0B', b: '#FFFBEB', f: '#FDE68A' }, + sofa: { s: '#16A34A', b: '#F0FDF4', f: '#BBF7D0' }, + tv: { s: '#6366F1', b: '#EEF2FF', f: '#C7D2FE' }, + lamp: { s: '#F59E0B', b: '#FFFBEB', f: '#FDE68A' }, + furniture: { s: '#92400E', b: '#FFFBEB', f: '#FDE68A' }, + rug: { s: '#DC2626', b: '#FEF2F2', f: '#FECACA' }, + desk: { s: '#D97706', b: '#FFFBEB', f: '#FDE68A' }, + keyboard: { s: '#64748B', b: '#F1F5F9', f: '#E2E8F0' }, + monitor: { s: '#3B82F6', b: '#EFF6FF', f: '#BFDBFE' }, + cable: { s: '#10B981', b: '#ECFDF5', f: '#A7F3D0' }, + chair: { s: '#B45309', b: '#FFFBEB', f: '#FCD34D' }, + trash: { s: '#64748B', b: '#F1F5F9', f: '#CBD5E1' }, + broom: { s: '#16A34A', b: '#F0FDF4', f: '#BBF7D0' }, + washer: { s: '#0EA5E9', b: '#E0F2FE', f: '#BAE6FD' }, + dryer: { s: '#F97316', b: '#FFF7ED', f: '#FED7AA' }, + detergent: { s: '#8B5CF6', b: '#F5F3FF', f: '#DDD6FE' }, + iron: { s: '#6366F1', b: '#EEF2FF', f: '#A5B4FC' }, + clothes: { s: '#EC4899', b: '#FDF2F8', f: '#FBCFE8' }, + tools: { s: '#64748B', b: '#F1F5F9', f: '#CBD5E1' }, + cobweb: { s: '#7C3AED', b: '#F5F3FF', f: '#DDD6FE' }, + chemicals: { s: '#EF4444', b: '#FEF2F2', f: '#FCA5A5' }, + garagedoor: { s: '#78716C', b: '#F5F5F4', f: '#D6D3D1' }, + shelf: { s: '#D97706', b: '#FFFBEB', f: '#FDE68A' }, + drawer: { s: '#92400E', b: '#FEF3C7', f: '#FDE68A' }, + closet: { s: '#A78BFA', b: '#F5F3FF', f: '#DDD6FE' }, +}; + +function I({ k, size }: { k: string; size: number }) { + const p = P[k] || P.sparkle; + const w = 1.4; + switch (k) { + // ===== KITCHEN ===== + case 'dishes': return ( + + + + + {/* kawaii face */} + + + + {/* bubbles */} + + + + ); + case 'sink': return ( + + + + + + + + + + + ); + case 'stove': return ( + + + + + + + {/* steam */} + + + + + ); + case 'counter': return ( + + + + + + + {/* sparkle on counter */} + + + ); + case 'fridge': return ( + + + + + + + + + + ); + case 'oven': return ( + + + + + + + + {/* kawaii face on window */} + + + + + ); + case 'microwave': return ( + + + + + + + + + + + ); + case 'kettle': return ( + + + + + + + + {/* steam */} + + + + ); + case 'rangehood': return ( + + + + + + + ); + case 'pantry': return ( + + + + + + {/* jars */} + + + + + + + ); + case 'cabinet': return ( + + + + + + {/* sparkle */} + + + ); + + // ===== BEDROOM ===== + case 'bed': return ( + + + + + + + + + ); + case 'pillow': return ( + + + + + + + + + + ); + case 'sheets': return ( + + + + + + + + + + ); + case 'mattress': return ( + + + + + + + {/* flip arrows */} + + + + ); + case 'nightstand': return ( + + + + + + {/* lamp on top */} + + + + ); + + // ===== BATHROOM ===== + case 'toilet': return ( + + + + + + + + {/* sparkle */} + + + ); + case 'shower': return ( + + + + + + {/* water drops */} + + + + + ); + case 'mirror': return ( + + + + + {/* shine */} + + + ); + case 'towel': return ( + + + + + + + + ); + case 'drain': return ( + + + + + + + + + + + + ); + case 'bathmat': return ( + + + + + {/* tassels */} + + + ); + case 'showerhead': return ( + + + + {/* water drops */} + + + + + + + + {/* face */} + + + + + ); + case 'toothbrush': return ( + + + + + {/* sparkles */} + + + + ); + case 'grout': return ( + + {/* tiles */} + + + + + {/* grout lines highlighted */} + + + {/* brush */} + + + + ); + + // ===== CLEANING ===== + case 'vacuum': return ( + + + + + + + + + + ); + case 'mop': return ( + + + + + {/* water drops */} + + + + + ); + case 'dust': return ( + + {/* feather duster */} + + + + + {/* sparkles */} + + + + ); + case 'sweep': return ( + + + + + + + {/* dust cloud */} + + + + + ); + + // ===== WINDOWS & SWITCHES ===== + case 'window': return ( + + + + + {/* shine */} + + {/* sparkle */} + + + ); + case 'curtain': return ( + + + + + + + {/* tie-backs */} + + + {/* window behind */} + + + ); + case 'lightswitch': return ( + + + + + {/* light rays */} + + + + + + ); + + // ===== LIVING ROOM ===== + case 'sofa': return ( + + + + + + + + + + + + ); + case 'tv': return ( + + + + + + {/* screen shine */} + + + ); + case 'lamp': return ( + + + + + {/* warm glow */} + + {/* rays */} + + + ); + case 'furniture': return ( + + + + + + + + {/* sparkle */} + + + ); + case 'rug': return ( + + + + + {/* tassels */} + + + + ); + + // ===== OFFICE ===== + case 'desk': return ( + + + + + + {/* items on desk */} + + + + + ); + case 'keyboard': return ( + + + {/* keys */} + + + + + + + + + + ); + case 'monitor': return ( + + + + + + {/* screen face */} + + + + + ); + case 'cable': return ( + + + + + {/* plug */} + + + + + ); + case 'chair': return ( + + + + + + + ); + + // ===== TRASH & BROOM ===== + case 'trash': return ( + + + + + + {/* face */} + + + + ); + case 'broom': return ( + + + + + + + {/* sparkle */} + + + ); + + // ===== LAUNDRY ===== + case 'washer': return ( + + + + + + + + + + ); + case 'dryer': return ( + + + + + + + {/* heat waves */} + + + + ); + case 'detergent': return ( + + + + + {/* label */} + + {/* bubbles */} + + + + ); + case 'iron': return ( + + + + + + {/* steam */} + + + + ); + case 'clothes': return ( + + {/* t-shirt shape */} + + + {/* collar */} + + {/* heart on shirt */} + + + ); + + // ===== GARAGE ===== + case 'tools': return ( + + {/* wrench */} + + + {/* screwdriver */} + + + + ); + case 'cobweb': return ( + + + + + + {/* spider */} + + + + + + ); + case 'chemicals': return ( + + + + + + + + {/* warning */} + + + ); + case 'garagedoor': return ( + + + + + + + + {/* sparkle */} + + + ); + + // ===== ORGANIZE ===== + case 'shelf': return ( + + + + + {/* items */} + + + + + + + + ); + case 'drawer': return ( + + + + + + + + + ); + case 'closet': return ( + + + + + + {/* hanger */} + + + + + + ); + + // ===== DEFAULT ===== + default: return ( + + + + + + ); + } +} + +export function TaskIcon({ iconKey, size = 14 }: { iconKey?: string | null; size?: number }) { + return ; +} diff --git a/client/src/components/icons/UIIcons.tsx b/client/src/components/icons/UIIcons.tsx new file mode 100644 index 0000000..b54af79 --- /dev/null +++ b/client/src/components/icons/UIIcons.tsx @@ -0,0 +1,253 @@ +export function CheckIcon() { + return ( + + + + ); +} + +export function PlusIcon() { + return ( + + + + ); +} + +export function BackIcon() { + return ( + + + + ); +} + +export function FireIcon() { + return ( + + + + + ); +} + +export function CoinIcon() { + return ( + + + + $ + + + ); +} + +export function SparkleIcon() { + return ( + + + + ); +} + +export function StarIcon() { + return ( + + + + ); +} + +export function StarEmptyIcon() { + return ( + + + + ); +} + +export function DownloadIcon() { + return ( + + + + + ); +} + +export function UploadIcon() { + return ( + + + + + ); +} + +export function LockIcon() { + return ( + + + + + + ); +} + +export function GlobeIcon() { + return ( + + + + + + + ); +} + +export function BellIcon() { + return ( + + + + + ); +} + +export function UsersIcon() { + return ( + + + + + + + ); +} + +export function TrashIcon() { + return ( + + + + + + + + ); +} diff --git a/client/src/components/layout/PageHeader.tsx b/client/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..cf92189 --- /dev/null +++ b/client/src/components/layout/PageHeader.tsx @@ -0,0 +1,60 @@ +import { FireIcon, CoinIcon } from '../icons/UIIcons'; +import type { User } from '../../hooks/useAuth'; + +interface PageHeaderProps { + title: string; + subtitle: string; + user: User; + rightContent?: React.ReactNode; + onCoinsClick?: () => void; + onStreakClick?: () => void; +} + +export function PageHeader({ title, subtitle, user, rightContent, onCoinsClick, onStreakClick }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+
+
+ {rightContent} +
{ + if (!onStreakClick) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onStreakClick(); + } + }} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + backgroundColor: 'var(--warm-accent-light)', borderRadius: 14, padding: '8px 16px', + border: '1.5px solid var(--warm-streak-border)', + cursor: onStreakClick ? 'pointer' : 'default', + }}> + + {user.currentStreak} +
+
+ + {user.coins} +
+
+
+ ); +} diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..ecff7e1 --- /dev/null +++ b/client/src/components/layout/Sidebar.tsx @@ -0,0 +1,109 @@ +import { NavLink, useNavigate } from 'react-router-dom'; +import { HomeIcon, RoomsIcon, TrophyIcon, CalendarIcon, ActivityIcon, AchievementsIcon, SettingsIcon, RewardsIcon } from '../icons/NavIcons'; +import { FireIcon, CoinIcon, SparkleIcon } from '../icons/UIIcons'; +import { UserAvatar } from '../shared/UserAvatar'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { User } from '../../hooks/useAuth'; + +interface SidebarProps { + user: User; +} + +const navItems = [ + { to: '/', label: 'Home', Icon: HomeIcon }, + { to: '/rooms', label: 'Rooms', Icon: RoomsIcon }, + { to: '/leaderboard', label: 'Board', Icon: TrophyIcon }, + { to: '/calendar', label: 'Calendar', Icon: CalendarIcon }, + { to: '/activity', label: 'Activity', Icon: ActivityIcon }, + { to: '/rewards', label: 'Rewards', Icon: RewardsIcon }, + { to: '/achievements', label: 'Achievements', Icon: AchievementsIcon }, + { to: '/settings', label: 'Settings', Icon: SettingsIcon }, +]; + +export function Sidebar({ user }: SidebarProps) { + const navigate = useNavigate(); + const { t } = useTranslation(user.language); + + return ( + + ); +} diff --git a/client/src/components/pages/Achievements.tsx b/client/src/components/pages/Achievements.tsx new file mode 100644 index 0000000..78e3333 --- /dev/null +++ b/client/src/components/pages/Achievements.tsx @@ -0,0 +1,162 @@ +import { SparkleIcon, FireIcon, CoinIcon, StarIcon } from '../icons/UIIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +function AchievementIcon({ type }: { type: string }) { + if (type === 'fire') return ; + if (type === 'coin') return ; + if (type === 'star') return ; + if (type === 'crown') { + return ( + + + + ); + } + if (type === 'broom') { + return ( + + + + + + + + ); + } + if (type === 'heart') { + return ( + + + + ); + } + if (type === 'shield') { + return ( + + + + + ); + } + if (type === 'rocket') { + return ( + + + + + + + ); + } + if (type === 'diamond') { + return ( + + + + + + ); + } + return ; +} + +interface AchRow { + id: string; + titleKey?: string; + title?: string; + descKey?: string; + description?: string; + icon: string; + threshold: number; + value: number; + progress: number; + unlocked: boolean; +} + +interface AchUser { + id: number; + displayName: string; + role: 'admin' | 'member' | 'child'; + avatarColor: string; + achievements: AchRow[]; +} + +interface Props { + data: { me: AchUser; family: AchUser[] } | null; + language?: string; +} + +export function Achievements({ data, language }: Props) { + const { t } = useTranslation(language); + if (!data) return null; + + const renderUser = (u: AchUser) => { + const unlocked = u.achievements.filter(a => a.unlocked); + const locked = u.achievements.filter(a => !a.unlocked); + + return ( +
+
+

{u.displayName}

+ + {unlocked.length}/{u.achievements.length} + +
+ {unlocked.length > 0 && ( +
0 ? 16 : 0 }}> + {unlocked.map((a) => ( +
+
+ +
{a.titleKey ? t(a.titleKey) : a.title}
+
+
{a.descKey ? t(a.descKey) : a.description}
+
+
+
+
+ {a.value}/{a.threshold} +
+
+ ))} +
+ )} + {locked.length > 0 && ( +
+ {locked.map((a) => ( +
+
+ +
{a.titleKey ? t(a.titleKey) : a.title}
+
+
{a.descKey ? t(a.descKey) : a.description}
+
+
+
+
+ {a.value}/{a.threshold} +
+
+ ))} +
+ )} +
+ ); + }; + + return ( +
+ {renderUser(data.me)} + {data.family.length > 0 && ( +
{t('achievements.familyProgress')}
+ )} + {data.family.map(renderUser)} +
+ ); +} diff --git a/client/src/components/pages/Calendar.tsx b/client/src/components/pages/Calendar.tsx new file mode 100644 index 0000000..63f0b9a --- /dev/null +++ b/client/src/components/pages/Calendar.tsx @@ -0,0 +1,143 @@ +import { TaskIcon } from '../icons/TaskIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface CalendarProps { + completions: Array<{ completedAt: string; taskName: string; translationKey?: string; roomName: string }>; + tasks: Array<{ id: number; name: string; translationKey?: string; roomName: string; roomType: string; health: number; frequencyDays: number; lastCompletedAt: string | null; iconKey?: string }>; + language?: string; +} + +export function Calendar({ completions, tasks, language }: CalendarProps) { + const { taskName, t, roomDisplayName } = useTranslation(language); + const localeMap: Record = { en: 'en-US', fr: 'fr-FR', de: 'de-DE', es: 'es-ES' }; + const locale = localeMap[language || 'en'] || 'en-US'; + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const monthName = today.toLocaleString(locale, { month: 'long', year: 'numeric' }); + + // Days with completions this month + const completionDays = new Set( + completions + .filter((c) => { + const d = new Date(c.completedAt); + return d.getFullYear() === year && d.getMonth() === month; + }) + .map((c) => new Date(c.completedAt).getDate()) + ); + + // Upcoming due dates + const upcoming = tasks + .map((task) => { + const dueDate = new Date(task.lastCompletedAt || Date.now()); + if (task.lastCompletedAt) { + dueDate.setDate(dueDate.getDate() + task.frequencyDays); + } + const dueInDays = task.lastCompletedAt + ? Math.max(0, Math.ceil((dueDate.getTime() - Date.now()) / 86400000)) + : 0; + return { ...task, dueInDays, dueDate }; + }) + .filter((task) => task.dueInDays <= 30) + .sort((a, b) => a.dueInDays - b.dueInDays); + + // Days with due tasks + const dueDays = new Set( + upcoming + .filter((task) => task.dueDate.getMonth() === month && task.dueDate.getFullYear() === year) + .map((task) => task.dueDate.getDate()) + ); + + // Calendar grid padding + const calDays: (number | null)[] = []; + const offset = firstDay === 0 ? 6 : firstDay - 1; + for (let i = 0; i < offset; i++) calDays.push(null); + for (let d = 1; d <= daysInMonth; d++) calDays.push(d); + + return ( +
+ {/* Calendar Grid */} +
+
{monthName}
+
+ {[ + t('calendar.mon'), + t('calendar.tue'), + t('calendar.wed'), + t('calendar.thu'), + t('calendar.fri'), + t('calendar.sat'), + t('calendar.sun'), + ].map((d) => ( +
{d}
+ ))} + {calDays.map((d, i) => { + if (!d) return
; + const isToday = d === today.getDate(); + const hasActivity = completionDays.has(d); + const isDue = dueDays.has(d); + const isPast = d < today.getDate(); + return ( +
+ {d} +
+ ); + })} +
+
+ {[ + { bg: 'var(--health-green-bg)', border: 'var(--health-green)', label: t('calendar.choresDone') }, + { bg: 'var(--health-red-bg)', border: 'var(--health-red)', label: t('calendar.tasksDue') }, + { bg: 'var(--warm-accent)', border: 'var(--warm-accent)', label: t('calendar.today') }, + ].map((l) => ( +
+
+ {l.label} +
+ ))} +
+
+ + {/* Upcoming Due Dates */} +
+

{t('calendar.upcomingDueDates')}

+ {upcoming.slice(0, 8).map((item, i) => { + return ( +
+
+
+
{taskName(item.name, item.translationKey)}
+
{roomDisplayName(item.roomName, item.roomType)}
+
+
{t('calendar.inDays').replace('{days}', `${item.dueInDays}`)}
+
+ ); + })} + {upcoming.length === 0 && ( +
+ {t('calendar.allCaughtUp')} +
+ )} +
+
+ ); +} diff --git a/client/src/components/pages/Dashboard.tsx b/client/src/components/pages/Dashboard.tsx new file mode 100644 index 0000000..4684eb2 --- /dev/null +++ b/client/src/components/pages/Dashboard.tsx @@ -0,0 +1,716 @@ +import React from 'react'; +import HealthBar from '../shared/HealthBar'; +import RingGauge from '../shared/RingGauge'; +import UserAvatar from '../shared/UserAvatar'; +import { FireIcon, CoinIcon, CheckIcon } from '../icons/UIIcons'; +import { getRoomIcon } from '../icons/RoomIcons'; +import { TaskIcon } from '../icons/TaskIcons'; +import { getHealthColor } from '../../utils/health'; +import { useTranslation } from '../../hooks/useTranslation'; + +/* ── Types ── */ + +interface Room { + id: number; + name: string; + roomType: string; + color: string; + accentColor: string; + health: number; + taskCount: number; + criticalCount: number; +} + +interface Quest { + id: number; + name: string; + translationKey?: string; + iconKey?: string; + health: number; + effort: number; + roomId: number; + roomName: string; + roomColor: string; + roomAccent: string; + lastCompletedAt: string | null; + isSeasonal: boolean; + frequencyDays: number; + dueDate?: string; + dueInDays?: number; +} + +interface CurrentUser { + role?: 'admin' | 'member' | 'child'; + displayName: string; + coins: number; + goalCoins?: number | null; + currentStreak: number; + avatarColor: string; + lastActiveDate?: string | null; +} + +interface ActivityEntry { + displayName: string; + avatarColor: string; + avatarType?: string; + avatarPreset?: string; + avatarPhotoUrl?: string; + taskName: string; + translationKey?: string; + roomId: number; + roomName: string; + completedAt: string; + coinsEarned: number; +} + +interface FamilyMember { + id: number; + displayName: string; + avatarColor: string; + avatarType?: string; + avatarPreset?: string; + avatarPhotoUrl?: string; + coins: number; + currentStreak: number; + points: number; +} + +interface DashboardProps { + data: { + houseHealth: number; + rooms: Room[]; + todaysQuests: Quest[]; + nextTasks: Quest[]; + myGoal?: { goalCoins: number; currentCoins: number; progress: number; goalStartAt?: string | null; goalEndAt?: string | null } | null; + childrenGoals?: Array<{ id: number; displayName: string; role: string; coins: number; currentCoins?: number; goalCoins: number | null; progress: number | null; goalStartAt?: string | null; goalEndAt?: string | null }>; + pendingRewardRequests?: Array<{ id: number; title: string; displayName: string; costCoins: number; redeemedAt: string; status: 'requested' | 'approved' | 'rejected' }>; + currentUser: CurrentUser; + recentActivity: ActivityEntry[]; + }; + family: FamilyMember[]; + language?: string; + onCompleteTask: (taskId: number) => void; + onNavigateToRoom: (roomId: number) => void; + onNavigateToActivity: () => void; + onRewardRequestAction: (id: number, status: 'approved' | 'rejected') => void | Promise; +} + +/* ── Component ── */ + +const Dashboard: React.FC = ({ + data, + family, + language, + onCompleteTask, + onNavigateToRoom, + onNavigateToActivity, + onRewardRequestAction, +}) => { + const { taskName, roomDisplayName, timeAgo, t } = useTranslation(language); + const { houseHealth, rooms, todaysQuests, nextTasks, myGoal, childrenGoals = [], pendingRewardRequests = [], currentUser, recentActivity } = data; + const localeMap: Record = { en: 'en-US', fr: 'fr-FR', de: 'de-DE', es: 'es-ES' }; + const locale = localeMap[language || 'en'] || 'en-US'; + + const sortedRooms = [...rooms].sort((a, b) => a.health - b.health); + const roomTypeById = new Map(rooms.map((r) => [r.id, r.roomType])); + const totalTasks = rooms.reduce((s, r) => s + r.taskCount, 0); + const totalCritical = rooms.reduce((s, r) => s + r.criticalCount, 0); + const sortedFamily = [...family].sort((a, b) => b.points - a.points); + const coinsSortedFamily = [...family].sort((a, b) => b.coins - a.coins); + const todayIso = new Date().toISOString().slice(0, 10); + const streakDoneToday = currentUser.lastActiveDate === todayIso; + + const healthMessage = + houseHealth >= 70 + ? t('dashboard.healthGreat') + : houseHealth >= 40 + ? t('dashboard.healthMedium') + : t('dashboard.healthLow'); + + return ( +
+ {/* ── Column 1 ── */} +
+ {/* House Health Card */} +
+ +
+
+ {t('dashboard.houseHealth')} +
+
+ {healthMessage} +
+
+ {[ + { label: t('dashboard.roomsCount'), value: rooms.length, color: 'var(--warm-accent)' }, + { label: t('dashboard.tasksCount'), value: totalTasks, color: '#4AABDE' }, + { label: t('dashboard.criticalCount'), value: totalCritical, color: 'var(--warm-badge-text)' }, + ].map((s) => ( +
+
+ {s.value} +
+
+ {s.label} +
+
+ ))} +
+
+
+ + {/* Today's Quests Card */} +
+
+

+ {t('dashboard.todaysQuests')} +

+ + {todaysQuests.length} {t('dashboard.pending')} + +
+
+ {todaysQuests.slice(0, 6).map((q) => { + return ( +
+
+ +
+
+
+ {taskName(q.name, q.translationKey)} +
+
+ {roomDisplayName(q.roomName, roomTypeById.get(q.roomId) || '')} + {q.lastCompletedAt ? ` \u00B7 ${timeAgo(q.lastCompletedAt)}` : ''} +
+
+ +
+
+ +
+ ); + })} + {todaysQuests.length === 0 && ( +
+ {t('dashboard.noQuests')} +
+ )} +
+
+ + {/* Next Tasks Card */} +
+

+ {t('dashboard.nextTasks')} +

+
+ {nextTasks.slice(0, 6).map((q) => { + return ( +
+
+
+
{taskName(q.name, q.translationKey)}
+
+ {roomDisplayName(q.roomName, roomTypeById.get(q.roomId) || '')} +
+
+
+ {t('calendar.inDays').replace('{days}', `${q.dueInDays || 0}`)} +
+
+ ); + })} + {nextTasks.length === 0 && ( +
+ {t('calendar.allCaughtUp')} +
+ )} +
+
+
+ + {/* ── Column 2: Rooms ── */} +
+
+

+ {t('nav.rooms')} +

+ + {t('dashboard.sortedUrgency')} + +
+
+ {sortedRooms.map((room) => { + const RoomIcon = getRoomIcon(room.roomType || room.name); + return ( +
onNavigateToRoom(room.id)} + > +
+
+
+ +
+
+
+ {roomDisplayName(room.name, room.roomType)} +
+
+ {room.taskCount} {t('rooms.tasks')} + {room.criticalCount > 0 && ( + + {' '} + · {room.criticalCount} {t('dashboard.criticalCount')} + + )} +
+
+
+ {room.health}% +
+
+ +
+
+ ); + })} +
+
+ + {/* ── Column 3: Widgets ── */} +
+ {/* Streak Card */} +
+
+
+ +
+
+
+ {currentUser.currentStreak} +
+
+ {t('dashboard.dayStreak')} +
+
+
+
+ {Array.from({ length: 7 }, (_, i) => ( +
+ ))} +
+
+ {streakDoneToday ? t('dashboard.streakDoneToday') : t('dashboard.keepStreak')} +
+
+ +
+
+
{t('dashboard.coinsStatusTitle')}
+ + {coinsSortedFamily.length} + +
+
+ {coinsSortedFamily.slice(0, 6).map((u) => ( +
+ +
{u.displayName}
+
+ {u.coins} +
+
+ ))} +
+
+ + {myGoal && ( +
+
{t('dashboard.myGoal')}
+
+ {myGoal.currentCoins}/{myGoal.goalCoins} {t('leaderboard.points')} +
+ {(myGoal.goalStartAt || myGoal.goalEndAt) && ( +
+ {(myGoal.goalStartAt ? new Date(myGoal.goalStartAt).toLocaleDateString(locale) : '...')} - {(myGoal.goalEndAt ? new Date(myGoal.goalEndAt).toLocaleDateString(locale) : '...')} +
+ )} + +
+ )} + + {currentUser.role === 'admin' && childrenGoals.length > 0 && ( +
+
{t('dashboard.childrenGoals')}
+
+ {childrenGoals.map((cg) => ( +
+
+ {cg.displayName} + {cg.goalCoins ? `${cg.currentCoins ?? cg.coins}/${cg.goalCoins}` : t('dashboard.noGoal')} +
+ {(cg.goalStartAt || cg.goalEndAt) && ( +
+ {(cg.goalStartAt ? new Date(cg.goalStartAt).toLocaleDateString(locale) : '...')} - {(cg.goalEndAt ? new Date(cg.goalEndAt).toLocaleDateString(locale) : '...')} +
+ )} + {cg.goalCoins && } +
+ ))} +
+
+ )} + + {currentUser.role === 'admin' && ( +
+
+
{t('dashboard.rewardRequestsTitle')}
+ + {pendingRewardRequests.length} + +
+ {pendingRewardRequests.length === 0 && ( +
+ {t('dashboard.rewardRequestEmpty')} +
+ )} +
+ {pendingRewardRequests.slice(0, 6).map((rr) => ( +
+
{rr.displayName}
+
{rr.title}
+
+
+ {timeAgo(rr.redeemedAt)} +
+
+ + {rr.costCoins} + + + +
+
+
+ ))} +
+
+ )} + + {/* Mini Leaderboard */} +
+

+ {t('leaderboard.thisWeek')} +

+ {sortedFamily.map((u, i) => ( +
+
+ #{i + 1} +
+ +
+ {u.displayName} +
+
+ {u.points} +
+
+ ))} +
+ + {/* Recent Activity */} +
+
+

+ {t('dashboard.recentActivity')} +

+ +
+ {recentActivity.slice(0, 5).map((h, i) => ( +
+ +
+
+ {taskName(h.taskName, h.translationKey)} +
+
+ {t('history.by')} {h.displayName} +
+
+ {roomDisplayName(h.roomName, roomTypeById.get(h.roomId) || '')} · {timeAgo(h.completedAt)} +
+
+
+ +{h.coinsEarned} +
+
+ ))} +
+
+
+ ); +}; + +export default Dashboard; diff --git a/client/src/components/pages/History.tsx b/client/src/components/pages/History.tsx new file mode 100644 index 0000000..b2c9fb1 --- /dev/null +++ b/client/src/components/pages/History.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { UserAvatar } from '../shared/UserAvatar'; +import { CoinIcon } from '../icons/UIIcons'; +import { api } from '../../hooks/useApi'; +import { useTranslation } from '../../hooks/useTranslation'; + +export function History({ language }: { language?: string }) { + const { taskName, t, roomDisplayName, timeAgo } = useTranslation(language); + const [history, setHistory] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const limit = 20; + + useEffect(() => { + api.history(limit, offset).then((data) => { + setHistory(data.history); + setTotal(data.total); + }); + }, [offset]); + + return ( +
+
+
+
+
{t('history.task')}
+
{t('history.room')}
+
{t('history.when')}
+
{t('history.earned')}
+
+ + {history.map((h) => ( +
+ +
+
{taskName(h.taskName, h.translationKey)}
+
{t('history.by')} {h.displayName}
+
+
{roomDisplayName(h.roomName, h.roomType || '')}
+
{timeAgo(h.completedAt)}
+
+ +{h.coinsEarned} +
+
+ ))} + + {history.length === 0 && ( +
+ {t('history.noActivity')} +
+ )} + + {total > limit && ( +
+ + + {offset + 1}-{Math.min(offset + limit, total)} {t('history.of')} {total} + + +
+ )} +
+
+ ); +} diff --git a/client/src/components/pages/Leaderboard.tsx b/client/src/components/pages/Leaderboard.tsx new file mode 100644 index 0000000..a98928d --- /dev/null +++ b/client/src/components/pages/Leaderboard.tsx @@ -0,0 +1,111 @@ +import { UserAvatar } from '../shared/UserAvatar'; +import { FireIcon, CoinIcon } from '../icons/UIIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface LeaderboardProps { + users: Array<{ id: number; displayName: string; avatarColor: string; avatarType?: string; avatarPreset?: string; avatarPhotoUrl?: string; coins: number; currentStreak: number; points: number }>; + period: 'week' | 'month' | 'quarter' | 'year'; + language?: string; + onPeriodChange: (period: 'week' | 'month' | 'quarter' | 'year') => void; +} + +export function Leaderboard({ users, period, language, onPeriodChange }: LeaderboardProps) { + const { t } = useTranslation(language); + const sorted = [...users].sort((a, b) => b.points - a.points); + + // Podium order: 2nd, 1st, 3rd + const podium = [sorted[1], sorted[0], sorted[2]].filter(Boolean); + const podiumHeights = [100, 140, 75]; + const podiumRanks = [2, 1, 3]; + const podiumGradients = [ + 'linear-gradient(180deg, #E2E8F0, #94A3B8)', + 'linear-gradient(180deg, #FDE68A, #F59E0B)', + 'linear-gradient(180deg, #FED7AA, #EA580C)', + ]; + + return ( +
+ {/* Period Toggle */} +
+ {(['week', 'month', 'quarter', 'year'] as const).map((p) => ( + + ))} +
+ + {/* Podium */} + {podium.length > 0 && ( +
+
+ {podium.map((u, i) => ( +
+ +
{u.displayName}
+
+
#{podiumRanks[i]}
+
{u.points}
+
{t('leaderboard.points')}
+
+
+ ))} +
+
+ )} + + {/* Stats List */} + {sorted.map((u, i) => ( +
+
#{i + 1}
+ +
+
{u.displayName}
+
+ + {u.currentStreak}d {t('leaderboard.streak')} + + + {u.coins} + +
+
+
{u.points}
+
+ ))} + + {sorted.length === 0 && ( +
+ {t('leaderboard.noData')} +
+ )} +
+ ); +} diff --git a/client/src/components/pages/Login.tsx b/client/src/components/pages/Login.tsx new file mode 100644 index 0000000..394d46a --- /dev/null +++ b/client/src/components/pages/Login.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { SparkleIcon } from '../icons/UIIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface LoginProps { + onLogin: (username: string, password: string) => Promise; + onSwitchToRegister: () => void; +} + +export function Login({ onLogin, onSwitchToRegister }: LoginProps) { + const initialLang = (() => { + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('tidyquest_auth_lang') : null; + if (saved && ['en', 'fr', 'de', 'es'].includes(saved)) return saved; + const browser = typeof navigator !== 'undefined' ? navigator.language.slice(0, 2) : 'en'; + return ['en', 'fr', 'de', 'es'].includes(browser) ? browser : 'en'; + })(); + const [authLanguage, setAuthLanguage] = useState(initialLang); + const { t } = useTranslation(authLanguage); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await onLogin(username.trim(), password); + window.location.href = '/'; + } catch (err: any) { + setError(err.message || t('auth.loginFailed')); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

TidyQuest

+

{t('auth.welcomeBack')}

+ +
+ +
+
+ + setUsername(e.target.value)} + style={{ + width: '100%', padding: '10px 14px', borderRadius: 12, border: '1.5px solid #F0E6D9', + fontSize: 14, fontFamily: 'Nunito', fontWeight: 600, color: '#3D2F1E', + outline: 'none', backgroundColor: '#FFFBF5', + }} + placeholder={t('auth.usernamePlaceholder')} + /> +
+
+ + setPassword(e.target.value)} + style={{ + width: '100%', padding: '10px 14px', borderRadius: 12, border: '1.5px solid #F0E6D9', + fontSize: 14, fontFamily: 'Nunito', fontWeight: 600, color: '#3D2F1E', + outline: 'none', backgroundColor: '#FFFBF5', + }} + placeholder={t('auth.passwordPlaceholder')} + /> +
+ + {error &&
{error}
} + + +
+ +
+ +
+
+
+ ); +} diff --git a/client/src/components/pages/Profile.tsx b/client/src/components/pages/Profile.tsx new file mode 100644 index 0000000..ae6c5a3 --- /dev/null +++ b/client/src/components/pages/Profile.tsx @@ -0,0 +1,277 @@ +import { useState, useRef } from 'react'; +import { UserAvatar } from '../shared/UserAvatar'; +import { AVATAR_PRESETS, AvatarPresetIcon } from '../icons/AvatarPresets'; +import { GlobeIcon } from '../icons/UIIcons'; +import { api } from '../../hooks/useApi'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { User } from '../../hooks/useAuth'; + +const COLORS = [ + '#F97316', '#EF4444', '#EC4899', '#A855F7', + '#6366F1', '#3B82F6', '#06B6D4', '#10B981', + '#84CC16', '#F59E0B', '#78716C', '#64748B', +]; + +interface ProfileProps { + user: User; + onSave: () => void; + onLogout: () => void; +} + +export function Profile({ user, onSave, onLogout }: ProfileProps) { + const { t } = useTranslation(user.language); + const [displayName, setDisplayName] = useState(user.displayName); + const [avatarType, setAvatarType] = useState<'letter' | 'preset' | 'photo'>(user.avatarType || 'letter'); + const [avatarColor, setAvatarColor] = useState(user.avatarColor); + const [avatarPreset, setAvatarPreset] = useState(user.avatarPreset || 'cat'); + const [avatarPhotoUrl, setAvatarPhotoUrl] = useState(user.avatarPhotoUrl); + const [language, setLanguage] = useState(user.language); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordMsg, setPasswordMsg] = useState(''); + const fileRef = useRef(null); + + const handleSave = async () => { + setSaving(true); + try { + await api.updateProfile(user.id, { + displayName, + avatarType, + avatarColor, + avatarPreset: avatarType === 'preset' ? avatarPreset : undefined, + language, + }); + setSaved(true); + onSave(); + setTimeout(() => setSaved(false), 2000); + } finally { + setSaving(false); + } + }; + + const handlePhotoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setSaving(true); + try { + const result = await api.uploadAvatar(user.id, file); + setAvatarType('photo'); + setAvatarPhotoUrl(result.avatarPhotoUrl); + onSave(); + } finally { + setSaving(false); + } + }; + + const handlePasswordChange = async () => { + setPasswordMsg(''); + if (!currentPassword || !newPassword) return; + if (newPassword !== confirmPassword) { + setPasswordMsg(t('settings.passwordMismatch')); + return; + } + setSaving(true); + try { + await api.updatePassword(user.id, { currentPassword, newPassword }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordMsg(t('settings.passwordUpdated')); + } catch (err: any) { + setPasswordMsg(err?.message || t('settings.passwordUpdateFailed')); + } finally { + setSaving(false); + } + }; + + const tabs: Array<{ key: 'letter' | 'preset' | 'photo'; label: string }> = [ + { key: 'letter', label: t('profile.letterMode') }, + { key: 'preset', label: t('profile.characterMode') }, + { key: 'photo', label: t('profile.photoMode') }, + ]; + + return ( +
+
+ {/* Avatar preview */} +
+ +
+ + {/* Display name */} +
+ + setDisplayName(e.target.value)} + style={{ + width: '100%', padding: '10px 14px', borderRadius: 12, + border: '1.5px solid var(--warm-border)', fontSize: 14, fontWeight: 700, + color: 'var(--warm-text)', fontFamily: 'Nunito', backgroundColor: 'var(--warm-bg-input)', + outline: 'none', boxSizing: 'border-box', + }} + /> +
+ + {/* Avatar mode tabs */} +
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Letter mode: color picker */} + {avatarType === 'letter' && ( +
+ {COLORS.map((c) => ( +
+ )} + + {/* Preset mode: grid of animal faces */} + {avatarType === 'preset' && ( +
+ {Object.entries(AVATAR_PRESETS).map(([id]) => ( + + ))} +
+ )} + + {/* Photo mode: upload button */} + {avatarType === 'photo' && ( +
+ + + {avatarPhotoUrl && ( +
+ Photo uploaded +
+ )} +
+ )} +
+ + {/* Language */} +
+ +
+ + +
+
+ +
+ + setCurrentPassword(e.target.value)} placeholder={t('settings.currentPassword')} style={{ padding: '10px 14px', borderRadius: 12, border: '1.5px solid var(--warm-border)', fontFamily: 'Nunito', backgroundColor: 'var(--warm-bg-input)', color: 'var(--warm-text)' }} /> + setNewPassword(e.target.value)} placeholder={t('settings.newPassword')} style={{ padding: '10px 14px', borderRadius: 12, border: '1.5px solid var(--warm-border)', fontFamily: 'Nunito', backgroundColor: 'var(--warm-bg-input)', color: 'var(--warm-text)' }} /> + setConfirmPassword(e.target.value)} placeholder={t('settings.confirmPassword')} style={{ padding: '10px 14px', borderRadius: 12, border: '1.5px solid var(--warm-border)', fontFamily: 'Nunito', backgroundColor: 'var(--warm-bg-input)', color: 'var(--warm-text)' }} /> + + {passwordMsg &&
{passwordMsg}
} +
+ + {/* Save */} + + +
+
+ ); +} diff --git a/client/src/components/pages/Register.tsx b/client/src/components/pages/Register.tsx new file mode 100644 index 0000000..ea47997 --- /dev/null +++ b/client/src/components/pages/Register.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { SparkleIcon } from '../icons/UIIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface RegisterProps { + onRegister: (data: { username: string; password: string; displayName: string; language: string }) => Promise; + onSwitchToLogin: () => void; +} + +export function Register({ onRegister, onSwitchToLogin }: RegisterProps) { + const initialLang = (() => { + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('tidyquest_auth_lang') : null; + if (saved && ['en', 'fr', 'de', 'es'].includes(saved)) return saved; + const browser = typeof navigator !== 'undefined' ? navigator.language.slice(0, 2) : 'en'; + return ['en', 'fr', 'de', 'es'].includes(browser) ? browser : 'en'; + })(); + const [authLanguage, setAuthLanguage] = useState(initialLang); + const { t } = useTranslation(authLanguage); + const [displayName, setDisplayName] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!displayName || !username || !password) { + setError(t('auth.allFieldsRequired')); + return; + } + setLoading(true); + try { + await onRegister({ username: username.trim(), password, displayName, language: authLanguage }); + window.location.href = '/'; + } catch (err: any) { + setError(err.message || t('auth.registrationFailed')); + } finally { + setLoading(false); + } + }; + + const inputStyle = { + width: '100%', padding: '10px 14px', borderRadius: 12, border: '1.5px solid #F0E6D9', + fontSize: 14, fontFamily: 'Nunito', fontWeight: 600 as const, color: '#3D2F1E', + outline: 'none', backgroundColor: '#FFFBF5', + }; + + return ( +
+
+
+
+ +
+

{t('auth.welcome')}

+

{t('auth.createYourAccount')}

+ +
+ +
+
+ + setDisplayName(e.target.value)} style={inputStyle} placeholder={t('auth.displayNamePlaceholder')} /> +
+
+ + setUsername(e.target.value)} style={inputStyle} placeholder={t('auth.usernamePlaceholder')} /> +
+
+ + setPassword(e.target.value)} style={inputStyle} placeholder={t('auth.passwordMinChars')} /> +
+ + {error &&
{error}
} + + +
+ +
+ +
+
+
+ ); +} diff --git a/client/src/components/pages/Rewards.tsx b/client/src/components/pages/Rewards.tsx new file mode 100644 index 0000000..5570ca4 --- /dev/null +++ b/client/src/components/pages/Rewards.tsx @@ -0,0 +1,219 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { CoinIcon } from '../icons/UIIcons'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface Reward { + id: number; + title: string; + description?: string | null; + costCoins: number; + isPreset?: boolean; +} + +interface Redemption { + id: number; + title: string; + costCoins: number; + redeemedAt: string; + status: string; +} + +interface RewardsProps { + language?: string; + rewards: Reward[]; + mine: Redemption[]; + userCoins: number; + onRedeem: (rewardId: number) => Promise; + onCancel: (redemptionId: number) => Promise; +} + +export function Rewards({ language, rewards, mine, userCoins, onRedeem, onCancel }: RewardsProps) { + const { t } = useTranslation(language); + const [pendingRewardId, setPendingRewardId] = useState(null); + const [spendFx, setSpendFx] = useState<{ amount: number; key: number } | null>(null); + const [refundFx, setRefundFx] = useState<{ amount: number; key: number } | null>(null); + const previousStatuses = useRef>({}); + + const pendingReward = useMemo(() => rewards.find((r) => r.id === pendingRewardId) || null, [pendingRewardId, rewards]); + + const presetKeyByTitle: Record = { + 'movie night pick': 'movie_night', + 'ice cream treat': 'ice_cream', + 'stay up 30 min': 'late_bedtime', + 'game time bonus': 'game_bonus', + 'choose dinner': 'choose_dinner', + 'park adventure': 'park_adventure', + 'no-chore pass': 'chore_pass', + 'family board game': 'board_game', + }; + + const rewardTitleByName = (title: string): string => { + const k = presetKeyByTitle[title.toLowerCase()]; + if (k) return t(`rewardsPreset.${k}.title`); + return title; + }; + + const rewardTitle = (r: Reward): string => { + return rewardTitleByName(r.title); + }; + + const rewardDesc = (r: Reward): string => { + const k = presetKeyByTitle[r.title.toLowerCase()]; + if (k) return t(`rewardsPreset.${k}.desc`); + return r.description || t('rewards.noDescription'); + }; + + const confirmRedeem = async () => { + if (!pendingReward) return; + await onRedeem(pendingReward.id); + setSpendFx({ amount: pendingReward.costCoins, key: Date.now() }); + setPendingRewardId(null); + }; + + useEffect(() => { + if (!spendFx) return; + const id = window.setTimeout(() => setSpendFx(null), 1500); + return () => window.clearTimeout(id); + }, [spendFx]); + + useEffect(() => { + const prev = previousStatuses.current; + for (const r of mine) { + if (prev[r.id] === 'requested' && r.status === 'rejected') { + setRefundFx({ amount: r.costCoins, key: Date.now() + r.id }); + break; + } + } + const next: Record = {}; + mine.forEach((r) => { + next[r.id] = r.status; + }); + previousStatuses.current = next; + }, [mine]); + + useEffect(() => { + if (!refundFx) return; + const id = window.setTimeout(() => setRefundFx(null), 1800); + return () => window.clearTimeout(id); + }, [refundFx]); + + const statusLabel = (status: string): string => { + if (status === 'requested') return t('rewards.statusPending'); + if (status === 'approved') return t('rewards.statusApproved'); + if (status === 'rejected') return t('rewards.statusRejected'); + if (status === 'cancelled') return t('rewards.statusCancelled'); + return status; + }; + + const statusStyle = (status: string): CSSProperties => { + if (status === 'approved') return { color: '#15803D', backgroundColor: '#DCFCE7', border: '1px solid #86EFAC' }; + if (status === 'rejected') return { color: '#B91C1C', backgroundColor: '#FEE2E2', border: '1px solid #FCA5A5' }; + if (status === 'cancelled') return { color: '#374151', backgroundColor: '#E5E7EB', border: '1px solid #D1D5DB' }; + return { color: 'var(--warm-accent)', backgroundColor: 'var(--warm-accent-light)', border: '1px solid var(--warm-accent)' }; + }; + + return ( +
+ +
+
+ +
{userCoins} {t('settings.coins')}
+
+
{t('rewards.balanceHint')}
+
+ +
+

{t('rewards.catalog')}

+
+ {rewards.map((r) => { + const canBuy = userCoins >= r.costCoins; + return ( +
+
{rewardTitle(r)}
+
{rewardDesc(r)}
+
+
+ {r.costCoins} +
+ +
+
+ ); + })} +
+
+ +
+

{t('rewards.myRequests')}

+ {mine.length === 0 ? ( +
{t('rewards.noRequests')}
+ ) : ( +
+ {mine.map((m) => ( +
+
+
{rewardTitleByName(m.title)}
+
{new Date(m.redeemedAt).toLocaleString()}
+
+
+
- {m.costCoins}
+ + {statusLabel(m.status)} + + {m.status === 'requested' && ( + + )} +
+
+ ))} +
+ )} +
+ {pendingReward && ( +
+
+
{t('rewards.confirmTitle')}
+
+ {t('rewards.confirmText').replace('{reward}', rewardTitle(pendingReward)).replace('{coins}', String(pendingReward.costCoins))} +
+
+ + +
+
+
+ )} + {spendFx && ( +
+ -{spendFx.amount} +
+ )} + {refundFx && ( +
+ +{refundFx.amount} +
+ )} +
+ ); +} diff --git a/client/src/components/pages/RoomDetail.tsx b/client/src/components/pages/RoomDetail.tsx new file mode 100644 index 0000000..f366bd4 --- /dev/null +++ b/client/src/components/pages/RoomDetail.tsx @@ -0,0 +1,499 @@ +import { useState } from 'react'; +import { HealthBar } from '../shared/HealthBar'; +import { RingGauge } from '../shared/RingGauge'; +import { EffortDots } from '../shared/EffortDots'; +import { getRoomIcon } from '../icons/RoomIcons'; +import { TaskIcon, TASK_ICON_OPTIONS } from '../icons/TaskIcons'; +import { CheckIcon, BackIcon, PlusIcon } from '../icons/UIIcons'; +import { getRoomHealth } from '../../utils/health'; +import { api } from '../../hooks/useApi'; +import { useTranslation } from '../../hooks/useTranslation'; + +const FREQ_UNITS = [ + { label: 'hours', toDays: 1 / 24 }, + { label: 'days', toDays: 1 }, + { label: 'weeks', toDays: 7 }, + { label: 'months', toDays: 30 }, + { label: 'years', toDays: 365 }, +]; + +function daysToFreq(days: number): { value: number; unit: string } { + if (days >= 365 && days % 365 === 0) return { value: days / 365, unit: 'years' }; + if (days >= 30 && days % 30 === 0) return { value: days / 30, unit: 'months' }; + if (days >= 7 && days % 7 === 0) return { value: days / 7, unit: 'weeks' }; + if (days >= 1) return { value: days, unit: 'days' }; + return { value: Math.round(days * 24), unit: 'hours' }; +} + +function freqToDays(value: number, unit: string): number { + const u = FREQ_UNITS.find(f => f.label === unit); + return value * (u?.toDays || 1); +} + +function formatFreq(days: number, t: (key: string) => string): string { + const { value, unit } = daysToFreq(days); + const short = t(`unitsShort.${unit}`); + return `${value}${short}`; +} + +function healthColor(value: number): string { + if (value >= 70) return '#22C55E'; + if (value >= 40) return '#F59E0B'; + return '#EF4444'; +} + +type SortKey = 'name' | 'health' | 'effort' | 'frequency'; + +interface Task { + id: number; name: string; translationKey?: string; health: number; frequencyDays: number; + effort: number; notes?: string | null; isSeasonal: boolean; lastCompletedAt: string | null; iconKey?: string; +} + +interface RoomDetailProps { + room: { + id: number; name: string; roomType: string; color: string; accentColor: string; + tasks: Task[]; + }; + language?: string; + isAdmin: boolean; + onCompleteTask: (taskId: number) => void; + onBack: () => void; + onRefresh?: () => void; +} + +function FrequencyPicker({ value, unit, onChange, t }: { + value: number; unit: string; + onChange: (v: number, u: string) => void; + t: (key: string) => string; +}) { + return ( +
+ onChange(Math.max(1, parseInt(e.target.value) || 1), unit)} + style={{ + width: 52, padding: '6px 8px', borderRadius: 8, border: '1.5px solid var(--warm-border)', + fontSize: 12, fontFamily: 'Nunito', fontWeight: 700, color: 'var(--warm-text)', + outline: 'none', backgroundColor: 'var(--warm-bg-input)', textAlign: 'center', + }} /> + +
+ ); +} + +function EffortPicker({ effort, onChange }: { effort: number; onChange: (e: number) => void }) { + return ( +
+ {[1, 2, 3, 4, 5].map((e) => ( + + ))} +
+ ); +} + +export function RoomDetail({ room, language, isAdmin, onCompleteTask, onBack, onRefresh }: RoomDetailProps) { + const { taskName: translateTask, roomDisplayName, timeAgo, t } = useTranslation(language); + const [animatedTask, setAnimatedTask] = useState(null); + const [editingTask, setEditingTask] = useState(null); + const [editForm, setEditForm] = useState({ name: '', notes: '', freqValue: 7, freqUnit: 'days', effort: 1, health: 100, iconKey: 'sparkle' }); + const [showAddTask, setShowAddTask] = useState(false); + const [newTaskName, setNewTaskName] = useState(''); + const [newTaskNotes, setNewTaskNotes] = useState(''); + const [newFreqValue, setNewFreqValue] = useState(7); + const [newFreqUnit, setNewFreqUnit] = useState('days'); + const [newTaskEffort, setNewTaskEffort] = useState(2); + const [newTaskHealth, setNewTaskHealth] = useState(100); + const [newTaskIconKey, setNewTaskIconKey] = useState('sparkle'); + const [sortKey, setSortKey] = useState('health'); + const [sortAsc, setSortAsc] = useState(true); + + const health = getRoomHealth(room.tasks); + const RoomIcon = getRoomIcon(room.roomType); + + const sortedTasks = [...room.tasks].sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case 'name': cmp = a.name.localeCompare(b.name); break; + case 'health': cmp = a.health - b.health; break; + case 'effort': cmp = a.effort - b.effort; break; + case 'frequency': cmp = a.frequencyDays - b.frequencyDays; break; + } + return sortAsc ? cmp : -cmp; + }); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { setSortAsc(!sortAsc); } + else { setSortKey(key); setSortAsc(true); } + }; + + const sortIndicator = (key: SortKey) => { + if (sortKey !== key) return ''; + return sortAsc ? ' \u25B2' : ' \u25BC'; + }; + + const handleComplete = (taskId: number) => { + setAnimatedTask(taskId); + onCompleteTask(taskId); + setTimeout(() => setAnimatedTask(null), 2200); + }; + + const startEdit = (task: Task) => { + const { value, unit } = daysToFreq(task.frequencyDays); + setEditingTask(task.id); + setEditForm({ name: task.name, notes: task.notes || '', freqValue: value, freqUnit: unit, effort: task.effort, health: task.health, iconKey: task.iconKey || 'sparkle' }); + }; + + const saveEdit = async () => { + if (!editingTask) return; + const frequencyDays = freqToDays(editForm.freqValue, editForm.freqUnit); + await api.updateTask(editingTask, { name: editForm.name, notes: editForm.notes, frequencyDays, effort: editForm.effort, health: editForm.health, iconKey: editForm.iconKey }); + setEditingTask(null); + onRefresh?.(); + }; + + const deleteTask = async (taskId: number) => { + await api.deleteTask(taskId); + setEditingTask(null); + onRefresh?.(); + }; + + const addTask = async () => { + if (!newTaskName.trim()) return; + const frequencyDays = freqToDays(newFreqValue, newFreqUnit); + await api.createTask(room.id, { + name: newTaskName.trim(), + notes: newTaskNotes.trim() || undefined, + frequencyDays, + effort: newTaskEffort, + health: newTaskHealth, + iconKey: newTaskIconKey, + }); + setNewTaskName(''); + setNewTaskNotes(''); + setNewFreqValue(7); + setNewFreqUnit('days'); + setNewTaskEffort(2); + setNewTaskHealth(100); + setNewTaskIconKey('sparkle'); + setShowAddTask(false); + onRefresh?.(); + }; + + const colStyle = (key: SortKey): React.CSSProperties => ({ + cursor: 'pointer', userSelect: 'none', + color: sortKey === key ? 'var(--warm-accent)' : 'var(--warm-text-light)', + }); + + return ( +
+ {/* Header */} +
+ +
+
+

{roomDisplayName(room.name, room.roomType)}

+
{room.tasks.length} {t('rooms.tasksTracked')}
+
+ +
+ + {/* Task Table */} +
+ {/* Column Headers (sortable) */} +
+
handleSort('name')}>{t('history.task')}{sortIndicator('name')}
+
handleSort('health')}>{t('rooms.health')}{sortIndicator('health')}
+
handleSort('frequency')}>{t('roomDetail.frequency')}{sortIndicator('frequency')}
+
handleSort('effort')}>{t('roomDetail.effort')}{sortIndicator('effort')}
+
{t('roomDetail.actions')}
+
+ + {sortedTasks.map((task) => ( +
+ {editingTask === task.id ? ( +
+
+
+ + setEditForm(f => ({ ...f, name: e.target.value }))} + style={{ + flex: 1, padding: '8px 12px', borderRadius: 10, border: '1.5px solid var(--warm-border)', + fontSize: 13, fontFamily: 'Nunito', fontWeight: 700, color: 'var(--warm-text)', + outline: 'none', backgroundColor: 'var(--warm-bg-input)', + }} /> +
+
+ +