@adriansteffan/reactive 0.0.9

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.
Files changed (55) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/.prettierrc +5 -0
  3. package/Dockerfile +20 -0
  4. package/README.md +68 -0
  5. package/bin/setup.js +100 -0
  6. package/dist/mod.d.ts +102 -0
  7. package/dist/reactivepsych.es.js +71241 -0
  8. package/dist/reactivepsych.umd.js +120 -0
  9. package/dist/style.css +5 -0
  10. package/dist/tailwind.config.js +33 -0
  11. package/package.json +75 -0
  12. package/postcss.config.js +6 -0
  13. package/src/components/experiment.tsx +156 -0
  14. package/src/components/experimentprovider.tsx +28 -0
  15. package/src/components/mastermindlewrapper.tsx +662 -0
  16. package/src/components/microphonecheck.tsx +167 -0
  17. package/src/components/quest.tsx +102 -0
  18. package/src/components/text.tsx +45 -0
  19. package/src/components/upload.tsx +149 -0
  20. package/src/components/voicerecorder.tsx +346 -0
  21. package/src/index.css +74 -0
  22. package/src/mod.tsx +14 -0
  23. package/src/utils/common.ts +80 -0
  24. package/src/utils/request.ts +25 -0
  25. package/src/vite-env.d.ts +1 -0
  26. package/tailwind.config.js +33 -0
  27. package/template/.dockerignore +5 -0
  28. package/template/.eslintrc.cjs +18 -0
  29. package/template/.prettierrc +5 -0
  30. package/template/Dockerfile +25 -0
  31. package/template/README.md +102 -0
  32. package/template/backend/package-lock.json +2398 -0
  33. package/template/backend/package.json +31 -0
  34. package/template/backend/src/backend.ts +99 -0
  35. package/template/backend/tsconfig.json +110 -0
  36. package/template/docker-compose.yaml +13 -0
  37. package/template/index.html +15 -0
  38. package/template/package-lock.json +6031 -0
  39. package/template/package.json +48 -0
  40. package/template/postcss.config.js +6 -0
  41. package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Bold.ttf +0 -0
  42. package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-BoldItalic.ttf +0 -0
  43. package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Italic.ttf +0 -0
  44. package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Regular.ttf +0 -0
  45. package/template/public/Atkinson_Hyperlegible/OFL.txt +93 -0
  46. package/template/src/App.tsx +116 -0
  47. package/template/src/index.css +3 -0
  48. package/template/src/main.tsx +14 -0
  49. package/template/tailwind.config.js +7 -0
  50. package/template/tsconfig.json +25 -0
  51. package/template/tsconfig.node.json +11 -0
  52. package/template/vite.config.ts +24 -0
  53. package/tsconfig.json +28 -0
  54. package/tsconfig.node.json +12 -0
  55. package/vite.config.ts +48 -0
