@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.
- package/.eslintrc.cjs +18 -0
- package/.prettierrc +5 -0
- package/Dockerfile +20 -0
- package/README.md +68 -0
- package/bin/setup.js +100 -0
- package/dist/mod.d.ts +102 -0
- package/dist/reactivepsych.es.js +71241 -0
- package/dist/reactivepsych.umd.js +120 -0
- package/dist/style.css +5 -0
- package/dist/tailwind.config.js +33 -0
- package/package.json +75 -0
- package/postcss.config.js +6 -0
- package/src/components/experiment.tsx +156 -0
- package/src/components/experimentprovider.tsx +28 -0
- package/src/components/mastermindlewrapper.tsx +662 -0
- package/src/components/microphonecheck.tsx +167 -0
- package/src/components/quest.tsx +102 -0
- package/src/components/text.tsx +45 -0
- package/src/components/upload.tsx +149 -0
- package/src/components/voicerecorder.tsx +346 -0
- package/src/index.css +74 -0
- package/src/mod.tsx +14 -0
- package/src/utils/common.ts +80 -0
- package/src/utils/request.ts +25 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +33 -0
- package/template/.dockerignore +5 -0
- package/template/.eslintrc.cjs +18 -0
- package/template/.prettierrc +5 -0
- package/template/Dockerfile +25 -0
- package/template/README.md +102 -0
- package/template/backend/package-lock.json +2398 -0
- package/template/backend/package.json +31 -0
- package/template/backend/src/backend.ts +99 -0
- package/template/backend/tsconfig.json +110 -0
- package/template/docker-compose.yaml +13 -0
- package/template/index.html +15 -0
- package/template/package-lock.json +6031 -0
- package/template/package.json +48 -0
- package/template/postcss.config.js +6 -0
- package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Bold.ttf +0 -0
- package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-BoldItalic.ttf +0 -0
- package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Italic.ttf +0 -0
- package/template/public/Atkinson_Hyperlegible/AtkinsonHyperlegible-Regular.ttf +0 -0
- package/template/public/Atkinson_Hyperlegible/OFL.txt +93 -0
- package/template/src/App.tsx +116 -0
- package/template/src/index.css +3 -0
- package/template/src/main.tsx +14 -0
- package/template/tailwind.config.js +7 -0
- package/template/tsconfig.json +25 -0
- package/template/tsconfig.node.json +11 -0
- package/template/vite.config.ts +24 -0
- package/tsconfig.json +28 -0
- package/tsconfig.node.json +12 -0
- 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
|
+
}
|