Skip to content

Commit f4cd9bd

Browse files
committed
feat: new app: picker wheel
1 parent f005f4e commit f4cd9bd

File tree

8 files changed

+461
-1
lines changed

8 files changed

+461
-1
lines changed

src/components/AnimatedRoutes.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import ColorPaletteGeneratorPage from '../pages/apps/ColorPaletteGeneratorPage';
3030
import CssUnitConverterPage from '../pages/apps/CssUnitConverterPage';
3131
import FantasyNameGeneratorPage from '../pages/apps/FantasyNameGeneratorPage';
3232
import DiceRollerPage from '../pages/apps/DiceRollerPage';
33+
import PickerWheelPage from '../pages/apps/PickerWheelPage';
3334

3435
import UsefulLinksPage from '../pages/UsefulLinksPage';
3536

@@ -312,6 +313,7 @@ function AnimatedRoutes() {
312313
<Route path="/apps::css" element={<Navigate to="/apps/css-unit-converter" replace />} />
313314
<Route path="/apps::fng" element={<Navigate to="/apps/fantasy-name-generator" replace />} />
314315
<Route path="/apps::dice" element={<Navigate to="/apps/dice-roller" replace />} />
316+
<Route path="/apps::pw" element={<Navigate to="/apps/picker-wheel" replace />} />
315317
{/* End of hardcoded redirects */}
316318
<Route
317319
path="/apps/ip"
@@ -496,6 +498,20 @@ function AnimatedRoutes() {
496498
</motion.div>
497499
}
498500
/>
501+
<Route
502+
path="/apps/picker-wheel"
503+
element={
504+
<motion.div
505+
initial="initial"
506+
animate="in"
507+
exit="out"
508+
variants={pageVariants}
509+
transition={pageTransition}
510+
>
511+
<PickerWheelPage />
512+
</motion.div>
513+
}
514+
/>
499515
{/* D&D specific 404 page */}
500516
<Route
501517
path="/dnd/*"

src/components/ListInputModal.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useState } from 'react';
2+
3+
const ListInputModal = ({ isOpen, onClose, onSave }) => {
4+
const [list, setList] = useState('');
5+
6+
if (!isOpen) {
7+
return null;
8+
}
9+
10+
const handleSave = () => {
11+
onSave(list);
12+
onClose();
13+
};
14+
15+
return (
16+
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
17+
<div className="bg-zinc-800 p-6 rounded-lg shadow-2xl w-full max-w-md border border-zinc-700">
18+
<h2 className="text-2xl font-arvo font-normal mb-4">Load from List</h2>
19+
<textarea
20+
className="w-full h-48 p-2 bg-gray-900/50 rounded-md border border-gray-700"
21+
value={list}
22+
onChange={(e) => setList(e.target.value)}
23+
placeholder="Paste your list here, each line is an entry (max 30 entries)"
24+
/>
25+
<p className="text-sm text-gray-400 mt-2">Only the first 30 entries will be added.</p>
26+
<div className="flex justify-end gap-4 mt-4">
27+
<button
28+
onClick={onClose}
29+
className="flex items-center gap-2 text-lg font-arvo font-normal px-4 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-gray-600 text-white hover:bg-gray-700"
30+
>
31+
Cancel
32+
</button>
33+
<button
34+
onClick={handleSave}
35+
className="flex items-center gap-2 text-lg font-arvo font-normal px-4 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70"
36+
>
37+
Save
38+
</button>
39+
</div>
40+
</div>
41+
</div>
42+
);
43+
};
44+
45+
export default ListInputModal;