@@ -0,0 +1,167 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { VoiceRecorder } from './voicerecorder';
3
+
4
+ interface MicrophoneDevice {
5
+ deviceId: string;
6
+ label: string;
7
+ }
8
+
9
+ export function MicrophoneSelect({
10
+ onMicrophoneSelect,
11
+ }: {
12
+ onMicrophoneSelect: (deviceId: string) => void;
13
+ }) {
14
+ const [microphones, setMicrophones] = useState<MicrophoneDevice[]>([]);
15
+ const [selectedMic, setSelectedMic] = useState<string>('');
16
+ const [isOpen, setIsOpen] = useState(false);
17
+
18
+ useEffect(() => {
19
+ // Request microphone permissions and get list of devices
20
+ navigator.mediaDevices
21
+ .getUserMedia({ audio: true })
22
+ .then(() => {
23
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
24
+ const mics = devices
25
+ .filter((device) => device.kind === 'audioinput')
26
+ .map((device) => ({
27
+ deviceId: device.deviceId,
28
+ label: device.label || `Microphone ${device.deviceId.slice(0, 5)}`,
29
+ }));
30
+ setMicrophones(mics);
31
+
32
+ // Set default microphone
33
+ if (mics.length > 0 && !selectedMic) {
34
+ setSelectedMic(mics[0].deviceId);
35
+ onMicrophoneSelect(mics[0].deviceId);
36
+ }
37
+ });
38
+ })
39
+ .catch((err) => {
40
+ console.error('Error accessing microphone:', err);
41
+ });
42
+ }, [onMicrophoneSelect, selectedMic]);
43
+
44
+ const handleSelect = (deviceId: string) => {
45
+ setSelectedMic(deviceId);
46
+ onMicrophoneSelect(deviceId);
47
+ setIsOpen(false);
48
+ };
49
+
50
+ const selectedLabel =
51
+ microphones.find((mic) => mic.deviceId === selectedMic)?.label || 'Select Microphone';
52
+
53
+ return (
54
+ <div className='relative w-64 mx-auto mt-8 mb-8'>
55
+ {/* Selected Option Display */}
56
+ <button
57
+ onClick={() => setIsOpen(!isOpen)}
58
+ className={`
59
+ w-full px-4 py-2
60
+ bg-white text-black
61
+ border-2 border-black
62
+ shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]
63
+ hover:shadow-[1px_1px_0px_0px_rgba(0,0,0,1)]
64
+ active:shadow-none
65
+ active:translate-x-1
66
+ active:translate-y-1
67
+ transition-all
68
+ text-left
69
+ font-bold
70
+ ${isOpen ? 'shadow-none translate-x-1 translate-y-1' : ''}
71
+ `}
72
+ >
73
+ <div className='flex items-center justify-between'>
74
+ <span className='truncate'>{selectedLabel}</span>
75
+ <span className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}>▼</span>
76
+ </div>
77
+ </button>
78
+
79
+ {/* Dropdown Options */}
80
+ {isOpen && (
81
+ <div className='absolute w-full mt-2 z-50'>
82
+ <ul
83
+ className={`
84
+ bg-white
85
+ border-2 border-black
86
+ shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]
87
+ max-h-60 overflow-auto
88
+ `}
89
+ >
90
+ {microphones.map((mic) => (
91
+ <li
92
+ key={mic.deviceId}
93
+ onClick={() => handleSelect(mic.deviceId)}
94
+ className={`
95
+ px-4 py-2
96
+ cursor-pointer
97
+ hover:bg-black hover:text-white
98
+ transition-colors
99
+ ${selectedMic === mic.deviceId ? 'bg-black text-white' : ''}
100
+ border-b-2 border-black last:border-b-0
101
+ `}
102
+ >
103
+ {mic.label}
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ const MicrophoneCheck = ({ next }: { next: (data: object) => void }) => {
114
+ const [recordingExists, setRecordingExists] = useState(false);
115
+
116
+ return (
117
+ <div className={`max-w-prose mx-auto mt-20 mb-20`}>
118
+ <article className='prose prose-2xl prose-slate text-xl text-black leading-relaxed'>
119
+ <h1 className='text-3xl mb-6 font-semibold'>Let's test your microphone!</h1>
120
+ <p>
121
+ In this experiment, you will need to answer some questions verbally. To make sure that we
122
+ don't lose your input, please select your preferred microphone using the menu below...
123
+ </p>
124
+ </article>
125
+ {/*This is really hacky but it works and there is a deadline, we should find a way to pass around such values in the future */}
126
+ {/*eslint-disable-next-line @typescript-eslint/no-explicit-any*/}
127
+ <MicrophoneSelect onMicrophoneSelect={(id: string) => ((window as any).audioInputId = id)} />
128
+
129
+ <article className='prose prose-2xl prose-slate text-xl text-black leading-relaxed'>
130
+ <p>
131
+ ... and use the recording button below to make a test recording of your voice. After
132
+ stopping the recording, you can use the play button to play back the audio.
133
+ </p>
134
+ </article>
135
+
136
+ <VoiceRecorder
137
+ question={{ value: null }}
138
+ handleSaveVoiceData={() => setRecordingExists(true)}
139
+ handleDiscardVoiceData={() => setRecordingExists(false)}
140
+ />
141
+
142
+ {recordingExists && (
143
+ <>
144
+ <article className='prose prose-2xl prose-slate text-xl text-black leading-relaxed'>
145
+ <p>
146
+ Can you hear yourself? Great! You can continue on to the next step. <br /> If you
147
+ can't hear your voice, try one of the other options from the list or check any
148
+ physical mute switches on your microphone. Be sure to also check that your computer
149
+ audio is turned on so you can actually listen back to your test recording!
150
+ </p>
151
+ </article>
152
+
153
+ <div className='mt-16 flex justify-center'>
154
+ <button
155
+ onClick={() => next({})}
156
+ className='bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
157
+ >
158
+ Next
159
+ </button>
160
+ </div>
161
+ </>
162
+ )}
163
+ </div>
164
+ );
165
+ };
166
+
167
+ export default MicrophoneCheck;
@@ -0,0 +1,102 @@
1
+ import { useCallback, createElement, ComponentType } from 'react';
2
+ import { defaultV2Css, Model, Question, Serializer } from 'survey-core';
3
+ import { ReactQuestionFactory, Survey, SurveyQuestionElementBase } from 'survey-react-ui';
4
+ import { ContrastLight } from 'survey-core/themes';
5
+ import 'survey-core/defaultV2.min.css';
6
+
7
+
8
+ type ComponentsMap = {
9
+ [key: string]: ComponentType<any>;
10
+ };
11
+
12
+
13
+ const registerCustomQuestion = (name: string, component: ComponentType<any>) => {
14
+
15
+ class CustomQuestionModel extends Question {
16
+ getType() {
17
+ return name;
18
+ }
19
+ }
20
+
21
+ Serializer.addClass(
22
+ name,
23
+ [],
24
+ function() {
25
+ return new CustomQuestionModel('');
26
+ },
27
+ 'question'
28
+ );
29
+
30
+ // Create the question wrapper
31
+ class CustomQuestionWrapper extends SurveyQuestionElementBase {
32
+ constructor(props: any) {
33
+ super(props);
34
+ this.state = { value: this.questionBase.value };
35
+ }
36
+
37
+ get question() {
38
+ return this.questionBase;
39
+ }
40
+
41
+ renderElement() {
42
+ const Component = component;
43
+ return (
44
+ <Component
45
+ setValue={(val: any) => {
46
+ this.question.value = val;
47
+ }}
48
+ />
49
+ );
50
+ }
51
+ }
52
+
53
+ // Register the React component
54
+ ReactQuestionFactory.Instance.registerQuestion(name, (props) => {
55
+ return createElement(CustomQuestionWrapper, props);
56
+ });
57
+ };
58
+
59
+ // The main Quest component
60
+ function Quest({ next, surveyJson, customQuestions = {} }: {
61
+ next: (data: object) => void;
62
+ surveyJson: object;
63
+ customQuestions?: ComponentsMap;
64
+ }) {
65
+
66
+ Object.keys(customQuestions).forEach(name => {
67
+ registerCustomQuestion(name, customQuestions[name]);
68
+ });
69
+
70
+
71
+ const survey = new Model({
72
+ ...surveyJson,
73
+ css: { ...defaultV2Css, root: 'sd-root-modern custom-root' }
74
+ });
75
+ survey.applyTheme(ContrastLight);
76
+
77
+ const saveResults = useCallback((sender: any) => {
78
+ const resultData = [];
79
+ for (const key in sender.data) {
80
+ const question = sender.getQuestionByName(key);
81
+ if (question) {
82
+ resultData.push({
83
+ name: key,
84
+ type: question.jsonObj.type,
85
+ value: question.value,
86
+ });
87
+ }
88
+ }
89
+ next(resultData);
90
+ }, [next]);
91
+
92
+ survey.onComplete.add(saveResults);
93
+
94
+ return (
95
+ <div className="max-w-4xl mx-auto">
96
+ <div className="absolute inset-0 -z-10 h-full w-full bg-white bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px]" />
97
+ <Survey model={survey} />
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export default Quest;
@@ -0,0 +1,45 @@
1
+ function Text({
2
+ content,
3
+ buttonText = 'Click me',
4
+ className = '',
5
+ next,
6
+ animate = false,
7
+ }: {
8
+ content: React.ReactNode;
9
+ buttonText?: string;
10
+ onButtonClick?: () => void;
11
+ className?: string;
12
+ next: (newData: object) => void;
13
+ animate?: boolean; // new parameter
14
+ }) {
15
+ const handleClick = () => {
16
+ next({});
17
+ };
18
+
19
+ return (
20
+ <div className={`max-w-prose mx-auto ${className} mt-20 mb-20`}>
21
+ <article
22
+ className={`prose prose-2xl prose-slate text-xl prose-h1:text-5xl text-black leading-relaxed
23
+ ${animate ? 'animate-slideDown opacity-0' : ''}`}
24
+ >
25
+ {content}
26
+ </article>
27
+
28
+ {buttonText && (
29
+ <div
30
+ className={`mt-16 flex justify-center ${animate ? 'animate-fadeIn opacity-0' : ''}`}
31
+ style={animate ? { animationDelay: '1s' } : {}}
32
+ >
33
+ <button
34
+ onClick={handleClick}
35
+ className='bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
36
+ >
37
+ {buttonText}
38
+ </button>
39
+ </div>
40
+ )}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export default Text;
@@ -0,0 +1,149 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { useMutation } from '@tanstack/react-query';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { post } from '../utils/request';
5
+ import { FileUpload, getParam, StudyEvent } from '../utils/common';
6
+ import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
7
+
8
+ interface UploadPayload {
9
+ sessionId: string;
10
+ files: FileUpload[];
11
+ }
12
+
13
+ interface UploadResponse {
14
+ status: number;
15
+ message?: string;
16
+ }
17
+
18
+ export default function Upload({
19
+ data,
20
+ next,
21
+ sessionID,
22
+ generateFiles,
23
+ uploadRaw = true,
24
+ }: {
25
+ data: StudyEvent[];
26
+ next: () => void;
27
+ sessionID?: string | null;
28
+ generateFiles: (sessionID: string, data: StudyEvent[]) => FileUpload[];
29
+ uploadRaw: boolean;
30
+ }) {
31
+ const [uploadState, setUploadState] = useState<'initial' | 'uploading' | 'success' | 'error'>(
32
+ 'initial',
33
+ );
34
+ const shouldUpload = getParam('upload', true, 'boolean');
35
+ const shouldDownload = getParam('download', false, 'boolean');
36
+
37
+ const uploadData = useMutation({
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ mutationFn: async (body: any) => {
40
+ const response = await post('/data', body);
41
+ return response as UploadResponse;
42
+ },
43
+ onSuccess: (res: UploadResponse) => {
44
+ if (res.status === 200) {
45
+ setUploadState('success');
46
+ next();
47
+ } else {
48
+ setUploadState('error');
49
+ }
50
+ },
51
+ onError: () => {
52
+ setUploadState('error');
53
+ },
54
+ });
55
+
56
+ const downloadFiles = useCallback(async (files: FileUpload[]) => {
57
+ const zipWriter = new ZipWriter(new BlobWriter());
58
+
59
+ for (const file of files) {
60
+ await zipWriter.add(file.filename, new TextReader(file.content));
61
+ }
62
+
63
+ const blob = await zipWriter.close();
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement('a');
66
+ a.href = url;
67
+ a.download = 'study-data.zip';
68
+ document.body.appendChild(a);
69
+ a.click();
70
+ document.body.removeChild(a);
71
+ URL.revokeObjectURL(url);
72
+ }, []);
73
+
74
+ const handleUpload = async () => {
75
+ setUploadState('uploading');
76
+
77
+ const sessionIDUpload = sessionID ?? uuidv4();
78
+
79
+ const files: FileUpload[] = generateFiles ? generateFiles(sessionIDUpload, data) : [];
80
+ if (uploadRaw) {
81
+ files.push({
82
+ filename: `${sessionIDUpload}.raw.json`,
83
+ content: JSON.stringify(data),
84
+ encoding: 'utf8',
85
+ });
86
+ }
87
+
88
+ try {
89
+ const payload: UploadPayload = {
90
+ sessionId: sessionIDUpload,
91
+ files,
92
+ };
93
+
94
+ if (shouldDownload) {
95
+ await downloadFiles(files);
96
+ }
97
+
98
+ if (!shouldUpload) {
99
+ next();
100
+ return;
101
+ }
102
+
103
+ uploadData.mutate(payload);
104
+ } catch (error) {
105
+ console.error('Error uploading:', error);
106
+ setUploadState('error');
107
+ }
108
+ };
109
+
110
+ return (
111
+ <div className='flex flex-col items-center justify-center gap-4 p-6 text-xl mt-16'>
112
+ {uploadState == 'initial' && (
113
+ <>
114
+ <p className=''>
115
+ Thank you for participating! Please click the button below to submit your data.
116
+ </p>
117
+ <button
118
+ onClick={handleUpload}
119
+ className='mt-8 bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
120
+ >
121
+ Submit Data
122
+ </button>
123
+ </>
124
+ )}
125
+ {uploadState == 'uploading' && (
126
+ <>
127
+ <div className='w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin'></div>
128
+ <p className=''>Uploading your data...</p>
129
+ </>
130
+ )}
131
+ {uploadState == 'success' && <></>}
132
+
133
+ {uploadState == 'error' && (
134
+ <>
135
+ <div className='text-red-500 mb-4'>
136
+ <p className=''>Sorry, there was an error uploading your data.</p>
137
+ <p>Please try again or contact the researcher.</p>
138
+ </div>
139
+ <button
140
+ onClick={handleUpload}
141
+ className='px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors'
142
+ >
143
+ Try Again
144
+ </button>
145
+ </>
146
+ )}
147
+ </div>
148
+ );
149
+ }