@adriansteffan/reactive 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,662 +0,0 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { Bounce, toast } from 'react-toastify';
3
- import Quest from './quest';
4
- import { now } from '../utils/common';
5
-
6
- const COLORS = {
7
- red: '#D81B60',
8
- blue: '#1E88E5',
9
- yellow: '#FFC107',
10
- green: '#01463A',
11
- grey: '#DADADA',
12
- } as const;
13
-
14
- type ColorKey = keyof typeof COLORS;
15
- type Size = 8 | 10 | 12 | 14 | 16 | 20 | 24 | 28 | 32;
16
-
17
- interface ColorOrbProps {
18
- color: ColorKey;
19
- /** Size in Tailwind units (8-32). Defaults to 12 */
20
- size?: Size;
21
- interactive?: boolean;
22
- pressed?: boolean;
23
- hoverborder?: boolean;
24
- onClick?: () => void;
25
- }
26
-
27
- type GuessResult = {
28
- color: ColorKey;
29
- status: 'correct' | 'wrong-position' | 'incorrect';
30
- };
31
-
32
- const ColorOrb: React.FC<ColorOrbProps> = ({
33
- color,
34
- size = 12,
35
- interactive = false,
36
- hoverborder = false,
37
- pressed = false,
38
- onClick,
39
- }) => {
40
- const letter = color === 'grey' ? '?' : color[0].toUpperCase();
41
-
42
- const sizeClasses = {
43
- 8: 'h-8 w-8 text-sm',
44
- 10: 'h-10 w-10 text-base',
45
- 12: 'h-12 w-12 text-lg',
46
- 14: 'h-14 w-14 text-xl',
47
- 16: 'h-16 w-16 text-xl',
48
- 20: 'h-20 w-20 text-2xl',
49
- 24: 'h-24 w-24 text-3xl',
50
- 28: 'h-28 w-28 text-3xl',
51
- 32: 'h-32 w-32 text-4xl',
52
- }[size];
53
-
54
- const handleInteraction = (e: React.MouseEvent | React.TouchEvent) => {
55
- e.preventDefault(); // Prevent double-firing on mobile devices
56
- onClick?.();
57
- };
58
-
59
- const handleTouchEnd = (e: React.MouseEvent | React.TouchEvent) => {
60
- e.preventDefault();
61
- };
62
-
63
- return (
64
- <div
65
- style={{
66
- backgroundColor: COLORS[color],
67
- color: color === 'grey' ? '#000000' : '#FFFFFF',
68
- }}
69
- className={`
70
- ${sizeClasses}
71
- rounded-full
72
- flex items-center justify-center
73
- font-bold
74
- border-2
75
- border-black
76
-
77
- ${interactive ? ' shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none cursor-pointer touch-manipulation' : `border-[${size / 8}px]`}
78
- ${pressed ? ' translate-x-[2px] translate-y-[2px] shadow-none' : ''}
79
- ${hoverborder ? ' hover:border-4 cursor-pointer' : ''}
80
- `}
81
- onClick={handleInteraction}
82
- onTouchStart={handleInteraction}
83
- onTouchEnd={handleTouchEnd}
84
- >
85
- {letter}
86
- </div>
87
- );
88
- };
89
-
90
- // to pass up to the next function
91
- interface GuessData {
92
- index: number;
93
- colors: ColorKey[];
94
- results: GuessResult[];
95
- isCorrect: boolean;
96
- start: number;
97
- end: number;
98
- duration: number;
99
- }
100
-
101
- function useScreenWidth() {
102
- const [width, setWidth] = useState(0);
103
-
104
- useEffect(() => {
105
- const updateWidth = () => {
106
- setWidth(window.innerWidth);
107
- };
108
-
109
- updateWidth();
110
- window.addEventListener('resize', updateWidth);
111
-
112
- return () => window.removeEventListener('resize', updateWidth);
113
- }, []);
114
-
115
- return width;
116
- }
117
-
118
- function MasterMindle({
119
- feedback,
120
- next,
121
- maxTime,
122
- timeLeft,
123
- maxGuesses,
124
- setTimeLeft,
125
- setQuitLastGame,
126
- }: {
127
- feedback: 1 | 2 | 3 | 4 | 5;
128
- next: (data: object) => void;
129
- maxTime: number;
130
- timeLeft: number;
131
- maxGuesses: number;
132
- setTimeLeft: (time: number) => void;
133
- setQuitLastGame: (quit: boolean) => void;
134
- }) {
135
- const [selectedColor, setSelectedColor] = useState<ColorKey | null>(null);
136
- const [currentGuess, setCurrentGuess] = useState<(ColorKey | null)[]>([null, null, null, null]);
137
- const [localTimeLeft, setLocalTimeLeft] = useState<number>(timeLeft);
138
- const [guessesLeft, setGuessesLeft] = useState<number>(maxGuesses - 1);
139
- const [roundOver, setRoundOver] = useState<boolean>(false);
140
-
141
- const [guessStartTime, setGuessStartTime] = useState<number>(now());
142
- const [accumulatedGuesses, setAccumulatedGuesses] = useState<GuessData[]>([]);
143
- const screenWidth = useScreenWidth();
144
-
145
- const warningShownRef = useRef(false);
146
-
147
- useEffect(() => {
148
- warningShownRef.current = false;
149
- }, [maxTime]);
150
-
151
- useEffect(() => {
152
- // Only start the timer if the round is not over
153
- if (roundOver) return;
154
-
155
- const timer = setInterval(() => {
156
- setLocalTimeLeft((prev) => {
157
- const newTime = Math.max(0, prev - 1);
158
-
159
- if (newTime === 30 && !warningShownRef.current) {
160
- warningShownRef.current = true;
161
- toast('30 seconds remaining!', {
162
- position: 'top-center',
163
- hideProgressBar: true,
164
- closeOnClick: true,
165
- pauseOnHover: true,
166
- draggable: false,
167
- progress: undefined,
168
- theme: 'light',
169
- transition: Bounce,
170
- autoClose: 4000,
171
- });
172
- }
173
-
174
- return newTime;
175
- });
176
- }, 1000);
177
-
178
- // Cleanup timer when component unmounts or roundOver changes
179
- return () => clearInterval(timer);
180
- }, [roundOver, setLocalTimeLeft]);
181
-
182
- const [previousGuesses, setPreviousGuesses] = useState<
183
- { colors: ColorKey[]; results: GuessResult[] }[]
184
- >([]);
185
-
186
- const [solution] = useState<ColorKey[]>(() => {
187
- const colors = Object.keys(COLORS).filter((color) => color !== 'grey') as ColorKey[];
188
- return Array(4)
189
- .fill(null)
190
- .map(() => colors[Math.floor(Math.random() * colors.length)]);
191
- });
192
-
193
- // Add effect to scroll to bottom when previousGuesses changes
194
- const guessesContainerRef = useRef<HTMLDivElement>(null);
195
- useEffect(() => {
196
- if (guessesContainerRef.current) {
197
- guessesContainerRef.current.scrollTop = guessesContainerRef.current.scrollHeight;
198
- }
199
- }, [previousGuesses]);
200
-
201
- const checkGuess = (guess: ColorKey[]): GuessResult[] => {
202
- // Count color frequencies in solution
203
- const solutionColorCounts = solution.reduce(
204
- (counts, color) => {
205
- counts[color] = (counts[color] || 0) + 1;
206
- return counts;
207
- },
208
- {} as Record<ColorKey, number>,
209
- );
210
-
211
- // First pass: Mark correct positions
212
- const results: GuessResult[] = guess.map((color, i) => {
213
- if (color === solution[i]) {
214
- solutionColorCounts[color]--;
215
- return { color, status: 'correct' as const };
216
- }
217
- return { color, status: 'incorrect' as const };
218
- });
219
-
220
- // Second pass: Check wrong positions
221
- guess.forEach((color, i) => {
222
- if (results[i].status === 'correct') return;
223
- if (solutionColorCounts[color] > 0) {
224
- results[i] = { color, status: 'wrong-position' as const };
225
- }
226
- });
227
-
228
- return results;
229
- };
230
-
231
- const handleCheck = () => {
232
- if (currentGuess.some((color) => color === null)) {
233
- toast('Please complete your guess!', {
234
- position: 'top-center',
235
- hideProgressBar: true,
236
- closeOnClick: true,
237
- pauseOnHover: true,
238
- draggable: false,
239
- progress: undefined,
240
- theme: 'light',
241
- transition: Bounce,
242
- });
243
-
244
- return;
245
- }
246
-
247
- const currentTime = now();
248
- const guessResults = checkGuess(currentGuess as ColorKey[]);
249
- const isCorrect = guessResults.every((result) => result.status === 'correct');
250
-
251
- const guessData: GuessData = {
252
- index: previousGuesses.length,
253
- colors: currentGuess as ColorKey[],
254
- results: guessResults,
255
- isCorrect: isCorrect,
256
- start: guessStartTime,
257
- end: currentTime,
258
- duration: currentTime - guessStartTime,
259
- };
260
-
261
- setAccumulatedGuesses((prev) => [...prev, guessData]);
262
-
263
- setPreviousGuesses((prev) => [
264
- ...prev,
265
- {
266
- colors: currentGuess as ColorKey[],
267
- results: guessResults,
268
- },
269
- ]);
270
-
271
- setSelectedColor(null);
272
-
273
- if (isCorrect) {
274
- toast.success('You found the solution! Continue to the next trial.', {
275
- closeOnClick: true,
276
- transition: Bounce,
277
- });
278
- setSelectedColor(null);
279
- setRoundOver(true);
280
- return;
281
- }
282
-
283
- setGuessesLeft((prev) => prev - 1);
284
- if (guessesLeft == 0) {
285
- toast.error('Out of guesses! Continue to the next trial.', {
286
- closeOnClick: true,
287
- transition: Bounce,
288
- });
289
- setSelectedColor(null);
290
- setRoundOver(true);
291
- }
292
-
293
- if (localTimeLeft == 0) {
294
- toast.error('Out of time! Continue to the next trial.', {
295
- closeOnClick: true,
296
- transition: Bounce,
297
- });
298
- setSelectedColor(null);
299
- setRoundOver(true);
300
- }
301
-
302
- setCurrentGuess([null, null, null, null]);
303
- setGuessStartTime(currentTime);
304
- };
305
-
306
- const handleNext = (skipped: boolean) => {
307
- setTimeLeft(localTimeLeft);
308
- next({
309
- solution: solution,
310
- solved: accumulatedGuesses.some((guess: GuessData) => guess.isCorrect),
311
- skipped: skipped,
312
- timeLeft_s: timeLeft,
313
- guesses: accumulatedGuesses,
314
- });
315
- };
316
-
317
- return (
318
- <div className='mt-16 md:p-8 lg:mt-16 max-w-7xl h-[calc(100vh-230px)] lg:h-full w-fit mx-auto flex flex-col lg:flex-row xl:gap-x-12 lg:gap-x-8 justify-between lg:justify-center'>
319
- <div className='absolute inset-0 -z-10 h-full w-full bg-white bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px]'></div>
320
-
321
- {/* Action Buttons */}
322
- <div className='flex gap-6 xl:w-56 xl:px-12 lg:p-4 flex-row justify-center lg:justify-start lg:flex-col'>
323
- {!roundOver && (
324
- <button
325
- className='bg-white px-6 md:px-8 py-3 text-sm md:text-lg border-2 border-black font-bold rounded-full shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
326
- onClick={handleCheck}
327
- >
328
- CHECK
329
- </button>
330
- )}
331
- {!roundOver && (
332
- <button
333
- className='bg-white px-6 md:px-8 py-1 md:py-3 text-sm md:text-lg border-2 border-black font-bold rounded-full shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
334
- onClick={() => setCurrentGuess([null, null, null, null])}
335
- >
336
- CLEAR
337
- </button>
338
- )}
339
- {!roundOver && (
340
- <button
341
- className='bg-white px-6 md:px-8 py-1 md:py-3 text-sm md:text-lg border-2 border-black font-bold border-red-500 text-red-500 rounded-full shadow-[2px_2px_0px_rgba(239,68,68,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
342
- onClick={() => {
343
- setQuitLastGame(true);
344
- handleNext(true);
345
- }}
346
- >
347
- SKIP
348
- </button>
349
- )}
350
- {roundOver && (
351
- <button
352
- className='bg-white px-6 md:px-8 py-3 md:py-3 text-sm md:text-lg border-2 border-black font-bold text-black rounded-full shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
353
- onClick={() => {
354
- handleNext(false);
355
- }}
356
- >
357
- NEXT
358
- </button>
359
- )}
360
-
361
- </div>
362
-
363
- {/* Gameboard */}
364
- <div className='flex flex-col justify-between order-first items-center lg:order-none min-h-0'>
365
- <div className='space-y-4 -mt-8 sm:mt-0 md:space-y-8 flex-1'>
366
- {/* Timer */}
367
- <div className='flex justify-center items-center gap-6'>
368
- <div className='text-lg text-center sm:text-2xl font-bold w-20 sm:text-left'>
369
- {Math.floor(localTimeLeft / 60)}:{(localTimeLeft % 60).toString().padStart(2, '0')}
370
- </div>
371
- </div>
372
- {/* Current Guess Slots */}
373
- <div className='py-5 sm:py-10 md:p-10 rounded-lg'>
374
- <div className='flex gap-4 sm:gap-8 justify-center relative w-fit mx-auto'>
375
- <div className='absolute top-1/2 h-1 left-0 right-0 bg-gray-300 -z-10' />
376
- {currentGuess.map((color: ColorKey | null, index: number) => (
377
- <ColorOrb
378
- key={index}
379
- color={color ?? 'grey'}
380
- size={screenWidth >= 600 ? 24 : 16}
381
- hoverborder={selectedColor != null || (!!color && color !== 'grey')}
382
- onClick={() => {
383
- if (roundOver) {
384
- return;
385
- }
386
- if (!selectedColor || selectedColor === 'grey') {
387
- if (!!color && color != 'grey') {
388
- setCurrentGuess((prevGuess) =>
389
- prevGuess.map((color, i) => (i === index ? null : color)),
390
- );
391
- return;
392
- }
393
- toast('Please select a color first!', {
394
- position: 'top-center',
395
- hideProgressBar: true,
396
- closeOnClick: true,
397
- pauseOnHover: true,
398
- draggable: false,
399
- progress: undefined,
400
- theme: 'light',
401
- transition: Bounce,
402
- });
403
- return;
404
- }
405
-
406
- if (selectedColor === color) {
407
- setCurrentGuess((prevGuess) =>
408
- prevGuess.map((color, i) => (i === index ? null : color)),
409
- );
410
- return;
411
- }
412
-
413
- setCurrentGuess((prevGuess) =>
414
- prevGuess.map((color, i) => (i === index ? selectedColor : color)),
415
- );
416
- }}
417
- />
418
- ))}
419
- </div>
420
- </div>
421
-
422
- {/* Previous Guesses */}
423
- <div
424
- ref={guessesContainerRef}
425
- className='space-y-6 md:p-4 lg:p-16 border-gray-400 h-[25vh] lg:h-[40vh] overflow-y-auto'
426
- >
427
- {previousGuesses.map((guess, rowNum) => (
428
- <div
429
- key={rowNum}
430
- className={`flex items-center gap-4 sm:gap-8 justify-center ${feedback == 3 ? 'flex-col sm:flex-row' : ''}`}
431
- >
432
- <div className='hidden sm:block w-4 sm:w-8 text-xl'>{rowNum + 1}</div>
433
- <div className='flex gap-2 sm:gap-4 flex-row items-center'>
434
- <div className='w-4 sm:hidden sm:w-8 text-xl'>{rowNum + 1}</div>
435
- {guess.colors.map((color, index) => (
436
- <div key={index} className='flex flex-col items-center'>
437
- <ColorOrb key={index} color={color} size={12} />
438
- {feedback == 4 && (
439
- <span>
440
- {guess.results[index].status === 'correct' && '✓'}
441
- {guess.results[index].status !== 'correct' && <>&nbsp;</>}
442
- </span>
443
- )}
444
- {feedback == 5 && (
445
- <span>
446
- {guess.results[index].status == 'correct' && '✓'}
447
- {guess.results[index].status == 'incorrect' && '✗'}
448
- {guess.results[index].status == 'wrong-position' && 'C'}
449
- </span>
450
- )}
451
- </div>
452
- ))}
453
- </div>
454
- <div className='flex items-center gap-4 text-lg'>
455
- {feedback == 1 && (
456
- <span>
457
- {guess.results.filter((result) => result.status !== 'correct').length == 0 ? (
458
- <span className='font-bold text-blue-600'>✓</span>
459
- ) : (
460
- <span className='font-bold text-red-600'>✗</span>
461
- )}
462
- </span>
463
- )}
464
- {feedback == 2 && (
465
- <>
466
- <span className='font-bold text-blue-600'>✓</span>
467
- <span>
468
- {guess.results.filter((result) => result.status === 'correct').length}
469
- </span>
470
- <span className='font-bold text-red-600'>✗</span>
471
- <span>
472
- {guess.results.filter((result) => result.status !== 'correct').length}
473
- </span>
474
- </>
475
- )}
476
- {feedback == 3 && (
477
- <>
478
- <span className='font-bold text-blue-600'>✓</span>
479
- <span>
480
- {guess.results.filter((result) => result.status === 'correct').length}
481
- </span>
482
- <span className='font-bold text-red-600'>✗</span>
483
- <span>
484
- {guess.results.filter((result) => result.status === 'incorrect').length}
485
- </span>
486
- <span className='font-bold'>C</span>
487
- <span>
488
- {
489
- guess.results.filter((result) => result.status === 'wrong-position')
490
- .length
491
- }
492
- </span>
493
- </>
494
- )}
495
- {feedback == 4 && (
496
- <>
497
- <span className='font-bold text-red-600'>✗</span>
498
- <span>
499
- {guess.results.filter((result) => result.status === 'incorrect').length}
500
- </span>
501
- <span className='font-bold'>C</span>
502
- <span>
503
- {
504
- guess.results.filter((result) => result.status === 'wrong-position')
505
- .length
506
- }
507
- </span>
508
- </>
509
- )}
510
- </div>
511
- </div>
512
- ))}
513
- </div>
514
- </div>
515
- </div>
516
-
517
- {/* Right Side - Color Selection */}
518
- <div className='lg:space-y-6 xl:px-8 flex flex-row justify-center gap-x-4 sm:gap-x-12 lg:gap-x-0 lg:justify-start lg:flex-col'>
519
- {(Object.keys(COLORS) as ColorKey[])
520
- .filter((color) => color !== 'grey')
521
- .map((color) => (
522
- <div key={color} className='flex items-center gap-4'>
523
- <ColorOrb
524
- color={color}
525
- size={16}
526
- interactive={selectedColor != color}
527
- pressed={selectedColor == color}
528
- onClick={() => {
529
- if (roundOver) {
530
- return;
531
- }
532
- if (selectedColor == color) {
533
- setSelectedColor(null);
534
- return;
535
- }
536
- setSelectedColor(color);
537
- }}
538
- />
539
- <span
540
- className={`hidden lg:inline uppercase text-lg ${selectedColor == color ? 'underline underline-offset-2' : ''}`}
541
- >
542
- {color}
543
- </span>
544
- </div>
545
- ))}
546
- </div>
547
- </div>
548
- );
549
- }
550
-
551
- interface MMTrialData {
552
- type: 'game' | 'survey';
553
- index: number;
554
- start: number;
555
- end: number;
556
- duration: number;
557
- data: object;
558
- quitLastGame?: boolean;
559
- }
560
-
561
- function MasterMindleWrapper({
562
- next,
563
- blockIndex,
564
- feedback,
565
- timeLimit = 120,
566
- maxGuesses = 10,
567
- }: {
568
- next: (data: object) => void;
569
- blockIndex: number;
570
- feedback: 1 | 2 | 3 | 4 | 5;
571
- timeLimit: number;
572
- maxGuesses: number;
573
- }) {
574
- const [gameState, setGameState] = useState<'game' | 'survey'>('game');
575
- const [timeLeft, setTimeLeft] = useState(timeLimit);
576
- const [trialStartTime, setTrialStartTime] = useState(now());
577
- const [accumulatedData, setAccumulatedData] = useState<MMTrialData[]>([]);
578
- const [quitLastGame, setQuitLastGame] = useState<boolean>(false);
579
- const [trialIndex, setTrialIndex] = useState(0);
580
-
581
- function switchGameState(newData: object) {
582
- const currentTime = now();
583
-
584
- const trialData: MMTrialData = {
585
- type: gameState,
586
- index: trialIndex,
587
- start: trialStartTime,
588
- end: currentTime,
589
- duration: currentTime - trialStartTime,
590
- data: newData,
591
- };
592
-
593
- if (gameState === 'survey' && timeLeft <= 0) {
594
- next({
595
- blockIndex: blockIndex,
596
- feedbacktype: feedback,
597
- timelimit_s: timeLimit,
598
- data: [...accumulatedData, trialData],
599
- });
600
- return;
601
- }
602
-
603
- setAccumulatedData((prev) => [...prev, trialData]);
604
-
605
- if (gameState === 'survey') {
606
- setQuitLastGame(false);
607
- }
608
-
609
- setTrialStartTime(currentTime);
610
- setTrialIndex((prev) => prev + 1);
611
- setGameState(gameState === 'survey' ? 'game' : 'survey');
612
- }
613
-
614
- if (gameState === 'survey') {
615
- return (
616
- <Quest
617
- next={switchGameState}
618
- surveyJson={{
619
- pages: [
620
- {
621
- elements: [
622
- {
623
- type: 'rating',
624
- name: 'intensityofeffort',
625
- title: 'How effortful was guessing this combination for you?',
626
- isRequired: true,
627
- rateMin: 1,
628
- rateMax: 6,
629
- minRateDescription: 'Minimal Effort',
630
- maxRateDescription: 'Maximum Effort',
631
- },
632
- ...(quitLastGame
633
- ? [
634
- {
635
- type: 'voicerecorder',
636
- name: 'whyskip',
637
- title: 'Why did you chose to quit before you found the solution?',
638
- isRequired: true,
639
- },
640
- ]
641
- : [{}]),
642
- ],
643
- },
644
- ],
645
- }}
646
- />
647
- );
648
- }
649
- return (
650
- <MasterMindle
651
- feedback={feedback}
652
- next={switchGameState}
653
- maxTime={timeLimit}
654
- maxGuesses={maxGuesses}
655
- timeLeft={timeLeft}
656
- setTimeLeft={setTimeLeft}
657
- setQuitLastGame={setQuitLastGame}
658
- />
659
- );
660
- }
661
-
662
- export default MasterMindleWrapper;