Commit 34f956fc by Sathish A

Initial commit: Spin The Wheel - Full-stack wheel spinner application

parents
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
# Production
dist/
build/
# Misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Prisma
backend/prisma/migrations/
# Spin The Wheel - Random Name Picker
A full-stack wheel spinner application with support for up to 10,000 entries, built with React, Node.js, and PostgreSQL.
## Features
- **Wheel Spinning**: Physics-based animation with cryptographically secure random selection
- **Large Dataset Support**: Handle up to 10,000 entries (10x the original wheelofnames.com limit)
- **Customization**: Extensive settings for spin behavior, appearance, and post-spin actions
- **Results Management**: Track spin history with results tab
- **Real-time Database Sync**: Automatic saving of wheels and results
- **File Upload**: Import entries from CSV/TXT files
- **Dynamic Font Sizing**: Text automatically adjusts to fit full names within segments
## Technology Stack
- **Frontend**: React 18 + TypeScript + Vite
- **Backend**: Node.js + Express + TypeScript
- **Database**: PostgreSQL + Prisma ORM
- **Randomness**: crypto.getRandomValues() / crypto.randomBytes()
## Project Structure
```
wheeler/
├── frontend/ # React frontend application
├── backend/ # Express backend API
└── README.md
```
## Quick Start
### Prerequisites
- Node.js 18+ and npm
- PostgreSQL database
### Option 1: Combined Setup (Recommended)
1. **Install all dependencies, generate Prisma client, and run migrations:**
```bash
npm run setup
```
2. **Configure backend environment:**
```bash
cd backend
cp .env.example .env
```
3. **Update `backend/.env` with your PostgreSQL connection:**
```env
DATABASE_URL="postgresql://user:password@localhost:5432/wheeler?schema=public"
PORT=5000
NODE_ENV=development
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
```
4. **Start both frontend and backend:**
```bash
npm run dev
```
The frontend will run on `http://localhost:3000` and backend on `http://localhost:5000`
### Option 2: Manual Setup
#### Backend Setup
1. Navigate to backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
npm install
```
3. Create `.env` file:
```bash
cp .env.example .env
```
4. Update `.env` with your PostgreSQL connection string:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/wheeler?schema=public"
PORT=5000
NODE_ENV=development
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
```
5. Generate Prisma client:
```bash
npm run db:generate
```
6. Run database migrations:
```bash
npm run db:migrate
```
7. Start the backend server:
```bash
npm run dev
```
#### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
## Available Scripts
### Root Level (Combined)
- `npm run setup` - Install all dependencies, generate Prisma client, and run migrations
- `npm run install:all` - Install dependencies for both frontend and backend
- `npm run dev` - Start both frontend and backend in development mode
- `npm run build` - Build both frontend and backend for production
- `npm run start` - Start both frontend and backend in production mode
- `npm run db:generate` - Generate Prisma client
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Prisma Studio
### Backend Scripts
- `npm run dev` - Start development server with hot reload
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run db:generate` - Generate Prisma client
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Prisma Studio
### Frontend Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
## Usage
1. **Add Entries**:
- Type entries in the text area, one per line
- Or click "Upload CSV" to import from a file
- Supports up to 10,000 entries
2. **Customize**: Click "Customize" to adjust:
- Spin settings (sound, volume, spin time, max visible names)
- Appearance (colors, themes, shadows, contours)
- Post-spin behavior (confetti, popup, auto-remove winner)
3. **Spin**: Click the wheel or press Ctrl+Enter to spin
4. **View Results**: Switch to the Results tab to see spin history
5. **Real-time Sync**: All changes are automatically saved to the database
## Large Dataset Support
For wheels with more than 1,000 entries:
- Selection happens on the backend for optimal performance
- Frontend displays a configurable subset (up to 10,000 visible entries)
- All entries have equal chance of winning regardless of visibility
## Environment Variables
### Backend (.env)
```env
DATABASE_URL="postgresql://user:password@localhost:5432/wheeler?schema=public"
PORT=5000
NODE_ENV=development
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://yourdomain.com
```
- `DATABASE_URL`: PostgreSQL connection string
- `PORT`: Backend server port (default: 5000)
- `NODE_ENV`: Environment (development/production)
- `ALLOWED_ORIGINS`: Comma-separated list of allowed frontend URLs for CORS
## API Endpoints
### Wheels
- `POST /api/wheels` - Create new wheel
- `GET /api/wheels/:id` - Get wheel
- `GET /api/wheels/recent` - Get most recent wheel
- `PUT /api/wheels/:id` - Update wheel
- `DELETE /api/wheels/:id` - Delete wheel
- `GET /api/wheels/gallery` - Get public wheels
- `POST /api/wheels/:id/share` - Generate share link
### Entries
- `GET /api/entries/:wheelId` - Get entries
- `POST /api/entries/:wheelId` - Add entries
- `DELETE /api/entries/:wheelId/:entryId` - Remove entry
- `DELETE /api/entries/:wheelId/clear` - Clear all entries
### Spin
- `POST /api/spin` - Perform spin (returns winner)
### Results
- `GET /api/results/:wheelId` - Get results
- `POST /api/results/:wheelId` - Add result
- `DELETE /api/results/:wheelId/:resultId` - Remove result
- `DELETE /api/results/:wheelId` - Clear all results
## Development
### Running in Development
From the root directory:
```bash
npm run dev
```
This starts both frontend (port 3000) and backend (port 5000) concurrently.
### Database Management
- **Prisma Studio**: `npm run db:studio` - Visual database browser
- **Migrations**: `npm run db:migrate` - Run pending migrations
- **Generate Client**: `npm run db:generate` - Regenerate Prisma client after schema changes
## Production Deployment
1. Build both frontend and backend:
```bash
npm run build
```
2. Set production environment variables in `backend/.env`
3. Start production servers:
```bash
npm run start
```
## License
MIT
DATABASE_URL="postgresql://postgres:pass@localhost:5432/wheeler?schema=public"
PORT=5000
NODE_ENV=development
\ No newline at end of file
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
*.local
# Environment variables
.env
.env.test
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
{
"name": "wheeler-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wheeler-backend",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.7.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.5",
"@types/uuid": "^9.0.7",
"prisma": "^5.7.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"dev": true,
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"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/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/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==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"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/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
{
"name": "spin-the-wheel-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"@prisma/client": "^5.7.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.5",
"@types/uuid": "^9.0.7",
"prisma": "^5.7.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Wheel {
id String @id @default(uuid())
name String @default("Untitled Wheel")
userId String?
isPublic Boolean @default(false)
shareToken String? @unique
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
entries Entry[]
results Result[]
@@index([userId])
@@index([shareToken])
}
model Entry {
id String @id @default(uuid())
wheelId String
text String
imageUrl String?
weight Float?
color String?
order Int @default(0)
createdAt DateTime @default(now())
wheel Wheel @relation(fields: [wheelId], references: [id], onDelete: Cascade)
results Result[]
@@index([wheelId])
@@index([wheelId, order])
}
model Result {
id String @id @default(uuid())
wheelId String
entryId String
spunAt DateTime @default(now())
wheel Wheel @relation(fields: [wheelId], references: [id], onDelete: Cascade)
entry Entry @relation(fields: [entryId], references: [id], onDelete: Cascade)
@@index([wheelId])
@@index([entryId])
@@index([spunAt])
}
import { Request, Response } from 'express'
import prisma from '../services/prisma.js'
import { EntryProcessor } from '../services/entryProcessor.js'
export const validateEntries = async (req: Request, res: Response) => {
try {
const { entries } = req.body
if (!Array.isArray(entries)) {
return res.status(400).json({ error: 'Entries must be an array' })
}
const validation = EntryProcessor.validateEntries(entries)
res.json(validation)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const processEntries = async (req: Request, res: Response) => {
try {
const { entries, maxVisible } = req.body
if (!Array.isArray(entries)) {
return res.status(400).json({ error: 'Entries must be an array' })
}
const processed = EntryProcessor.processEntries(entries, maxVisible || 1000)
res.json(processed)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const getEntries = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
const entries = await prisma.entry.findMany({
where: { wheelId },
orderBy: { order: 'asc' },
})
res.json(entries)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const addEntries = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
const { entries } = req.body
if (!Array.isArray(entries)) {
return res.status(400).json({ error: 'Entries must be an array' })
}
// Get current max order
const maxOrder = await prisma.entry.aggregate({
where: { wheelId },
_max: { order: true },
})
const startOrder = (maxOrder._max.order ?? -1) + 1
const created = await prisma.entry.createMany({
data: entries.map((entry: any, index: number) => ({
wheelId,
text: entry.text || entry,
imageUrl: entry.imageUrl,
weight: entry.weight,
color: entry.color,
order: startOrder + index,
})),
})
res.json({ count: created.count })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const removeEntry = async (req: Request, res: Response) => {
try {
const { wheelId, entryId } = req.params
await prisma.entry.delete({
where: { id: entryId },
})
res.json({ message: 'Entry removed successfully' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const clearAllEntries = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
await prisma.entry.deleteMany({
where: { wheelId },
})
res.json({ message: 'All entries cleared successfully' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
import { Request, Response } from 'express'
import prisma from '../services/prisma.js'
export const getResults = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
const results = await prisma.result.findMany({
where: { wheelId },
include: {
entry: true,
},
orderBy: { spunAt: 'desc' },
})
res.json(results)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const addResult = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
const { entryId } = req.body
if (!entryId) {
return res.status(400).json({ error: 'Entry ID is required' })
}
const result = await prisma.result.create({
data: {
wheelId,
entryId,
},
include: {
entry: true,
},
})
res.json(result)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const removeResult = async (req: Request, res: Response) => {
try {
const { wheelId, resultId } = req.params
await prisma.result.delete({
where: { id: resultId },
})
res.json({ message: 'Result removed successfully' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const clearResults = async (req: Request, res: Response) => {
try {
const { wheelId } = req.params
await prisma.result.deleteMany({
where: { wheelId },
})
res.json({ message: 'Results cleared successfully' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
import { Request, Response } from 'express'
import prisma from '../services/prisma.js'
import { getSecureRandomEntry, getWeightedRandomEntry } from '../services/randomService.js'
export const performSpin = async (req: Request, res: Response) => {
try {
const { wheelId, entries: entriesArray } = req.body
let entries: any[] = []
if (wheelId) {
// Get all entries for the wheel from database
entries = await prisma.entry.findMany({
where: { wheelId },
orderBy: { order: 'asc' },
})
} else if (entriesArray && Array.isArray(entriesArray) && entriesArray.length > 0) {
// Use provided entries array (for large datasets without saved wheel)
entries = entriesArray.map((e: any, index: number) => ({
id: e.id || `temp-${index}`,
text: e.text || e,
weight: e.weight,
color: e.color,
}))
} else {
return res.status(400).json({ error: 'Either wheelId or entries array is required' })
}
if (entries.length === 0) {
return res.status(400).json({ error: 'No entries found' })
}
// Check if any entries have weights (advanced mode)
const hasWeights = entries.some((e) => e.weight !== null && e.weight !== undefined)
// Select random entry
const selectedEntry = hasWeights
? getWeightedRandomEntry(entries)
: getSecureRandomEntry(entries)
// Save result
const result = await prisma.result.create({
data: {
wheelId,
entryId: selectedEntry.id,
},
include: {
entry: true,
},
})
res.json({
winner: {
id: selectedEntry.id,
text: selectedEntry.text,
imageUrl: selectedEntry.imageUrl,
color: selectedEntry.color,
},
result: {
id: result.id,
spunAt: result.spunAt,
},
})
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
import { Request, Response } from 'express'
import prisma from '../services/prisma.js'
import { v4 as uuidv4 } from 'uuid'
export const createWheel = async (req: Request, res: Response) => {
try {
const { name, settings, entries } = req.body
const wheel = await prisma.wheel.create({
data: {
name: name || 'Untitled Wheel',
settings: settings || {},
entries: {
create: entries?.map((entry: any, index: number) => ({
text: entry.text,
imageUrl: entry.imageUrl,
weight: entry.weight,
color: entry.color,
order: index,
})) || [],
},
},
include: {
entries: true,
},
})
res.json(wheel)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const getWheel = async (req: Request, res: Response) => {
try {
const { id } = req.params
const wheel = await prisma.wheel.findUnique({
where: { id },
include: {
entries: {
orderBy: { order: 'asc' },
},
results: {
include: {
entry: true,
},
orderBy: { spunAt: 'desc' },
},
},
})
if (!wheel) {
return res.status(404).json({ error: 'Wheel not found' })
}
res.json(wheel)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const updateWheel = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { name, settings, isPublic } = req.body
const wheel = await prisma.wheel.update({
where: { id },
data: {
...(name && { name }),
...(settings && { settings }),
...(isPublic !== undefined && { isPublic }),
},
})
res.json(wheel)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const deleteWheel = async (req: Request, res: Response) => {
try {
const { id } = req.params
await prisma.wheel.delete({
where: { id },
})
res.json({ message: 'Wheel deleted successfully' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const getGalleryWheels = async (req: Request, res: Response) => {
try {
const wheels = await prisma.wheel.findMany({
where: { isPublic: true },
include: {
entries: {
take: 10, // Limit entries for gallery view
},
},
orderBy: { createdAt: 'desc' },
take: 20,
})
res.json(wheels)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const shareWheel = async (req: Request, res: Response) => {
try {
const { id } = req.params
const shareToken = uuidv4()
const wheel = await prisma.wheel.update({
where: { id },
data: { shareToken },
})
res.json({ shareToken, shareUrl: `/wheels/${shareToken}` })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
export const getRecentWheel = async (req: Request, res: Response) => {
try {
// Get the most recently updated wheel (or created if no updates)
const wheel = await prisma.wheel.findFirst({
orderBy: { updatedAt: 'desc' },
include: {
entries: {
orderBy: { order: 'asc' },
},
results: {
include: {
entry: true,
},
orderBy: { spunAt: 'desc' },
},
},
})
if (!wheel) {
return res.status(404).json({ error: 'No wheels found' })
}
res.json(wheel)
} catch (error: any) {
res.status(500).json({ error: error.message })
}
}
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import wheelRoutes from './routes/wheelRoutes.js'
import entryRoutes from './routes/entryRoutes.js'
import spinRoutes from './routes/spinRoutes.js'
import resultRoutes from './routes/resultRoutes.js'
dotenv.config()
const app = express()
const PORT = process.env.PORT || 5000
// CORS configuration - allow dynamic frontend URL
const corsOptions = {
origin: function (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true)
// Get allowed origins from environment variable or use default
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
: ['http://localhost:3000', 'http://localhost:5173']
// Check if origin is allowed
if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
callback(null, true)
} else {
// For production, you might want to be more strict
// For development, allow all origins
if (process.env.NODE_ENV === 'development') {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
}
},
credentials: true,
optionsSuccessStatus: 200
}
// Middleware
app.use(cors(corsOptions))
app.use(express.json())
// Routes
app.use('/api/wheels', wheelRoutes)
app.use('/api/entries', entryRoutes)
app.use('/api/spin', spinRoutes)
app.use('/api/results', resultRoutes)
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Spin The Wheel API is running' })
})
app.listen(PORT, () => {
// Server started successfully
})
import express from 'express'
import {
validateEntries,
processEntries,
getEntries,
addEntries,
removeEntry,
clearAllEntries,
} from '../controllers/entryController.js'
const router = express.Router()
router.post('/validate', validateEntries)
router.post('/process', processEntries)
router.get('/:wheelId', getEntries)
router.post('/:wheelId', addEntries)
router.delete('/:wheelId/clear', clearAllEntries)
router.delete('/:wheelId/:entryId', removeEntry)
export default router
import express from 'express'
import {
getResults,
addResult,
removeResult,
clearResults,
} from '../controllers/resultController.js'
const router = express.Router()
router.get('/:wheelId', getResults)
router.post('/:wheelId', addResult)
router.delete('/:wheelId/:resultId', removeResult)
router.delete('/:wheelId', clearResults)
export default router
import express from 'express'
import { performSpin } from '../controllers/spinController.js'
const router = express.Router()
router.post('/', performSpin)
export default router
import express from 'express'
import {
createWheel,
getWheel,
updateWheel,
deleteWheel,
getGalleryWheels,
shareWheel,
getRecentWheel,
} from '../controllers/wheelController.js'
const router = express.Router()
router.post('/', createWheel)
router.get('/gallery', getGalleryWheels)
router.get('/recent', getRecentWheel)
router.get('/:id', getWheel)
router.put('/:id', updateWheel)
router.delete('/:id', deleteWheel)
router.post('/:id/share', shareWheel)
export default router
import { Entry } from '../types/index.js'
/**
* Process and validate large entry lists (up to 10,000 entries)
* Optimizes entries for wheel rendering
*/
export class EntryProcessor {
/**
* Validate entry list
*/
static validateEntries(entries: string[]): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (entries.length === 0) {
errors.push('At least one entry is required')
}
if (entries.length > 10000) {
errors.push('Maximum 10,000 entries allowed')
}
// Check for empty entries
const emptyEntries = entries.filter((e) => !e.trim())
if (emptyEntries.length > 0) {
errors.push(`${emptyEntries.length} empty entries found`)
}
// Check for extremely long entries
const longEntries = entries.filter((e) => e.length > 200)
if (longEntries.length > 0) {
errors.push(`${longEntries.length} entries exceed 200 characters`)
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Process entries for wheel display
* Returns optimized subset for frontend rendering
*/
static processEntries(
entries: Entry[],
maxVisible: number
): {
visible: Entry[]
total: number
hasMore: boolean
} {
const visible = entries.slice(0, maxVisible)
const total = entries.length
const hasMore = total > maxVisible
return {
visible,
total,
hasMore,
}
}
/**
* Batch process entries for large datasets
*/
static batchProcessEntries(
entries: Entry[],
batchSize: number = 1000
): Entry[][] {
const batches: Entry[][] = []
for (let i = 0; i < entries.length; i += batchSize) {
batches.push(entries.slice(i, i + batchSize))
}
return batches
}
}
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
import crypto from 'crypto'
/**
* Cryptographically secure random selection from an array
* Uses crypto.randomBytes for true randomness
*/
export function getSecureRandomEntry<T>(entries: T[]): T {
if (entries.length === 0) {
throw new Error('Cannot select from empty array')
}
// Generate cryptographically secure random bytes
const randomBytes = crypto.randomBytes(4)
const randomValue = randomBytes.readUInt32BE(0) / (0xFFFFFFFF + 1)
const index = Math.floor(randomValue * entries.length)
return entries[index]
}
/**
* Select random entry with weights (for advanced mode)
*/
export function getWeightedRandomEntry<T extends { weight?: number }>(
entries: T[]
): T {
if (entries.length === 0) {
throw new Error('Cannot select from empty array')
}
// Calculate total weight
const totalWeight = entries.reduce((sum, entry) => sum + (entry.weight || 1), 0)
// Generate random value
const randomBytes = crypto.randomBytes(4)
const randomValue = (randomBytes.readUInt32BE(0) / (0xFFFFFFFF + 1)) * totalWeight
// Find entry based on weighted random
let currentWeight = 0
for (const entry of entries) {
currentWeight += entry.weight || 1
if (randomValue <= currentWeight) {
return entry
}
}
// Fallback to last entry
return entries[entries.length - 1]
}
export interface Entry {
id?: string
text: string
imageUrl?: string
weight?: number
color?: string
order?: number
}
export interface WheelSettings {
duringSpin: {
sound: string
volume: number
displayDuplicates: boolean
spinSlowly: boolean
showTitle: boolean
spinTime: number
maxVisibleNames: number
}
afterSpin: {
sound: string
volume: number
animateWinningEntry: boolean
launchConfetti: boolean
autoRemoveWinner: number | null
displayPopup: boolean
popupMessage: string
displayRemoveButton: boolean
playClickSoundOnRemove: boolean
}
appearance: {
colorScheme: 'one-color' | 'background-image'
theme: string
colors: string[]
centerImage?: string
centerImageSize: 'S' | 'M' | 'L'
pageBackgroundColor?: string
displayGradient: boolean
contours: boolean
wheelShadow: boolean
pointerChangesColor: boolean
}
}
export interface Result {
id?: string
entryId: string
entryText: string
spunAt?: Date
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
VITE_APP_URL=http://localhost:5000
\ No newline at end of file
# 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?
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spin The Wheel - Random Name Picker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "spin-the-wheel-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.4.7",
"axios": "^1.6.2",
"canvas-confetti": "^1.9.2",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #e8f4f8 0%, #f5e8f5 100%);
transition: background 0.5s;
}
.app-header {
height: 64px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid #e2e8f0;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: #2563eb;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.logo-icon:hover {
transform: rotate(12deg);
}
.app-header h1 {
font-size: 20px;
font-weight: 900;
color: #1e293b;
letter-spacing: -0.5px;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon-btn {
padding: 10px;
color: #475569;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon-btn:hover {
background: #f1f5f9;
color: #1e293b;
}
.header-divider {
width: 1px;
height: 24px;
background: #e2e8f0;
margin: 0 8px;
}
.btn-customize {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #1e293b;
color: white;
border: none;
border-radius: 9999px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-customize:hover {
background: #0f172a;
}
.btn-customize:active {
transform: scale(0.95);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.wheel-area {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar {
width: 400px;
height: 100%;
background: white;
border-left: 1px solid #e2e8f0;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
z-index: 20;
}
/* Winner Popup Overlay */
.winner-popup-overlay {
position: absolute;
inset: 0;
z-index: 40;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.winner-popup-card {
background: white;
border-radius: 24px;
padding: 32px;
max-width: 384px;
width: 100%;
text-align: center;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border-top: 8px solid #2563eb;
animation: scaleIn 0.2s;
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.winner-trophy-icon {
width: 80px;
height: 80px;
background: #dbeafe;
color: #2563eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.winner-popup-title {
font-size: 20px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 4px 0;
}
.winner-name {
font-size: 36px;
font-weight: 900;
color: #1e293b;
margin: 0 0 32px 0;
word-break: break-word;
line-height: 1.2;
}
.winner-popup-buttons {
display: flex;
gap: 12px;
}
.btn-popup-close {
flex: 1;
padding: 12px;
background: #f1f5f9;
color: #475569;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.btn-popup-close:hover {
background: #e2e8f0;
color: #1e293b;
}
.btn-popup-remove {
flex: 1;
padding: 12px;
background: #2563eb;
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2);
}
.btn-popup-remove:hover {
background: #1d4ed8;
}
import { useState, useEffect } from 'react'
import WheelCanvas from './components/Wheel/WheelCanvas'
import EntryManager from './components/EntryManager/EntryManager'
import CustomizationModal from './components/Customization/CustomizationModal'
import { useWheelStore } from './store/wheelStore'
import { wheelApi, entryApi, resultApi } from './services/api'
import { Settings, Trophy } from 'lucide-react'
import './App.css'
function App() {
const [showCustomize, setShowCustomize] = useState(false)
const {
settings,
lastWinner,
setLastWinner,
removeEntry,
currentWheelId,
setWheelId,
loadEntries,
loadResults,
loadSettings,
} = useWheelStore()
// Load wheel data from database when wheelId changes or on mount
useEffect(() => {
const loadWheelData = async () => {
let wheelId = currentWheelId
// If no wheelId, try to get from localStorage or load most recent wheel
if (!wheelId) {
// Try to get from localStorage first
const savedWheelId = localStorage.getItem('spin-the-wheel_currentWheelId')
if (savedWheelId) {
wheelId = savedWheelId
setWheelId(wheelId)
} else {
// Try to get the most recent wheel from database
try {
const recentResponse = await wheelApi.getRecent()
const latestWheel = recentResponse.data
if (latestWheel && latestWheel.id) {
wheelId = latestWheel.id
setWheelId(wheelId)
localStorage.setItem('spin-the-wheel_currentWheelId', wheelId)
} else {
// No wheels exist, start with empty state
loadEntries([])
loadResults([])
return
}
} catch (error: any) {
// If 404, no wheels exist yet - that's fine
if (error.response?.status === 404) {
loadEntries([])
loadResults([])
return
}
loadEntries([])
loadResults([])
return
}
}
}
if (!wheelId) {
loadEntries([])
loadResults([])
return
}
try {
// Load wheel with entries and results
const wheelResponse = await wheelApi.get(wheelId)
const wheel = wheelResponse.data
// Load entries
if (wheel.entries && Array.isArray(wheel.entries) && wheel.entries.length > 0) {
const entries = wheel.entries.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl || null,
weight: e.weight || null,
color: e.color || null,
}))
loadEntries(entries)
} else {
loadEntries([])
}
// Load results
if (wheel.results && Array.isArray(wheel.results)) {
const results = wheel.results.map((r: any) => ({
id: r.id,
entryId: r.entryId,
entryText: r.entry?.text || '',
spunAt: new Date(r.spunAt),
}))
loadResults(results)
} else {
loadResults([])
}
// Load settings if available
if (wheel.settings) {
loadSettings(wheel.settings)
}
} catch (error) {
// If wheel doesn't exist, clear the wheelId and localStorage
setWheelId(null)
localStorage.removeItem('spin-the-wheel_currentWheelId')
loadEntries([])
loadResults([])
}
}
loadWheelData()
}, [currentWheelId, loadEntries, loadResults, loadSettings, setWheelId])
const handleRemoveWinner = () => {
if (lastWinner) {
if (settings.afterSpin.playClickSoundOnRemove) {
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(800, ctx.currentTime)
gain.gain.setValueAtTime(0.1, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.1)
} catch (e) {}
}
removeEntry(lastWinner.id)
setLastWinner(null)
}
}
return (
<div className="app" style={{ background: settings.appearance.displayGradient ? 'linear-gradient(135deg, #e8f4f8 0%, #f5e8f5 100%)' : '#e8f4f8' }}>
{/* Header */}
<header className="app-header">
<div className="header-logo">
<div className="logo-icon">W</div>
<h1>SPIN THE WHEEL</h1>
</div>
<div className="header-actions">
<button
onClick={() => setShowCustomize(true)}
className="btn-customize"
>
<Settings size={18} /> Customize
</button>
</div>
</header>
{/* Main Content */}
<main className="app-main">
{/* Wheel Area */}
<div className="wheel-area">
<WheelCanvas />
{/* Winner Popup Overlay */}
{lastWinner && settings.afterSpin.displayPopup && (
<div className="winner-popup-overlay">
<div className="winner-popup-card">
<div className="winner-trophy-icon">
<Trophy size={48} />
</div>
<h2 className="winner-popup-title">
{settings.afterSpin.popupMessage}
</h2>
<div className="winner-name">
{lastWinner.text}
</div>
<div className="winner-popup-buttons">
<button
onClick={() => setLastWinner(null)}
className="btn-popup-close"
>
Close
</button>
{settings.afterSpin.displayRemoveButton && (
<button
onClick={handleRemoveWinner}
className="btn-popup-remove"
>
Remove
</button>
)}
</div>
</div>
</div>
)}
</div>
{/* Sidebar */}
<aside className="sidebar">
<EntryManager />
</aside>
</main>
{/* Modals */}
{showCustomize && (
<CustomizationModal onClose={() => setShowCustomize(false)} />
)}
</div>
)
}
export default App
import { useRef } from 'react'
import { WheelSettings } from '../../types'
import { Play, Square } from 'lucide-react'
import './settings.css'
interface AfterSpinSettingsProps {
settings: WheelSettings['afterSpin']
onUpdate: (updates: Partial<WheelSettings['afterSpin']>) => void
}
export default function AfterSpinSettings({
settings,
onUpdate,
}: AfterSpinSettingsProps) {
const audioContextRef = useRef<AudioContext | null>(null)
const activeOscillatorsRef = useRef<OscillatorNode[]>([])
const initAudio = () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume()
}
return audioContextRef.current
}
const stopAllSounds = () => {
activeOscillatorsRef.current.forEach(osc => {
try {
osc.stop()
osc.disconnect()
} catch (e) {}
})
activeOscillatorsRef.current = []
}
const playPreview = () => {
if (settings.sound === 'none' || settings.volume === 0) return
const ctx = initAudio()
stopAllSounds()
// Victory sounds (arpeggio)
const freqs = [392.00, 493.88, 587.33, 783.99] // G Major arpeggio
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
const startTime = ctx.currentTime + (i * 0.12)
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, startTime)
gain.gain.setValueAtTime(0, startTime)
gain.gain.linearRampToValueAtTime((settings.volume / 100) * 0.15, startTime + 0.05)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 1.2)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(startTime)
osc.stop(startTime + 1.2)
activeOscillatorsRef.current.push(osc)
})
}
return (
<div className="settings-panel">
<div className="setting-group">
<label className="setting-label">Sound</label>
<div className="sound-control-group">
<select
value={settings.sound}
onChange={(e) => onUpdate({ sound: e.target.value })}
className="setting-select"
>
<option value="applause">Subdued applause</option>
<option value="cheer">Cheer</option>
<option value="none">None</option>
</select>
<div className="audio-preview-buttons">
<button
onClick={playPreview}
className="audio-preview-btn"
title="Play preview"
>
<Play size={16} fill="currentColor" />
</button>
<button
onClick={stopAllSounds}
className="audio-preview-btn"
title="Stop"
>
<Square size={16} fill="currentColor" />
</button>
</div>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Volume</label>
<div className="slider-container">
<input
type="range"
min="0"
max="100"
value={settings.volume}
onChange={(e) => onUpdate({ volume: parseInt(e.target.value) })}
className="setting-slider"
/>
<span className="slider-value">{settings.volume}%</span>
</div>
<div className="slider-markers">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.animateWinningEntry}
onChange={(e) =>
onUpdate({ animateWinningEntry: e.target.checked })
}
/>
<span>Animate winning entry</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.launchConfetti}
onChange={(e) => onUpdate({ launchConfetti: e.target.checked })}
/>
<span>Launch confetti</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.autoRemoveWinner !== null}
onChange={(e) =>
onUpdate({ autoRemoveWinner: e.target.checked ? 5 : null })
}
/>
<span>Auto-remove winner after 5 seconds</span>
</label>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.displayPopup}
onChange={(e) => onUpdate({ displayPopup: e.target.checked })}
/>
<span>Display popup with message:</span>
</label>
{settings.displayPopup && (
<input
type="text"
value={settings.popupMessage}
onChange={(e) => onUpdate({ popupMessage: e.target.value })}
className="setting-input"
placeholder="We have a winner!"
/>
)}
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.displayRemoveButton}
onChange={(e) =>
onUpdate({ displayRemoveButton: e.target.checked })
}
/>
<span>Display the "Remove" button</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.playClickSoundOnRemove}
onChange={(e) =>
onUpdate({ playClickSoundOnRemove: e.target.checked })
}
/>
<span>Play a click sound when the winner is removed</span>
</label>
</div>
</div>
)
}
import { WheelSettings } from '../../types'
import './settings.css'
interface AppearanceSettingsProps {
settings: WheelSettings['appearance']
onUpdate: (updates: Partial<WheelSettings['appearance']>) => void
}
export default function AppearanceSettings({
settings,
onUpdate,
}: AppearanceSettingsProps) {
const defaultColors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#FFA07A',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
]
const toggleColor = (color: string) => {
const newColors = settings.colors.includes(color)
? settings.colors.filter((c) => c !== color)
: [...settings.colors, color]
onUpdate({ colors: newColors })
}
return (
<div className="settings-panel">
<div className="setting-group">
<div className="color-scheme-selector">
<label className="color-scheme-option">
<input
type="radio"
name="colorScheme"
value="one-color"
checked={settings.colorScheme === 'one-color'}
onChange={(e) =>
onUpdate({ colorScheme: e.target.value as 'one-color' | 'background-image' })
}
/>
<span>One color per section</span>
</label>
<label className="color-scheme-option">
<input
type="radio"
name="colorScheme"
value="background-image"
checked={settings.colorScheme === 'background-image'}
onChange={(e) =>
onUpdate({ colorScheme: e.target.value as 'one-color' | 'background-image' })
}
/>
<span>Wheel background image</span>
</label>
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<button className="btn-theme">Apply a theme</button>
<div className="setting-label-with-help">
<label className="setting-label">Customize colors</label>
<button className="help-btn">?</button>
</div>
<div className="color-palette">
{defaultColors.map((color, index) => (
<label key={index} className="color-swatch">
<input
type="checkbox"
checked={settings.colors.includes(color)}
onChange={() => toggleColor(color)}
/>
<span
className="color-box"
style={{ backgroundColor: color }}
></span>
</label>
))}
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-label">Image at the center of the wheel</label>
<button className="btn-image">Add image</button>
<div className="setting-group-inline">
<label className="setting-label">Image size</label>
<select
value={settings.centerImageSize}
onChange={(e) =>
onUpdate({ centerImageSize: e.target.value as 'S' | 'M' | 'L' })
}
className="setting-select"
>
<option value="S">S</option>
<option value="M">M</option>
<option value="L">L</option>
</select>
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.displayGradient}
onChange={(e) => onUpdate({ displayGradient: e.target.checked })}
/>
<span>Display a color gradient on the page</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.contours}
onChange={(e) => onUpdate({ contours: e.target.checked })}
/>
<span>Contours</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.wheelShadow}
onChange={(e) => onUpdate({ wheelShadow: e.target.checked })}
/>
<span>Wheel shadow</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.pointerChangesColor}
onChange={(e) =>
onUpdate({ pointerChangesColor: e.target.checked })
}
/>
<span>Pointer changes color</span>
</label>
</div>
</div>
)
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
color: #999;
cursor: pointer;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f5f5f5;
color: #333;
}
.modal-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
}
.modal-tab {
flex: 1;
padding: 1rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: #666;
transition: all 0.2s;
border-bottom: 2px solid transparent;
}
.modal-tab:hover {
background: #f5f5f5;
}
.modal-tab.active {
color: #667eea;
border-bottom-color: #667eea;
font-weight: 600;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.btn-cancel,
.btn-ok {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-cancel {
background: #f5f5f5;
color: #333;
}
.btn-cancel:hover {
background: #e0e0e0;
}
.btn-ok {
background: #667eea;
color: white;
}
.btn-ok:hover {
background: #5568d3;
}
import { useState } from 'react'
import { useWheelStore } from '../../store/wheelStore'
import DuringSpinSettings from './DuringSpinSettings'
import AfterSpinSettings from './AfterSpinSettings'
import AppearanceSettings from './AppearanceSettings'
import './CustomizationModal.css'
interface CustomizationModalProps {
onClose: () => void
}
export default function CustomizationModal({ onClose }: CustomizationModalProps) {
const [activeTab, setActiveTab] = useState<'during' | 'after' | 'appearance'>('during')
const { settings, updateSettings } = useWheelStore()
const handleSave = () => {
// Settings are already updated in the store via individual components
onClose()
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Customize Wheel</h2>
<button onClick={onClose} className="modal-close">
×
</button>
</div>
<div className="modal-tabs">
<button
className={`modal-tab ${activeTab === 'during' ? 'active' : ''}`}
onClick={() => setActiveTab('during')}
>
During spin
</button>
<button
className={`modal-tab ${activeTab === 'after' ? 'active' : ''}`}
onClick={() => setActiveTab('after')}
>
After spin
</button>
<button
className={`modal-tab ${activeTab === 'appearance' ? 'active' : ''}`}
onClick={() => setActiveTab('appearance')}
>
Appearance
</button>
</div>
<div className="modal-body">
{activeTab === 'during' && (
<DuringSpinSettings
settings={settings.duringSpin}
onUpdate={(updates) =>
updateSettings({ duringSpin: { ...settings.duringSpin, ...updates } })
}
/>
)}
{activeTab === 'after' && (
<AfterSpinSettings
settings={settings.afterSpin}
onUpdate={(updates) =>
updateSettings({ afterSpin: { ...settings.afterSpin, ...updates } })
}
/>
)}
{activeTab === 'appearance' && (
<AppearanceSettings
settings={settings.appearance}
onUpdate={(updates) =>
updateSettings({ appearance: { ...settings.appearance, ...updates } })
}
/>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn-cancel">
Cancel
</button>
<button onClick={handleSave} className="btn-ok">
OK
</button>
</div>
</div>
</div>
)
}
import { useRef, useEffect } from 'react'
import { WheelSettings } from '../../types'
import { Play, Square } from 'lucide-react'
import './settings.css'
interface DuringSpinSettingsProps {
settings: WheelSettings['duringSpin']
onUpdate: (updates: Partial<WheelSettings['duringSpin']>) => void
}
export default function DuringSpinSettings({
settings,
onUpdate,
}: DuringSpinSettingsProps) {
// Clamp maxVisibleNames to valid range on mount
useEffect(() => {
if (settings.maxVisibleNames < 4 || settings.maxVisibleNames > 10000) {
onUpdate({ maxVisibleNames: Math.max(4, Math.min(10000, settings.maxVisibleNames)) })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run on mount
const audioContextRef = useRef<AudioContext | null>(null)
const activeOscillatorsRef = useRef<OscillatorNode[]>([])
// Logarithmic scale conversion for maxVisibleNames slider
// Maps linear slider position (0-100) to logarithmic value (4-10000)
const logToLinear = (value: number): number => {
const min = 4
const max = 10000
const minLog = Math.log(min)
const maxLog = Math.log(max)
const valueLog = Math.log(value)
return ((valueLog - minLog) / (maxLog - minLog)) * 100
}
const linearToLog = (linear: number): number => {
const min = 4
const max = 10000
const minLog = Math.log(min)
const maxLog = Math.log(max)
const valueLog = minLog + (linear / 100) * (maxLog - minLog)
return Math.round(Math.exp(valueLog))
}
const initAudio = () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume()
}
return audioContextRef.current
}
const stopAllSounds = () => {
activeOscillatorsRef.current.forEach(osc => {
try {
osc.stop()
osc.disconnect()
} catch (e) {}
})
activeOscillatorsRef.current = []
}
const playPreview = () => {
if (settings.sound === 'none' || settings.volume === 0) return
const ctx = initAudio()
stopAllSounds()
// Play a quick sequence of 3 ticks for preview
for (let i = 0; i < 3; i++) {
const startTime = ctx.currentTime + (i * 0.15)
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, startTime)
osc.frequency.exponentialRampToValueAtTime(150, startTime + 0.05)
gain.gain.setValueAtTime((settings.volume / 100) * 0.3, startTime)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.05)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(startTime)
osc.stop(startTime + 0.05)
activeOscillatorsRef.current.push(osc)
}
}
return (
<div className="settings-panel">
<div className="setting-group">
<label className="setting-label">Sound</label>
<div className="sound-control-group">
<select
value={settings.sound}
onChange={(e) => onUpdate({ sound: e.target.value })}
className="setting-select"
>
<option value="ticking">Ticking sound</option>
<option value="none">None</option>
</select>
<div className="audio-preview-buttons">
<button
onClick={playPreview}
className="audio-preview-btn"
title="Play preview"
>
<Play size={16} fill="currentColor" />
</button>
<button
onClick={stopAllSounds}
className="audio-preview-btn"
title="Stop"
>
<Square size={16} fill="currentColor" />
</button>
</div>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Volume</label>
<div className="slider-container">
<input
type="range"
min="0"
max="100"
value={settings.volume}
onChange={(e) => onUpdate({ volume: parseInt(e.target.value) })}
className="setting-slider"
/>
<span className="slider-value">{settings.volume}%</span>
</div>
<div className="slider-markers">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.displayDuplicates}
onChange={(e) => onUpdate({ displayDuplicates: e.target.checked })}
/>
<span>Display duplicates</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.spinSlowly}
onChange={(e) => onUpdate({ spinSlowly: e.target.checked })}
/>
<span>Spin slowly</span>
</label>
<label className="setting-checkbox">
<input
type="checkbox"
checked={settings.showTitle}
onChange={(e) => onUpdate({ showTitle: e.target.checked })}
/>
<span>Show title</span>
</label>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-label">Spin time (seconds)</label>
<div className="slider-container">
<input
type="range"
min="1"
max="60"
value={settings.spinTime}
onChange={(e) => onUpdate({ spinTime: parseInt(e.target.value) })}
className="setting-slider"
/>
<span className="slider-value">{settings.spinTime}</span>
</div>
<div className="slider-markers">
<span>1</span>
<span>10</span>
<span>20</span>
<span>30</span>
<span>40</span>
<span>50</span>
<span>60</span>
</div>
</div>
<hr className="setting-separator" />
<div className="setting-group">
<label className="setting-label">
Max number of names visible on the wheel
</label>
<p className="setting-description">
All names in the text-box have the same chance of winning, regardless
of this value.
</p>
<div className="slider-container">
<input
type="range"
min="0"
max="100"
step="0.1"
value={logToLinear(Math.max(4, Math.min(10000, settings.maxVisibleNames)))}
onChange={(e) => {
const linearValue = parseFloat(e.target.value)
const logValue = linearToLog(linearValue)
onUpdate({ maxVisibleNames: Math.max(4, Math.min(10000, logValue)) })
}}
className="setting-slider"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${logToLinear(Math.max(4, Math.min(10000, settings.maxVisibleNames)))}%, #e2e8f0 ${logToLinear(Math.max(4, Math.min(10000, settings.maxVisibleNames)))}%, #e2e8f0 100%)`
}}
/>
<span className="slider-value">{Math.max(4, Math.min(10000, settings.maxVisibleNames))}</span>
</div>
<div className="slider-markers">
<span>4</span>
<span>100</span>
<span>500</span>
<span>1000</span>
<span>5000</span>
<span>10000</span>
</div>
</div>
</div>
)
}
.settings-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.setting-group-inline {
display: flex;
align-items: center;
gap: 1rem;
}
.setting-label {
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.setting-label-with-help {
display: flex;
align-items: center;
gap: 0.5rem;
}
.help-btn {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #ddd;
background: white;
color: #999;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.help-btn:hover {
background: #f5f5f5;
border-color: #667eea;
color: #667eea;
}
.setting-description {
font-size: 0.85rem;
color: #666;
margin: 0;
}
.setting-select,
.setting-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
}
.setting-select:focus,
.setting-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.setting-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #ddd;
outline: none;
-webkit-appearance: none;
}
.setting-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.setting-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.slider-container {
display: flex;
align-items: center;
gap: 1rem;
}
.slider-value {
min-width: 50px;
text-align: right;
font-weight: 600;
color: #667eea;
}
.slider-markers {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #999;
margin-top: -0.5rem;
}
.setting-separator {
border: none;
border-top: 1px solid #e0e0e0;
margin: 0;
}
.setting-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
color: #333;
}
.setting-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.color-scheme-selector {
display: flex;
gap: 1rem;
}
.color-scheme-option {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.color-scheme-option input[type="radio"] {
cursor: pointer;
}
.btn-theme,
.btn-image {
padding: 0.5rem 1rem;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-theme:hover,
.btn-image:hover {
background: #5568d3;
}
.color-palette {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.color-swatch input[type="checkbox"] {
cursor: pointer;
}
.color-box {
width: 40px;
height: 40px;
border-radius: 4px;
border: 2px solid #ddd;
transition: border-color 0.2s;
}
.color-swatch:has(input:checked) .color-box {
border-color: #667eea;
border-width: 3px;
}
.sound-control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sound-control-group .setting-select {
flex: 1;
}
.audio-preview-buttons {
display: flex;
gap: 0.25rem;
background: #f1f5f9;
border-radius: 8px;
padding: 4px;
}
.audio-preview-btn {
padding: 6px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.audio-preview-btn:hover {
background: white;
color: #2563eb;
}
.audio-preview-btn:active {
transform: scale(0.9);
}
.entry-manager {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: white;
border-left: 1px solid #e2e8f0;
}
.entry-tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
}
.entry-tab {
flex: 1;
padding: 16px;
font-size: 14px;
font-weight: 600;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s;
color: #64748b;
border-bottom: 2px solid transparent;
}
.entry-tab:hover {
color: #475569;
}
.entry-tab.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.entry-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.entry-input-section {
padding: 16px;
border-bottom: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 12px;
}
.entry-textarea-modern {
width: 100%;
height: 128px;
padding: 12px;
font-size: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
resize: none;
background: #f8fafc;
font-family: inherit;
transition: all 0.2s;
}
.entry-textarea-modern:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
background: white;
}
.entry-buttons-row {
display: flex;
gap: 8px;
}
.btn-add-entries,
.btn-upload-entries {
flex: 1;
padding: 8px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-add-entries:hover:not(:disabled),
.btn-upload-entries:hover:not(:disabled) {
background: #1d4ed8;
}
.btn-add-entries:disabled,
.btn-upload-entries:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-upload-entries {
background: #10b981;
}
.btn-upload-entries:hover:not(:disabled) {
background: #059669;
}
.entry-actions {
display: flex;
gap: 8px;
}
.btn-action {
flex: 1;
padding: 8px;
font-size: 12px;
font-weight: 500;
color: #475569;
background: #f1f5f9;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.btn-action:hover {
background: #e2e8f0;
}
.btn-action-danger {
color: #dc2626;
background: #fef2f2;
}
.btn-action-danger:hover {
background: #fee2e2;
}
.entry-list-modern {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.entry-item-modern {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 6px;
transition: background 0.2s;
position: relative;
}
.entry-item-modern:hover {
background: #f8fafc;
}
.entry-item-modern:hover .btn-remove-modern {
opacity: 1;
}
.entry-item-text {
font-size: 14px;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
}
.entry-index {
font-size: 12px;
color: #cbd5e1;
width: 16px;
text-align: right;
}
.btn-remove-modern {
background: transparent;
border: none;
color: #cbd5e1;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove-modern:hover {
color: #dc2626;
background: #fee2e2;
}
.entry-more-indicator {
padding: 8px;
text-align: center;
font-size: 12px;
color: #94a3b8;
font-style: italic;
}
.empty-state-modern {
text-align: center;
padding: 40px 16px;
color: #94a3b8;
font-size: 14px;
}
.results-header {
padding: 16px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: flex-end;
}
.btn-clear-results {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: #dc2626;
background: #fef2f2;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.btn-clear-results:hover {
background: #fee2e2;
}
.results-list-modern {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item-modern {
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s;
}
.result-item-modern:hover {
background: #f1f5f9;
}
.result-text-main {
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.result-timestamp {
font-size: 10px;
color: #64748b;
}
import { useState, useMemo, useRef } from 'react'
import { useWheelStore } from '../../store/wheelStore'
import { entryApi, wheelApi } from '../../services/api'
import { Plus, Shuffle, SortAsc, Trash2, X, Upload } from 'lucide-react'
import './EntryManager.css'
export default function EntryManager() {
const [inputText, setInputText] = useState('')
const [activeTab, setActiveTab] = useState<'entries' | 'results'>('entries')
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const {
entries,
results,
settings,
addEntry,
bulkAddEntries,
removeEntry,
shuffleEntries,
sortEntries,
clearEntries,
removeResult,
clearResults,
currentWheelId,
setWheelId,
loadEntries,
} = useWheelStore()
// Ensure wheel exists in database, create if needed
const ensureWheel = async (): Promise<string | null> => {
if (currentWheelId) {
return currentWheelId
}
try {
// Create new wheel with current entries and settings
const wheelData = {
name: 'My Wheel',
settings,
entries: entries.map((e, index) => ({
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
order: index,
})),
}
const response = await wheelApi.create(wheelData)
const wheelId = response.data.id
setWheelId(wheelId)
// Save wheelId to localStorage for persistence
localStorage.setItem('spin-the-wheel_currentWheelId', wheelId)
// Reload entries from database to get correct IDs
const entriesResponse = await entryApi.get(wheelId)
const dbEntries = entriesResponse.data.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
}))
loadEntries(dbEntries)
return wheelId
} catch (error) {
return null
}
}
// Save entries to database
const saveEntriesToDB = async (newEntries: string[]) => {
try {
const wheelId = await ensureWheel()
if (!wheelId) {
throw new Error('Failed to create or get wheel')
}
// Prepare entries for database
const entriesToSave = newEntries.map((text, index) => ({
text: text.trim(),
order: entries.length + index,
}))
// Save to database
await entryApi.add(wheelId, entriesToSave)
// Reload entries from database to get correct IDs
const entriesResponse = await entryApi.get(wheelId)
const dbEntries = entriesResponse.data.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
}))
loadEntries(dbEntries)
} catch (error: any) {
throw error // Re-throw to handle in calling function
}
}
const handleAdd = async () => {
if (!inputText.trim() || isSaving) return
setIsSaving(true)
try {
// Split by lines and add each entry
const lines = inputText.split('\n').map(l => l.trim()).filter(l => l !== '')
if (lines.length === 0) {
setInputText('')
setIsSaving(false)
return
}
// Add to local store first (for immediate UI update)
lines.forEach(line => addEntry(line))
setInputText('')
// Save to database
await saveEntriesToDB(lines)
} catch (error: any) {
const errorMessage = error.response?.data?.error || error.message || 'Unknown error'
alert(`Error saving entries: ${errorMessage}\n\nPlease check:\n1. Backend server is running\n2. Database connection is working`)
} finally {
setIsSaving(false)
}
}
const parseCSV = (text: string): string[] => {
const lines = text.split(/\r?\n/) // Handle both \n and \r\n
const names: string[] = []
let isFirstLine = true
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// Handle CSV format - split by comma and take first column (or whole line if no comma)
// Also handle quoted values
let name = ''
if (trimmed.includes(',')) {
// CSV format - try to parse properly
const parts = trimmed.split(',').map(p => p.trim().replace(/^"|"$/g, ''))
name = parts[0] || trimmed
} else {
// Plain text format
name = trimmed
}
// Skip header row if it contains "Name" or "name"
if (isFirstLine && (name.toLowerCase() === 'name' || name.toLowerCase() === 'names')) {
isFirstLine = false
continue
}
if (name) {
names.push(name)
}
isFirstLine = false
}
return names
}
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsUploading(true)
try {
const text = await file.text()
const names = parseCSV(text)
if (names.length === 0) {
alert('No names found in the file. Please check the file format.')
return
}
// Ensure wheel exists first
const wheelId = await ensureWheel()
if (!wheelId) {
alert('Error: Could not create wheel. Please try again.')
return
}
// Add all names in batches to avoid blocking the UI
const batchSize = 500
let imported = 0
for (let i = 0; i < names.length; i += batchSize) {
const batch = names.slice(i, i + batchSize)
// Add to local store first
bulkAddEntries(batch)
imported += batch.length
// Save batch to database
try {
const entriesToSave = batch.map((text, index) => ({
text: text.trim(),
order: entries.length + imported - batch.length + index,
}))
await entryApi.add(wheelId, entriesToSave)
} catch (error) {
// Silently handle batch save error
}
// Small delay to keep UI responsive for large files
if (i + batchSize < names.length) {
await new Promise(resolve => setTimeout(resolve, 50))
}
}
// Reload all entries from database to get correct IDs
try {
const entriesResponse = await entryApi.get(wheelId)
const dbEntries = entriesResponse.data.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
}))
loadEntries(dbEntries)
} catch (error) {
// Silently handle reload error
}
alert(`Successfully imported ${imported} names!`)
} catch (error) {
alert('Error reading file. Please make sure it is a valid CSV file.')
} finally {
setIsUploading(false)
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
// Handle remove entry - delete from database too
const handleRemoveEntry = async (entryId: string) => {
// Remove from local store first
removeEntry(entryId)
// Delete from database if wheelId exists
if (currentWheelId) {
try {
await entryApi.remove(currentWheelId, entryId)
} catch (error) {
// Reload entries to sync state
try {
const entriesResponse = await entryApi.get(currentWheelId)
const dbEntries = entriesResponse.data.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
}))
loadEntries(dbEntries)
} catch (err) {
// Silently handle reload error
}
}
}
}
// Basic optimization: only render the first 500 entries for the list view
const visibleEntries = useMemo(() => entries.slice(0, 500), [entries])
return (
<div className="entry-manager">
<div className="entry-tabs">
<button
className={`entry-tab ${activeTab === 'entries' ? 'active' : ''}`}
onClick={() => setActiveTab('entries')}
>
Entries ({entries.length})
</button>
<button
className={`entry-tab ${activeTab === 'results' ? 'active' : ''}`}
onClick={() => setActiveTab('results')}
>
Results ({results.length})
</button>
</div>
<div className="entry-content">
{activeTab === 'entries' ? (
<>
<div className="entry-input-section">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter names (one per line)..."
className="entry-textarea-modern"
/>
<div className="entry-buttons-row">
<button
onClick={handleAdd}
className="btn-add-entries"
disabled={!inputText.trim() || isSaving}
>
<Plus size={18} /> {isSaving ? 'Saving...' : 'Add Entries'}
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<button
onClick={handleUploadClick}
className="btn-upload-entries"
disabled={isUploading}
>
<Upload size={18} /> {isUploading ? 'Uploading...' : 'Upload CSV'}
</button>
</div>
<div className="entry-actions">
<button
onClick={shuffleEntries}
className="btn-action"
>
<Shuffle size={14} /> Shuffle
</button>
<button
onClick={sortEntries}
className="btn-action"
>
<SortAsc size={14} /> Sort
</button>
<button
onClick={async () => {
if (confirm('Are you sure you want to clear all entries?')) {
clearEntries()
// Clear from database if wheelId exists
if (currentWheelId) {
try {
await entryApi.clearAll(currentWheelId)
} catch (error) {
// Silently handle clear error
}
}
}
}}
className="btn-action btn-action-danger"
>
<Trash2 size={14} /> Clear
</button>
</div>
</div>
<div className="entry-list-modern">
{entries.length === 0 ? (
<div className="empty-state-modern">
No entries yet.
</div>
) : (
<>
{visibleEntries.map((entry, idx) => (
<div key={entry.id} className="entry-item-modern">
<span className="entry-item-text">
<span className="entry-index">{idx + 1}.</span>
{entry.text}
</span>
<button
onClick={() => handleRemoveEntry(entry.id)}
className="btn-remove-modern"
>
<X size={16} />
</button>
</div>
))}
{entries.length > 500 && (
<div className="entry-more-indicator">
... and {entries.length - 500} more entries
</div>
)}
</>
)}
</div>
</>
) : (
<>
<div className="results-header">
<button
onClick={clearResults}
className="btn-clear-results"
>
<Trash2 size={14} /> Clear History
</button>
</div>
<div className="results-list-modern">
{results.length === 0 ? (
<div className="empty-state-modern">
No spin results yet.
</div>
) : (
results.map((result) => (
<div key={result.id} className="result-item-modern">
<div>
<div className="result-text-main">{result.entryText}</div>
<div className="result-timestamp">
{new Date(result.spunAt).toLocaleTimeString()}
</div>
</div>
<button
onClick={() => removeResult(result.id)}
className="btn-remove-modern"
>
<X size={16} />
</button>
</div>
))
)}
</div>
</>
)}
</div>
</div>
)
}
// This component is now integrated into EntryManager
// Keeping this file for potential future separation
export default function ResultsList() {
return null
}
.wheel-canvas-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.wheel-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.wheel-inner {
position: relative;
width: 100%;
height: 100%;
max-width: 820px;
max-height: 820px;
aspect-ratio: 1;
}
.wheel-canvas {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: white;
display: block;
}
.wheel-instructions {
margin-top: 1.5rem;
text-align: center;
color: #5f6368;
font-size: 0.875rem;
}
.wheel-instructions p {
margin: 0.25rem 0;
font-size: 0.875rem;
color: #5f6368;
}
.winner-popup {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 2rem 3rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
text-align: center;
z-index: 1000;
animation: popupFadeIn 0.3s ease-out;
min-width: 300px;
}
@keyframes popupFadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.winner-popup h2 {
margin: 0 0 1rem 0;
color: #333;
font-size: 1.5rem;
}
.winner-name {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin: 1rem 0;
}
.popup-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.btn-popup {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-close {
background: #f5f5f5;
color: #333;
}
.btn-close:hover {
background: #e0e0e0;
}
.btn-remove {
background: #ff6b6b;
color: white;
}
.btn-remove:hover {
background: #ff5252;
}
.backend-indicator {
font-size: 0.75rem;
color: #5f6368;
margin-top: 0.5rem;
font-style: italic;
}
.spin-button-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 50%;
padding: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 8px solid #f8fafc;
cursor: pointer;
transition: transform 0.2s;
z-index: 10;
user-select: none;
}
.spin-button-overlay:hover {
transform: translate(-50%, -50%) scale(1.1);
}
.spin-button-overlay:active {
transform: translate(-50%, -50%) scale(0.95);
}
.spin-button-inner {
background: #2563eb;
border-radius: 50%;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: 24px;
transition: background-color 0.2s;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.spin-button-overlay:hover .spin-button-inner {
background: #1d4ed8;
}
import { useEffect, useRef, useState, useCallback } from 'react'
import { useWheelStore } from '../../store/wheelStore'
import { WheelPhysics, getSecureRandomEntry, getSegmentIndexFromAngle } from '../../utils/wheelPhysics'
import { spinApi, wheelApi, resultApi, entryApi } from '../../services/api'
import confetti from 'canvas-confetti'
import './WheelCanvas.css'
export default function WheelCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const physicsRef = useRef<WheelPhysics>(new WheelPhysics())
const animationFrameRef = useRef<number>()
// SINGLE SOURCE OF TRUTH: Store final angle and winner (calculated FROM angle, not predetermined)
const frozenFinalAngleRef = useRef<number | null>(null)
const frozenWinnerRef = useRef<{ entry: any; entryIndex: number } | null>(null)
const currentWheelIdRef = useRef<string | null>(null) // Store wheelId for result saving
const {
entries,
settings,
isSpinning,
setSpinning,
setLastWinner,
addResult,
removeEntry,
currentWheelId,
setWheelId,
loadEntries,
} = useWheelStore()
// Determine if we need backend for selection (large datasets)
const needsBackend = entries.length > 1000
const visibleEntries = entries.slice(0, settings.duringSpin.maxVisibleNames)
const totalEntries = entries.length
// Audio context for sounds
const audioContextRef = useRef<AudioContext | null>(null)
const lastTickIndexRef = useRef<number>(-1)
const initAudio = useCallback(() => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume()
}
return audioContextRef.current
}, [])
const playTick = useCallback(() => {
if (settings.duringSpin.sound === 'none' || settings.duringSpin.volume === 0) return
try {
const ctx = initAudio()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
// Snappy triangle tick
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(150, ctx.currentTime + 0.04)
gain.gain.setValueAtTime((settings.duringSpin.volume / 100) * 0.2, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.04)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.04)
} catch (e) {}
}, [settings.duringSpin.sound, settings.duringSpin.volume, initAudio])
const playVictorySound = useCallback(() => {
if (settings.afterSpin.sound === 'none' || settings.afterSpin.volume === 0) return
try {
const ctx = initAudio()
// Cheerful arpeggio for victory
const freqs = [392.00, 493.88, 587.33, 783.99] // G Major arpeggio
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
const startTime = ctx.currentTime + (i * 0.1)
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, startTime)
gain.gain.setValueAtTime(0, startTime)
gain.gain.linearRampToValueAtTime((settings.afterSpin.volume / 100) * 0.1, startTime + 0.05)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.8)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(startTime)
osc.stop(startTime + 0.8)
})
} catch (e) {}
}, [settings.afterSpin.sound, settings.afterSpin.volume, initAudio])
// Draw wheel function - PURE rendering, no winner logic
const drawWheel = useCallback((angleRadians: number, spinning: boolean = false) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Get container size for responsive sizing
const container = canvas.parentElement?.parentElement
const containerWidth = container ? container.clientWidth : 600
const containerHeight = container ? container.clientHeight : 600
const containerSize = Math.min(containerWidth, containerHeight) - 64
const size = Math.min(containerSize, 820)
const dpr = window.devicePixelRatio || 1
if (canvas.width !== size * dpr || canvas.height !== size * dpr) {
canvas.width = size * dpr
canvas.height = size * dpr
canvas.style.width = `${size}px`
canvas.style.height = `${size}px`
ctx.scale(dpr, dpr)
}
const centerX = size / 2
const centerY = size / 2
const radius = size / 2 - 20
ctx.clearRect(0, 0, size, size)
// Handle tick sound timing based on rotation (only when spinning)
if (spinning && visibleEntries.length > 0) {
const PI2 = Math.PI * 2
const anglePerEntry = PI2 / visibleEntries.length
// Calculate which segment is at the pointer (0 radians / 3 o'clock)
// Pointer is at 0, wheel starts at -PI/2, so: localAngle = PI/2 - angleRadians
const localAngleAtPointer = ((Math.PI / 2) - angleRadians + PI2) % PI2
const tickIndex = Math.floor(localAngleAtPointer / anglePerEntry)
if (tickIndex !== lastTickIndexRef.current && lastTickIndexRef.current !== -1) {
playTick()
}
lastTickIndexRef.current = tickIndex
} else {
lastTickIndexRef.current = -1
}
// Draw wheel shadow if enabled
if (settings.appearance.wheelShadow) {
ctx.save()
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 20
ctx.shadowOffsetX = 5
ctx.shadowOffsetY = 5
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.fillStyle = '#fff'
ctx.fill()
ctx.restore()
}
// Save context for rotation
ctx.save()
ctx.translate(centerX, centerY)
ctx.rotate(angleRadians) // Use radians directly
// Draw wheel segments
const PI2 = Math.PI * 2
const anglePerEntry = PI2 / visibleEntries.length
const colors = settings.appearance.colors
visibleEntries.forEach((entry, index) => {
// Start from -90° (top) and draw clockwise
const startAngle = (index * anglePerEntry) - Math.PI / 2
const endAngle = ((index + 1) * anglePerEntry) - Math.PI / 2
// Draw segment
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.arc(0, 0, radius, startAngle, endAngle, false)
ctx.closePath()
// Use custom color if available, otherwise cycle through palette
const color = entry.color || colors[index % colors.length]
ctx.fillStyle = color
ctx.fill()
// Draw contour if enabled
if (settings.appearance.contours) {
ctx.strokeStyle = '#333'
ctx.lineWidth = 2
ctx.stroke()
}
// Draw text with clipping and dynamic font sizing
ctx.save()
// Create clipping path for this segment to prevent text overflow
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.arc(0, 0, radius, startAngle, endAngle, false)
ctx.closePath()
ctx.clip() // Clip all drawing to this segment boundary
const textAngle = (startAngle + endAngle) / 2
ctx.rotate(textAngle)
// Calculate available arc length for text - use FULL width like wheelofnames
const innerRadius = radius * 0.20 // Inner boundary (very close to center for maximum space)
const outerRadius = radius * 0.98 // Outer boundary (very close to edge for maximum space)
// Calculate the actual arc length at the outer radius (where text will extend to)
// This gives us the maximum available width for text
const arcLengthAtOuter = outerRadius * anglePerEntry
const maxTextWidth = arcLengthAtOuter * 0.98 // Use 98% of full arc length
// Find optimal font size to fit full text with letter spacing
let fontSize = 24 // Start with base font size
let optimalFontSize = fontSize
const letterSpacing = 0.3 // Letter spacing in pixels (reduced for subtle effect)
// Helper function to measure text width with letter spacing
const measureTextWithSpacing = (text: string, font: string, spacing: number): number => {
ctx.font = font
const baseWidth = ctx.measureText(text).width
// Add spacing between characters (not after last character)
return baseWidth + (spacing * (text.length - 1))
}
// Measure text at base font size with letter spacing
ctx.font = `${fontSize}px sans-serif`
let textWidth = measureTextWithSpacing(entry.text, ctx.font, letterSpacing)
// If text is too wide, reduce font size
if (textWidth > maxTextWidth) {
// Calculate optimal font size based on ratio
optimalFontSize = Math.floor((fontSize * maxTextWidth) / textWidth)
optimalFontSize = Math.max(12, optimalFontSize) // Minimum font size of 12px
// Verify it fits with letter spacing
ctx.font = `${optimalFontSize}px sans-serif`
textWidth = measureTextWithSpacing(entry.text, ctx.font, letterSpacing)
// Fine-tune: if still too wide, reduce more
if (textWidth > maxTextWidth) {
optimalFontSize = Math.floor((optimalFontSize * maxTextWidth) / textWidth)
optimalFontSize = Math.max(12, optimalFontSize)
ctx.font = `${optimalFontSize}px sans-serif`
textWidth = measureTextWithSpacing(entry.text, ctx.font, letterSpacing)
}
} else {
// Text fits, maximize font size to use full available width
let testSize = fontSize + 1
while (testSize <= 40) { // Max font size of 40px
ctx.font = `${testSize}px sans-serif`
const measuredWidth = measureTextWithSpacing(entry.text, ctx.font, letterSpacing)
if (measuredWidth > maxTextWidth) {
break
}
optimalFontSize = testSize
testSize++
}
}
// Set final text properties - no bold, with letter spacing, centered
ctx.font = `${optimalFontSize}px sans-serif`
ctx.textBaseline = 'middle'
ctx.fillStyle = '#fff'
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
// Calculate center position for text (middle of the segment width)
const textCenterRadius = (innerRadius + outerRadius) / 2
// Draw text with letter spacing manually, centered
// Calculate total width of text with spacing
let totalTextWidth = 0
for (let i = 0; i < entry.text.length; i++) {
const charWidth = ctx.measureText(entry.text[i]).width
totalTextWidth += charWidth
if (i < entry.text.length - 1) {
totalTextWidth += letterSpacing
}
}
// Start position to center the text (use left align for individual characters)
ctx.textAlign = 'left'
let xPos = textCenterRadius - (totalTextWidth / 2)
// Draw each character with proper spacing
for (let i = 0; i < entry.text.length; i++) {
ctx.fillText(entry.text[i], xPos, 0)
// Move position by character width plus spacing
const charWidth = ctx.measureText(entry.text[i]).width
xPos += charWidth + letterSpacing
}
ctx.restore()
})
ctx.restore()
// Draw center circle
ctx.beginPath()
ctx.arc(centerX, centerY, 30, 0, Math.PI * 2)
ctx.fillStyle = '#fff'
ctx.fill()
ctx.strokeStyle = '#dadce0'
ctx.lineWidth = 2
ctx.stroke()
// Draw pointer (fixed at right side, 0°)
const pointerColor = settings.appearance.pointerChangesColor
? colors[0]
: '#DB4437'
ctx.fillStyle = pointerColor
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(centerX + radius, centerY)
ctx.lineTo(centerX + radius + 20, centerY - 15)
ctx.lineTo(centerX + radius + 20, centerY + 15)
ctx.closePath()
ctx.fill()
ctx.stroke()
}, [visibleEntries, settings])
// Auto-save wheel to backend
const autoSaveWheel = useCallback(async () => {
try {
const wheelData = {
name: 'My Wheel',
settings,
entries: entries.map((e, index) => ({
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
order: index,
})),
}
let wheelId = currentWheelId
if (wheelId) {
// Update existing wheel
await wheelApi.update(wheelId, wheelData)
} else {
// Create new wheel
const response = await wheelApi.create(wheelData)
wheelId = response.data.id
setWheelId(wheelId)
}
return wheelId
} catch (error) {
return currentWheelId
}
}, [entries, settings, currentWheelId, setWheelId, loadEntries])
// Auto-save result to backend
const autoSaveResult = useCallback(async (entry: any, wheelId: string | null) => {
if (!wheelId) {
return
}
try {
// First, try to find the entry in the database by text (since frontend IDs may not match DB IDs)
// If backend winner was used, it already has the correct DB entry ID
let entryId = entry.id
// If entry doesn't have a DB ID (frontend-only entry), we need to find it
// For now, we'll use the entryApi to get entries and match by text
if (!entryId || entryId.startsWith('temp-') || entryId.length < 10) {
try {
const entriesResponse = await entryApi.get(wheelId)
const dbEntries = entriesResponse.data
const matchingEntry = dbEntries.find((e: any) => e.text === entry.text)
if (matchingEntry) {
entryId = matchingEntry.id
} else {
return
}
} catch (err) {
return
}
}
await resultApi.add(wheelId, entryId)
} catch (error: any) {
// Silently fail - result saving is not critical
}
}, [])
// Animation loop - ONLY handles animation, calculates winner FROM final angle
useEffect(() => {
if (!isSpinning) {
// When not spinning, use frozen final angle if available
const angleToDraw = frozenFinalAngleRef.current ?? physicsRef.current.getAngle()
drawWheel(angleToDraw, false)
return
}
const animate = (currentTime: number) => {
// currentTime from requestAnimationFrame is in milliseconds (DOMHighResTimeStamp)
const { angle, isComplete } = physicsRef.current.update(currentTime)
drawWheel(angle, true)
if (!isComplete) {
animationFrameRef.current = requestAnimationFrame(animate)
} else {
// Spin complete - FREEZE the final angle
const finalAngle = physicsRef.current.getAngle()
frozenFinalAngleRef.current = finalAngle
// Calculate winner FROM the final angle (like reference implementation)
const winnerIndex = getSegmentIndexFromAngle(finalAngle, visibleEntries.length)
const calculatedWinner = visibleEntries[winnerIndex]
// Use backend winner if available (for large datasets), otherwise use visual winner
const finalWinner = frozenWinnerRef.current?.entry || calculatedWinner
if (finalWinner) {
// FREEZE the winner (single source of truth)
frozenWinnerRef.current = {
entry: finalWinner,
entryIndex: winnerIndex,
}
// Set winner in store (for popup display in App.tsx)
setLastWinner(finalWinner)
// Add result to local store
addResult(finalWinner)
// Auto-save result to backend (real-time sync)
// Use ref first (from current spin), then fallback to store
const wheelId = currentWheelIdRef.current || currentWheelId
if (wheelId) {
autoSaveResult(finalWinner, wheelId).catch(err => {
// Silently handle result save error
})
} else {
// Cannot save result: no wheelId available
}
// Play victory sound
playVictorySound()
// Launch confetti if enabled
if (settings.afterSpin.launchConfetti) {
confetti({
particleCount: 180,
spread: 80,
origin: { y: 0.55 },
colors: settings.appearance.colors,
})
}
// Auto-remove winner if enabled
if (settings.afterSpin.autoRemoveWinner) {
setTimeout(() => {
if (frozenWinnerRef.current) {
removeEntry(frozenWinnerRef.current.entry.id)
setLastWinner(null)
frozenWinnerRef.current = null
}
}, settings.afterSpin.autoRemoveWinner * 1000)
}
}
// Stop spinning
setSpinning(false)
}
}
// Start animation with current timestamp
animationFrameRef.current = requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isSpinning, drawWheel, setSpinning, setLastWinner, addResult, settings, removeEntry, visibleEntries, playTick, playVictorySound, currentWheelId, autoSaveResult])
// Continuous rendering when not spinning - keeps wheel at frozen position
useEffect(() => {
if (isSpinning) {
return // Don't interfere with animation
}
// Use frozen final angle if available, otherwise current angle
const angleToDraw = frozenFinalAngleRef.current ?? physicsRef.current.getAngle()
const renderLoop = () => {
// Always use the frozen final angle, never recalculate
drawWheel(angleToDraw, false)
animationFrameRef.current = requestAnimationFrame(renderLoop)
}
animationFrameRef.current = requestAnimationFrame(renderLoop)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isSpinning, drawWheel])
// Handle window resize
useEffect(() => {
const handleResize = () => {
if (!isSpinning) {
const angleToDraw = frozenFinalAngleRef.current ?? physicsRef.current.getAngle()
drawWheel(angleToDraw, false)
}
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [isSpinning, drawWheel])
// Handle spin - Let physics run naturally, calculate winner FROM final angle
const handleSpin = useCallback(async () => {
if (isSpinning || entries.length === 0) return
try {
// Clear previous frozen data
frozenFinalAngleRef.current = null
frozenWinnerRef.current = null
// Auto-save wheel before spinning (real-time sync)
const wheelId = await autoSaveWheel()
// Store wheelId in ref so it's available when result is saved
currentWheelIdRef.current = wheelId
// Update store if wheelId changed
if (wheelId && wheelId !== currentWheelId) {
setWheelId(wheelId)
// Save wheelId to localStorage for persistence
localStorage.setItem('spin-the-wheel_currentWheelId', wheelId)
// Reload entries from database to get correct IDs
try {
const entriesResponse = await entryApi.get(wheelId)
const dbEntries = entriesResponse.data.map((e: any) => ({
id: e.id,
text: e.text,
imageUrl: e.imageUrl,
weight: e.weight,
color: e.color,
}))
loadEntries(dbEntries)
} catch (error) {
// Silently handle error
}
}
// For large datasets (>1000), use backend for fair selection
// For smaller datasets, use frontend physics
let backendWinner: any = null
if (needsBackend) {
try {
// Use wheelId if available, otherwise send entries array
const spinPayload = wheelId
? { wheelId }
: { entries: entries.map(e => ({ id: e.id, text: e.text, weight: e.weight, color: e.color })) }
const response = await spinApi.perform(spinPayload)
backendWinner = response.data.winner
} catch (error) {
// Fallback to frontend if backend fails
}
}
// Start natural physics spin (no predetermined target)
// Physics will determine where it stops, then we calculate winner FROM that
setSpinning(true)
physicsRef.current.startSpin(settings.duringSpin.spinTime)
// Store backend winner for later use if available
if (backendWinner) {
frozenWinnerRef.current = {
entry: backendWinner,
entryIndex: -1, // Will be determined by visual position
}
}
} catch (error) {
// Silently handle spin error
setSpinning(false)
frozenFinalAngleRef.current = null
frozenWinnerRef.current = null
}
}, [
isSpinning,
entries,
needsBackend,
currentWheelId,
settings,
setSpinning,
autoSaveWheel,
])
// Keyboard shortcut
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !isSpinning) {
handleSpin()
}
}
window.addEventListener('keydown', handleKeyPress)
return () => window.removeEventListener('keydown', handleKeyPress)
}, [handleSpin, isSpinning])
return (
<div className="wheel-canvas-container">
<div className="wheel-wrapper">
<div className="wheel-inner">
<canvas
ref={canvasRef}
className="wheel-canvas"
onClick={handleSpin}
style={{ cursor: isSpinning ? 'wait' : 'pointer' }}
/>
{!isSpinning && entries.length > 0 && (
<button
onClick={handleSpin}
className="spin-button-overlay"
>
<div className="spin-button-inner">
SPIN
</div>
</button>
)}
</div>
</div>
{needsBackend && !isSpinning && (
<div className="wheel-instructions">
<p className="backend-indicator">Using backend for {totalEntries} entries</p>
</div>
)}
</div>
)
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(to bottom, #e8f4f8 0%, #f5e8f5 100%);
min-height: 100vh;
}
#root {
min-height: 100vh;
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
import axios from 'axios'
const api = axios.create({
baseURL: '/api', // Uses Vite proxy to backend
headers: {
'Content-Type': 'application/json',
},
})
// Wheel endpoints
export const wheelApi = {
create: (data: any) => api.post('/wheels', data),
get: (id: string) => api.get(`/wheels/${id}`),
update: (id: string, data: any) => api.put(`/wheels/${id}`, data),
delete: (id: string) => api.delete(`/wheels/${id}`),
getGallery: () => api.get('/wheels/gallery'),
getRecent: () => api.get('/wheels/recent'),
share: (id: string) => api.post(`/wheels/${id}/share`),
}
// Entry endpoints
export const entryApi = {
validate: (entries: string[]) => api.post('/entries/validate', { entries }),
process: (entries: any[], maxVisible: number) =>
api.post('/entries/process', { entries, maxVisible }),
get: (wheelId: string) => api.get(`/entries/${wheelId}`),
add: (wheelId: string, entries: any[]) =>
api.post(`/entries/${wheelId}`, { entries }),
remove: (wheelId: string, entryId: string) =>
api.delete(`/entries/${wheelId}/${entryId}`),
clearAll: (wheelId: string) => api.delete(`/entries/${wheelId}/clear`),
}
// Spin endpoints
export const spinApi = {
perform: (payload: { wheelId?: string; entries?: any[] }) => api.post('/spin', payload),
}
// Result endpoints
export const resultApi = {
get: (wheelId: string) => api.get(`/results/${wheelId}`),
add: (wheelId: string, entryId: string) =>
api.post(`/results/${wheelId}`, { entryId }),
remove: (wheelId: string, resultId: string) =>
api.delete(`/results/${wheelId}/${resultId}`),
clear: (wheelId: string) => api.delete(`/results/${wheelId}`),
}
export default api
import { create } from 'zustand'
import { Entry, WheelSettings, Result } from '../types'
interface WheelState {
entries: Entry[]
results: Result[]
settings: WheelSettings
isSpinning: boolean
winner: Entry | null
lastWinner: Entry | null // Alias for winner to match professional version
currentWheelId: string | null
addEntry: (text: string) => void
bulkAddEntries: (texts: string[]) => void
removeEntry: (id: string) => void
updateEntry: (id: string, updates: Partial<Entry>) => void
shuffleEntries: () => void
sortEntries: () => void
clearEntries: () => void
addResult: (entry: Entry) => void
removeResult: (id: string) => void
clearResults: () => void
updateSettings: (updates: Partial<WheelSettings>) => void
setSpinning: (spinning: boolean) => void
setWinner: (winner: Entry | null) => void
setLastWinner: (winner: Entry | null) => void // Alias for setWinner
setWheelId: (id: string | null) => void
loadEntries: (entries: Entry[]) => void
loadResults: (results: Result[]) => void
loadSettings: (settings: WheelSettings) => void
}
const defaultSettings: WheelSettings = {
duringSpin: {
sound: 'ticking',
volume: 50,
displayDuplicates: true,
spinSlowly: false,
showTitle: true,
spinTime: 10,
maxVisibleNames: 1000,
},
afterSpin: {
sound: 'applause',
volume: 50,
animateWinningEntry: false,
launchConfetti: true,
autoRemoveWinner: null,
displayPopup: true,
popupMessage: 'We have a winner!',
displayRemoveButton: true,
playClickSoundOnRemove: false,
},
appearance: {
colorScheme: 'one-color',
theme: 'default',
colors: ['#DB4437', '#4285F4', '#0F9D58', '#F4B400', '#DB4437', '#4285F4', '#0F9D58', '#F4B400'],
centerImageSize: 'S',
displayGradient: true,
contours: false,
wheelShadow: true,
pointerChangesColor: true,
},
}
export const useWheelStore = create<WheelState>((set) => ({
entries: [], // Start with empty entries - will load from DB
results: [], // Start with empty results - will load from DB
settings: defaultSettings,
isSpinning: false,
winner: null,
lastWinner: null,
currentWheelId: null,
addEntry: (text: string) =>
set((state) => ({
entries: [
...state.entries,
{ id: Date.now().toString() + Math.random().toString(36).substr(2, 9), text: text.trim() },
],
})),
bulkAddEntries: (texts: string[]) =>
set((state) => {
const newEntries = texts.map((text) => ({
id: Date.now().toString() + Math.random().toString(36).substr(2, 9) + Math.random().toString(36).substr(2, 9),
text: text.trim(),
}))
return {
entries: [...state.entries, ...newEntries],
}
}),
removeEntry: (id: string) =>
set((state) => ({
entries: state.entries.filter((e) => e.id !== id),
})),
updateEntry: (id: string, updates: Partial<Entry>) =>
set((state) => ({
entries: state.entries.map((e) =>
e.id === id ? { ...e, ...updates } : e
),
})),
shuffleEntries: () =>
set((state) => {
const shuffled = [...state.entries]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return { entries: shuffled }
}),
sortEntries: () =>
set((state) => ({
entries: [...state.entries].sort((a, b) =>
a.text.localeCompare(b.text)
),
})),
clearEntries: () => set({ entries: [] }),
addResult: (entry: Entry) =>
set((state) => ({
results: [
{
id: Date.now().toString(),
entryId: entry.id,
entryText: entry.text,
spunAt: new Date(),
},
...state.results,
],
})),
removeResult: (id: string) =>
set((state) => ({
results: state.results.filter((r) => r.id !== id),
})),
clearResults: () => set({ results: [] }),
updateSettings: (updates: Partial<WheelSettings>) =>
set((state) => ({
settings: { ...state.settings, ...updates },
})),
setSpinning: (spinning: boolean) => set({ isSpinning: spinning }),
setWinner: (winner: Entry | null) => set({ winner, lastWinner: winner }),
setLastWinner: (winner: Entry | null) => set({ winner, lastWinner: winner }),
setWheelId: (id: string | null) => set({ currentWheelId: id }),
loadEntries: (entries: Entry[]) => set({ entries }),
loadResults: (results: Result[]) => set({ results }),
loadSettings: (settings: WheelSettings) => set({ settings }),
}))
export interface Entry {
id: string
text: string
imageUrl?: string
weight?: number
color?: string
}
export interface WheelSettings {
duringSpin: {
sound: string
volume: number
displayDuplicates: boolean
spinSlowly: boolean
showTitle: boolean
spinTime: number
maxVisibleNames: number
}
afterSpin: {
sound: string
volume: number
animateWinningEntry: boolean
launchConfetti: boolean
autoRemoveWinner: number | null
displayPopup: boolean
popupMessage: string
displayRemoveButton: boolean
playClickSoundOnRemove: boolean
}
appearance: {
colorScheme: 'one-color' | 'background-image'
theme: string
colors: string[]
centerImage?: string
centerImageSize: 'S' | 'M' | 'L'
pageBackgroundColor?: string
displayGradient: boolean
contours: boolean
wheelShadow: boolean
pointerChangesColor: boolean
}
}
export interface Result {
id: string
entryId: string
entryText: string
spunAt: Date
}
export interface Wheel {
id: string
name: string
entries: Entry[]
settings: WheelSettings
results: Result[]
createdAt: Date
updatedAt: Date
}
export interface PhysicsState {
angle: number
velocity: number
deceleration: number
}
export class WheelPhysics {
private angle: number = 0 // Current angle in radians
private angleDelta: number = 0 // Current angular velocity
private maxSpeed: number = Math.PI / 16
private isSpinning: boolean = false
private spinStartTime: number = 0
private upTime: number = 1000 // Spin up time in ms
private downTime: number = 5000 // Spin down time in ms
/**
* Start spinning the wheel with natural physics
* Uses sine-based acceleration/deceleration like the reference
*/
startSpin(spinTime: number = 10) {
this.isSpinning = true
this.spinStartTime = performance.now()
// Randomly vary the spin intensity (like reference)
const array = new Uint32Array(1)
crypto.getRandomValues(array)
const randomValue = array[0] / (0xFFFFFFFF + 1)
this.maxSpeed = Math.PI / (16 + randomValue * 8) // Vary between PI/16 and PI/8
// Convert spinTime to milliseconds for upTime/downTime
this.upTime = Math.min(1000, spinTime * 1000 * 0.1) // 10% of time for spin up
this.downTime = spinTime * 1000 - this.upTime // Rest for spin down
}
/**
* Update physics based on elapsed time
* Returns current angle and whether spinning is complete
*/
update(currentTime: number): { angle: number; isComplete: boolean } {
if (!this.isSpinning) {
return { angle: this.angle, isComplete: true }
}
const duration = currentTime - this.spinStartTime
let progress = 0
let finished = false
// Spin up phase (acceleration)
if (duration < this.upTime) {
progress = duration / this.upTime
// Sine curve for smooth acceleration: sin(progress * PI/2)
this.angleDelta = this.maxSpeed * Math.sin((progress * Math.PI) / 2)
}
// Spin down phase (deceleration)
else {
progress = (duration - this.upTime) / this.downTime
// Sine curve for smooth deceleration: sin(progress * PI/2 + PI/2)
this.angleDelta = this.maxSpeed * Math.sin((progress * Math.PI) / 2 + Math.PI / 2)
if (progress >= 1) {
finished = true
}
}
// Update angle
this.angle += this.angleDelta
// Keep angle in range [0, 2π)
const PI2 = Math.PI * 2
while (this.angle >= PI2) {
this.angle -= PI2
}
while (this.angle < 0) {
this.angle += PI2
}
if (finished) {
this.isSpinning = false
this.angleDelta = 0
return { angle: this.angle, isComplete: true }
}
return { angle: this.angle, isComplete: false }
}
/**
* Get current angle in radians
*/
getAngle(): number {
return this.angle
}
/**
* Get current angle in degrees
*/
getAngleDegrees(): number {
return (this.angle * 180) / Math.PI
}
isCurrentlySpinning(): boolean {
return this.isSpinning
}
stop() {
this.isSpinning = false
this.angleDelta = 0
}
}
// Cryptographically secure random selection
export function getSecureRandomEntry<T>(entries: T[]): T {
if (entries.length === 0) {
throw new Error('Cannot select from empty array')
}
const array = new Uint32Array(1)
crypto.getRandomValues(array)
const randomValue = array[0] / (0xFFFFFFFF + 1)
const index = Math.floor(randomValue * entries.length)
return entries[index]
}
/**
* Calculates the winner index based on rotation angle.
*
* Logic:
* 1. Pointer is fixed at 0 radians (3 o'clock / right side).
* 2. Wheel drawing starts with segment 0 at -PI/2 radians (12 o'clock / top).
* 3. Wheel is rotated clockwise by 'angle'.
* 4. The local angle on the wheel that aligns with the global pointer (0) is:
* localAngle + (angle - PI/2) = 0 => localAngle = PI/2 - angle
*
* @param angle - Current wheel angle in radians
* @param totalSegments - Total number of segments
* @returns The index of the segment being pointed to (0-based)
*/
export function getSegmentIndexFromAngle(angle: number, totalSegments: number): number {
if (totalSegments === 0) return 0;
// Normalize angle to [0, 2PI)
const normalizedAngle = ((angle % (Math.PI * 2)) + (Math.PI * 2)) % (Math.PI * 2);
// Calculate which local wheel angle is at the global 0 position
let localAngleAtPointer = (Math.PI / 2) - normalizedAngle;
// Normalize localAngleAtPointer to [0, 2PI)
localAngleAtPointer = ((localAngleAtPointer % (Math.PI * 2)) + (Math.PI * 2)) % (Math.PI * 2);
const arcSize = (Math.PI * 2) / totalSegments;
const index = Math.floor(localAngleAtPointer / arcSize);
return index % totalSegments;
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})
{
"name": "wheeler",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wheeler",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}
{
"name": "spin-the-wheel",
"version": "1.0.0",
"description": "A full-stack wheel spinner application with support for up to 10,000 entries",
"private": true,
"scripts": {
"install:all": "npm install && npm install --prefix frontend && npm install --prefix backend",
"install:frontend": "npm install --prefix frontend",
"install:backend": "npm install --prefix backend",
"db:generate": "npm run db:generate --prefix backend",
"db:migrate": "npm run db:migrate --prefix backend",
"db:studio": "npm run db:studio --prefix backend",
"dev": "npx concurrently -n \"backend,frontend\" -c \"blue,green\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
"dev:frontend": "npm run dev --prefix frontend",
"dev:backend": "npm run dev --prefix backend",
"build": "npm run build --prefix backend && npm run build --prefix frontend",
"build:frontend": "npm run build --prefix frontend",
"build:backend": "npm run build --prefix backend",
"setup": "npm run install:all && npm run db:generate && npm run db:migrate",
"start": "npx concurrently -n \"backend,frontend\" -c \"blue,green\" \"cd backend && npm run start\" \"cd frontend && npm run preview\""
},
"devDependencies": {
"concurrently": "^8.2.2"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment