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": "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;
}
// 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;
}
* {
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": "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