src/components/PickerWheel.js

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import '../styles/PickerWheel.css';
3+
import colors from '../config/colors';
4+
import { Trash } from '@phosphor-icons/react';
5+
import ListInputModal from './ListInputModal';
6+
7+
const PickerWheel = () => {
8+
const [entries, setEntries] = useState([]);
9+
const [newEntry, setNewEntry] = useState('');
10+
const [winner, setWinner] = useState(null);
11+
const [spinning, setSpinning] = useState(false);
12+
const canvasRef = useRef(null);
13+
const [rotation, setRotation] = useState(0);
14+
const animationFrameId = useRef(null);
15+
const newEntryInputRef = useRef(null);
16+
const [isModalOpen, setIsModalOpen] = useState(false);
17+
18+
const colorPalette = [
19+
"#FDE2E4", "#E2ECE9", "#BEE1E6", "#F0EFEB", "#DFE7FD", "#CDDAFD", "#EAD5E6", "#F4C7C3", "#D6E2E9", "#B9E2E6",
20+
"#F9D8D6", "#D4E9E6", "#A8DADC", "#E9E4F2", "#D0D9FB", "#C0CFFB", "#E3C8DE", "#F1BDBD", "#C9D5DE", "#A1D5DB",
21+
"#F6C4C1", "#C1E0DA", "#92D2D2", "#E2DDF0", "#C3CEFA", "#B3C4FA", "#DBBBD1", "#EDB3B0", "#BCC8D3", "#8DCED1"
22+
];
23+
24+
const cardStyle = {
25+
backgroundColor: colors['app-alpha-10'],
26+
borderColor: colors['app-alpha-50'],
27+
color: colors.app,
28+
};
29+
30+
useEffect(() => {
31+
drawWheel();
32+
}, [entries, rotation]);
33+
34+
const getColorData = (color) => {
35+
const canvas = document.createElement('canvas');
36+
canvas.width = 1;
37+
canvas.height = 1;
38+
const ctx = canvas.getContext('2d');
39+
ctx.fillStyle = color;
40+
ctx.fillRect(0, 0, 1, 1);
41+
return ctx.getImageData(0, 0, 1, 1).data;
42+
}
43+
44+
const drawWheel = () => {
45+
const canvas = canvasRef.current;
46+
if (!canvas) return;
47+
const ctx = canvas.getContext('2d');
48+
const { width, height } = canvas;
49+
const arc = 2 * Math.PI / (entries.length || 1);
50+
51+
ctx.clearRect(0, 0, width, height);
52+
ctx.save();
53+
ctx.translate(width / 2, height / 2);
54+
ctx.rotate(rotation);
55+
ctx.translate(-width / 2, -height / 2);
56+
57+
for (let i = 0; i < entries.length; i++) {
58+
const angle = i * arc;
59+
ctx.fillStyle = colorPalette[i % colorPalette.length];
60+
ctx.beginPath();
61+
ctx.arc(width / 2, height / 2, width / 2 - 10, angle, angle + arc);
62+
ctx.arc(width / 2, height / 2, 0, angle + arc, angle, true);
63+
ctx.fill();
64+
65+
ctx.save();
66+
ctx.fillStyle = '#000';
67+
ctx.font = '30px Arial';
68+
ctx.translate(width / 2 + Math.cos(angle + arc / 2) * (width / 2 - 80), height / 2 + Math.sin(angle + arc / 2) * (height / 2 - 80));
69+
ctx.rotate(angle + arc / 2 + Math.PI / 2);
70+
const text = entries[i];
71+
ctx.fillText(text, -ctx.measureText(text).width / 2, 0);
72+
ctx.restore();
73+
}
74+
ctx.restore();
75+
};
76+
77+
const addEntry = () => {
78+
if (newEntry.trim() && entries.length < 30) {
79+
setEntries([...entries, newEntry.trim()]);
80+
setNewEntry('');
81+
newEntryInputRef.current.focus();
82+
}
83+
};
84+
85+
const handleKeyDown = (e) => {
86+
if (e.key === 'Enter') {
87+
addEntry();
88+
}
89+
};
90+
91+
const deleteEntry = (index) => {
92+
const newEntries = [...entries];
93+
newEntries.splice(index, 1);
94+
setEntries(newEntries);
95+
}
96+
97+
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
98+
99+
const spin = () => {
100+
if (entries.length > 1 && !spinning) {
101+
setSpinning(true);
102+
setWinner(null);
103+
const duration = 7000;
104+
const startTime = performance.now();
105+
const startRotation = rotation;
106+
const randomSpins = Math.random() * 5 + 5;
107+
const endRotation = startRotation + randomSpins * 2 * Math.PI;
108+
109+
const animate = (currentTime) => {
110+
const elapsedTime = currentTime - startTime;
111+
const progress = Math.min(elapsedTime / duration, 1);
112+
const easedProgress = easeOut(progress);
113+
114+
const newRotation = startRotation + (endRotation - startRotation) * easedProgress;
115+
setRotation(newRotation);
116+
117+
if (progress < 1) {
118+
animationFrameId.current = requestAnimationFrame(animate);
119+
} else {
120+
const canvas = canvasRef.current;
121+
const ctx = canvas.getContext('2d');
122+
const pinX = canvas.width / 2;
123+
const pinY = 30; // Position of the pin
124+
const pixel = ctx.getImageData(pinX, pinY, 1, 1).data;
125+
const pixelColor = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
126+
127+
for (let i = 0; i < entries.length; i++) {
128+
const colorData = getColorData(colorPalette[i % colorPalette.length]);
129+
const color = `rgb(${colorData[0]}, ${colorData[1]}, ${colorData[2]})`;
130+
if (color === pixelColor) {
131+
setWinner(entries[i]);
132+
break;
133+
}
134+
}
135+
setSpinning(false);
136+
}
137+
};
138+
139+
animationFrameId.current = requestAnimationFrame(animate);
140+
}
141+
};
142+
143+
const reset = () => {
144+
setWinner(null);
145+
}
146+
147+
const clearEntries = () => {
148+
setEntries([]);
149+
setWinner(null);
150+
setRotation(0);
151+
}
152+
153+
const handleSaveList = (list) => {
154+
const newEntries = list.split('\n').map(entry => entry.trim()).filter(entry => entry);
155+
setEntries([...entries, ...newEntries].slice(0, 30));
156+
};
157+
158+
return (
159+
<div
160+
className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow"
161+
style={cardStyle}
162+
>
163+
<div
164+
className="absolute top-0 left-0 w-full h-full opacity-10"
165+
style={{
166+
backgroundImage:
167+
'radial-gradient(circle, white 1px, transparent 1px)',
168+
backgroundSize: '10px 10px',
169+
}}
170+
></div>
171+
<div className="relative z-10">
172+
<h1 className="text-3xl font-arvo font-normal mb-4 text-app"> Picker Wheel </h1>
173+
<hr className="border-gray-700 mb-4" />
174+
<div className="flex gap-8">
175+
<div className="flex flex-col items-center">
176+
<div className="picker-wheel-container mt-8">
177+
<div className="wheel-wrapper">
178+
<div className="pin"></div>
179+
<canvas ref={canvasRef} width="600" height="600" className={`wheel ${entries.length > 1 && !spinning ? 'slow-spin' : ''}`}></canvas>
180+
<button onClick={spin} className="spin-button" disabled={spinning || entries.length < 2}>
181+
{spinning ? '...' : winner ? winner : 'Spin'}
182+
</button>
183+
</div>
184+
</div>
185+
<div className="winner mt-4">
186+
{spinning ? 'Spinning...' : winner ? `The winner is: ${winner}`: ''}
187+
</div>
188+
</div>
189+
<div className="w-full max-w-lg ml-16">
190+
<div className="controls">
191+
<input
192+
ref={newEntryInputRef}
193+
type="text"
194+
value={newEntry}
195+
onChange={(e) => setNewEntry(e.target.value)}
196+
onKeyDown={handleKeyDown}
197+
placeholder="Add an option (max 30)"
198+
className="bg-gray-800 text-white p-2 rounded-lg flex-grow"
199+
disabled={entries.length >= 30}
200+
/>
201+
<button
202+
onClick={addEntry}
203+
className="flex items-center gap-2 text-lg font-arvo font-normal px-4 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70"
204+
disabled={entries.length >= 30}
205+
>
206+
Add
207+
</button>
208+
</div>
209+
<button
210+
onClick={() => setIsModalOpen(true)}
211+
className="flex items-center justify-center w-full gap-2 text-lg font-arvo font-normal px-4 py-2 mt-4 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70"
212+
>
213+
Load from List
214+
</button>
215+
<div className="w-full mt-4">
216+
<h2 className="text-2xl font-arvo font-normal mb-4">Entries ({entries.length})</h2>
217+
<ul className="space-y-2">
218+
{entries.map((entry, index) => (
219+
<li key={index} className="flex items-center justify-between bg-gray-800/75 p-2 rounded-lg">
220+
<span className="flex-grow text-center">{entry}</span>
221+
<button
222+
onClick={() => deleteEntry(index)}
223+
className="flex items-center gap-2 text-lg font-mono font-normal px-2 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70"
224+
>
225+
<Trash size={20} />
226+
</button>
227+
</li>
228+
))}
229+
</ul>
230+
</div>
231+
</div>
232+
</div>
233+
</div>
234+
<div
235+
className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow mt-8"
236+
style={cardStyle}
237+
>
238+
<div
239+
className="absolute top-0 left-0 w-full h-full opacity-10"
240+
style={{
241+
backgroundImage:
242+
'radial-gradient(circle, white 1px, transparent 1px)',
243+
backgroundSize: '10px 10px',
244+
}}
245+
></div>
246+
<div className="relative z-10">
247+
<h2 className="text-2xl font-arvo font-normal mb-4">How it works</h2>
248+
<p className="text-gray-400">
249+
✦ Add entries one by one using the input field and "Add" button, or load a list of entries using the "Load from List" button. <br />
250+
✦ The wheel will display the entries as equal divisions. <br />
251+
✦ Click the "Spin" button to spin the wheel. It will spin fast and then slowly get slower, eventually stopping on a winner. <br />
252+
✦ The winner will be displayed below the wheel and in the center of the wheel. <br />
253+
</p>
254+
</div>
255+
</div>
256+
<ListInputModal
257+
isOpen={isModalOpen}
258+
onClose={() => setIsModalOpen(false)}
259+
onSave={handleSaveList}
260+
/>
261+
</div>
262+
);
263+
};
264+
265+
export default PickerWheel;

src/pages/AppPage.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import AppCard from '../components/AppCard';
55
import usePageTitle from '../utils/usePageTitle';
66

77
const apps = [
8+
{
9+
to: '/apps/picker-wheel',
10+
title: 'Picker Wheel',
11+
description: 'A spinning wheel to pick a random winner from a list of entries.',
12+
icon: DiceSix,
13+
},
814
{
915
to: '/apps/tournament-bracket',
1016
title: 'Tournament Bracket',

src/pages/apps/DiceRollerPage.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ArrowLeftIcon } from '@phosphor-icons/react';
55
import colors from '../../config/colors';
66
import { useToast } from '../../hooks/useToast';
77
import Dice from '../../components/Dice';
8-
import './DiceRollerPage.css'; // Import the CSS for animations
8+
import '../../styles/DiceRollerPage.css'; // Import the CSS for animations
99
import '../../styles/app-buttons.css';
1010

1111
const DiceRollerPage = () => {

0 commit comments

Comments
 (0)