@adriansteffan/reactive 0.0.18 → 0.0.19

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,10 +1,13 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useMutation } from '@tanstack/react-query';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
  import { post } from '../utils/request';
5
5
  import { FileUpload, getParam, StudyEvent } from '../utils/common';
6
6
  import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
7
7
 
8
+ import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
9
+ import { Capacitor } from '@capacitor/core';
10
+
8
11
  interface UploadPayload {
9
12
  sessionId: string;
10
13
  files: FileUpload[];
@@ -15,22 +18,175 @@ interface UploadResponse {
15
18
  message?: string;
16
19
  }
17
20
 
21
+
22
+ interface FileBackend {
23
+ directoryExists(path: string): Promise<boolean>;
24
+ createDirectory(path: string): Promise<void>;
25
+ saveFile(filename: string, content: string, directory: string): Promise<void>;
26
+ }
27
+
28
+ const createElectronFileBackend = (): FileBackend => {
29
+ const electronAPI = (window as any).electronAPI;
30
+
31
+ return {
32
+ directoryExists: async (path: string): Promise<boolean> => {
33
+ const result = await electronAPI.directoryExists(path);
34
+ return result.success && result.exists;
35
+ },
36
+ createDirectory: async (path: string): Promise<void> => {
37
+ await electronAPI.createDirectory(path);
38
+ },
39
+ saveFile: async (filename: string, content: string, directory: string): Promise<void> => {
40
+ await electronAPI.saveFile(filename, content, directory);
41
+ }
42
+ };
43
+ };
44
+
45
+
46
+ const createCapacitorFileBackend = (parentFolder?: string): FileBackend => {
47
+
48
+ const getPath = (path: string): string => {
49
+ if (!parentFolder) {
50
+ return path;
51
+ }
52
+ return path ? `${parentFolder}/${path}` : parentFolder;
53
+ };
54
+
55
+ return {
56
+ directoryExists: async (path: string): Promise<boolean> => {
57
+ try {
58
+
59
+ if (parentFolder) {
60
+ try {
61
+ await Filesystem.readdir({
62
+ path: parentFolder,
63
+ directory: Directory.Documents
64
+ });
65
+ } catch {
66
+ // Parent folder doesn't exist, so subpath doesn't exist either
67
+ return false;
68
+ }
69
+ }
70
+
71
+ const fullPath = getPath(path);
72
+ const result = await Filesystem.readdir({
73
+ path: fullPath,
74
+ directory: Directory.Documents
75
+ });
76
+ return result.files.length >= 0;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+
82
+ createDirectory: async (path: string): Promise<void> => {
83
+ try {
84
+
85
+ if (parentFolder) {
86
+ try {
87
+ await Filesystem.mkdir({
88
+ path: parentFolder,
89
+ directory: Directory.Documents,
90
+ recursive: false
91
+ });
92
+ } catch (e) {
93
+ // Parent directory might already exist, that's fine
94
+ }
95
+ }
96
+
97
+ const fullPath = getPath(path);
98
+ await Filesystem.mkdir({
99
+ path: fullPath,
100
+ directory: Directory.Documents,
101
+ recursive: true
102
+ });
103
+ } catch (e) {
104
+ console.error('Error creating directory:', e);
105
+ throw e;
106
+ }
107
+ },
108
+
109
+ saveFile: async (filename: string, content: string, directory: string): Promise<void> => {
110
+
111
+ const fullDirectory = getPath(directory);
112
+
113
+ await Filesystem.writeFile({
114
+ path: `${fullDirectory}/${filename}`,
115
+ data: content,
116
+ directory: Directory.Documents,
117
+ encoding: 'utf8' as Encoding,
118
+ });
119
+ }
120
+ };
121
+ };
122
+
123
+ export type Platform = 'electron' | 'capacitor' | 'web';
124
+
125
+ export const getPlatform = (): Platform => {
126
+ if ((window as any).electronAPI) {
127
+ return 'electron';
128
+ } else if (Capacitor.isNativePlatform()) {
129
+ return 'capacitor';
130
+ } else {
131
+ return 'web';
132
+ }
133
+ };
134
+
135
+ const getFileBackend = (parentDir?: string): { backend: FileBackend | null, type: Platform } => {
136
+ const platform = getPlatform();
137
+
138
+ switch (platform) {
139
+ case 'electron':
140
+ return { backend: createElectronFileBackend(), type: platform };
141
+ case 'capacitor':
142
+ return { backend: createCapacitorFileBackend(parentDir), type: platform };
143
+ case 'web':
144
+ return { backend: null, type: platform };
145
+ }
146
+ };
147
+
148
+ // Function to generate a unique directory name
149
+ const getUniqueDirectoryName = async (
150
+ backend: FileBackend,
151
+ baseSessionId: string
152
+ ): Promise<string> => {
153
+ let uniqueSessionID = baseSessionId;
154
+
155
+ if (await backend.directoryExists(uniqueSessionID)) {
156
+ let counter = 1;
157
+ uniqueSessionID = `${baseSessionId}_${counter}`;
158
+
159
+ while (await backend.directoryExists(uniqueSessionID)) {
160
+ counter++;
161
+ uniqueSessionID = `${baseSessionId}_${counter}`;
162
+ }
163
+ }
164
+
165
+ return uniqueSessionID;
166
+ };
167
+
18
168
  export default function Upload({
19
169
  data,
20
170
  next,
21
171
  sessionID,
22
172
  generateFiles,
23
173
  uploadRaw = true,
174
+ autoUpload = false,
175
+ androidFolderName,
24
176
  }: {
25
177
  data: StudyEvent[];
26
178
  next: () => void;
27
179
  sessionID?: string | null;
28
180
  generateFiles: (sessionID: string, data: StudyEvent[]) => FileUpload[];
29
181
  uploadRaw: boolean;
182
+ autoUpload: boolean;
183
+ androidFolderName?: string;
30
184
  }) {
31
185
  const [uploadState, setUploadState] = useState<'initial' | 'uploading' | 'success' | 'error'>(
32
186
  'initial',
33
187
  );
188
+ const uploadInitiatedRef = useRef(false);
189
+
34
190
  const shouldUpload = getParam('upload', true, 'boolean');
35
191
  const shouldDownload = getParam('download', false, 'boolean');
36
192
 
@@ -70,12 +226,19 @@ export default function Upload({
70
226
  document.body.removeChild(a);
71
227
  URL.revokeObjectURL(url);
72
228
  }, []);
73
-
74
- const handleUpload = async () => {
229
+
230
+
231
+ const handleUpload = useCallback(async () => {
75
232
  setUploadState('uploading');
76
-
233
+
234
+ if (uploadInitiatedRef.current) {
235
+ return;
236
+ }
237
+
238
+ uploadInitiatedRef.current = true;
239
+
77
240
  const sessionIDUpload = sessionID ?? uuidv4();
78
-
241
+
79
242
  const files: FileUpload[] = generateFiles ? generateFiles(sessionIDUpload, data) : [];
80
243
  if (uploadRaw) {
81
244
  files.push({
@@ -84,32 +247,80 @@ export default function Upload({
84
247
  encoding: 'utf8',
85
248
  });
86
249
  }
87
-
250
+
88
251
  try {
89
252
  const payload: UploadPayload = {
90
253
  sessionId: sessionIDUpload,
91
254
  files,
92
255
  };
93
-
256
+
94
257
  if (shouldDownload) {
95
258
  await downloadFiles(files);
96
259
  }
97
-
260
+
98
261
  if (!shouldUpload) {
99
262
  next();
100
263
  return;
101
264
  }
102
-
103
- uploadData.mutate(payload);
265
+
266
+ // Get the current platform and appropriate file backend
267
+ const { backend, type } = getFileBackend(androidFolderName);
268
+
269
+ if (type === 'web') {
270
+ // Web API case
271
+ uploadData.mutate(payload);
272
+ } else if (backend) {
273
+ try {
274
+ // Get a unique directory name
275
+ const uniqueSessionID = await getUniqueDirectoryName(backend, sessionIDUpload);
276
+
277
+ // Create the directory
278
+ await backend.createDirectory(uniqueSessionID);
279
+
280
+ // Save all files
281
+ for (const file of files) {
282
+ await backend.saveFile(file.filename, file.content, uniqueSessionID);
283
+ }
284
+
285
+ setUploadState('success');
286
+ next();
287
+ } catch (error) {
288
+ console.error(`Error saving files with ${type}:`, error);
289
+ setUploadState('error');
290
+ }
291
+ }
104
292
  } catch (error) {
105
293
  console.error('Error uploading:', error);
106
294
  setUploadState('error');
107
295
  }
108
- };
296
+ }, [
297
+ sessionID,
298
+ generateFiles,
299
+ data,
300
+ uploadRaw,
301
+ shouldDownload,
302
+ shouldUpload,
303
+ downloadFiles,
304
+ next,
305
+ uploadData,
306
+ ]);
307
+
308
+ useEffect(() => {
309
+ if (autoUpload && !uploadInitiatedRef.current && handleUpload) {
310
+ handleUpload();
311
+ }
312
+ }, [autoUpload, handleUpload]);
313
+
314
+ // reset the duplicate prevention if there was an error uploading
315
+ useEffect(() => {
316
+ if (uploadState === 'error') {
317
+ uploadInitiatedRef.current = false;
318
+ }
319
+ }, [uploadState]);
109
320
 
110
321
  return (
111
322
  <div className='flex flex-col items-center justify-center gap-4 p-6 text-xl mt-16'>
112
- {uploadState == 'initial' && (
323
+ {uploadState == 'initial' && !autoUpload && (
113
324
  <>
114
325
  <p className=''>
115
326
  Thank you for participating! Please click the button below to submit your data.
package/src/mod.tsx CHANGED
@@ -4,11 +4,13 @@ import ProlificEnding from './components/prolificending';
4
4
  import MicCheck from './components/microphonecheck';
5
5
  import Quest from './components/quest';
6
6
  import Upload from './components/upload';
7
+ import EnterFullscreen from './components/enterfullscreen';
8
+ import ExitFullscreen from './components/exitfullscreen';
7
9
  import ExperimentProvider from './components/experimentprovider';
8
10
  import Experiment from './components/experiment';
9
- import { shuffle, BaseComponentProps, ExperimentConfig } from './utils/common';
11
+ import { BaseComponentProps, ExperimentConfig } from './utils/common';
10
12
 
11
- export { Text, ProlificEnding, MicCheck, Quest, Upload, Experiment, ExperimentProvider, shuffle };
13
+ export { Text, ProlificEnding, MicCheck, Quest, Upload, EnterFullscreen, ExitFullscreen, Experiment, ExperimentProvider};
12
14
  export type { BaseComponentProps, ExperimentConfig };
13
15
  export * from './utils/common';
14
16
 
@@ -11,6 +11,10 @@ export function shuffle(array: any[]) {
11
11
  return array;
12
12
  }
13
13
 
14
+ export function isDesktop(){
15
+ return (window as any).electronAPI !== undefined
16
+ }
17
+
14
18
  // Generic type for all data structures
15
19
  export interface StudyEvent {
16
20
  index: number;
@@ -1,5 +1,8 @@
1
1
  node_modules
2
+ dist-electron
2
3
  dist
3
4
  .git
4
5
  *.log
5
6
  data/
7
+ android
8
+ ios
@@ -2,11 +2,53 @@
2
2
 
3
3
  Built with [reactive](https://github.com/adriansteffan/reactive)
4
4
 
5
- ## Setup
5
+ ## Prerequisites and Setup
6
6
 
7
- ## Deployment / Production
7
+ Regardless of what platform you target with your experiment, you will need a current version of [node.js](https://nodejs.org/en/download/) installed on your system for development.
8
8
 
9
- For deployment, you will need[docker](https://docs.docker.com/engine/install/)
9
+ To install the needed dependencies, run the following in the root directory:
10
+
11
+ ```
12
+ npm i && npm i --prefix backend
13
+ ```
14
+
15
+ You can target multiple platforms at once, just follow the setup processes for all platforms you want to run you study on.
16
+
17
+ ## Target: Web (Online Experiment)
18
+
19
+ This is the version of the experiment you should use if your testing devices have internet access and don't need any device-specific functionalities (e.g. sensors). The web version will work on all platforms! The specific targets serve as replacements in situations where a stable internet connection might not be given, or privacy directives prevent you from uploading data to a server (and/or you need device-specific functionality).
20
+
21
+ ### Development
22
+
23
+ The web version of the experiment needs no additional setup for development.
24
+
25
+ Run the app in development mode with
26
+
27
+ ```
28
+ npm run dev:all
29
+ ```
30
+ in the root directory.
31
+
32
+ By default, open [http://localhost:5173](http://localhost:5173) to view it in the browser.
33
+ The page will reload if you make edits.
34
+
35
+
36
+ #### Buidling the frontend locally (to test)
37
+
38
+
39
+ From the `frontend` directory, run
40
+
41
+ ```
42
+ npm run build
43
+ ```
44
+
45
+ the resulting output can be found in `frontend/dist/`
46
+
47
+
48
+ ### Deployment
49
+
50
+
51
+ For deployment, you will need a server with an installation of [docker](https://docs.docker.com/engine/install/)
10
52
 
11
53
 
12
54
  To build the docker images, run
@@ -17,7 +59,7 @@ docker compose build
17
59
 
18
60
  in the root directory. This might take a while.
19
61
 
20
- ### Running the app
62
+ #### Running the app
21
63
 
22
64
  After completing the setup, start the webapp with
23
65
 
@@ -34,69 +76,156 @@ docker compose down
34
76
  The server will be attached to the ports you specified in the .env files.
35
77
  Use Virtualhosts (Apache) or Server Blocks (Nginx) with reverse proxy to expose these to the outside. [This guide](https://gist.github.com/adriansteffan/48c9bda7237a8a7fcc5bb6987c8e1790) explains how to do this for our setup.
36
78
 
37
- ### Updating
79
+ #### Updating
38
80
 
39
- To update the app, simply stop the running containers, run a `git pull` and build the docker containers once more.
81
+ To update the app, simply stop the running containers, run a `git pull` and build the docker containers once more, and start the containers again.
40
82
 
41
- ## Development
83
+ #### Where is my data stored?
42
84
 
43
- ### Prerequisites
85
+ The server will create a "data" directory in the root directory of the cloned repo,
44
86
 
45
- You will need a current version of [node.js](https://nodejs.org/en/download/) installed on your system.
46
87
 
47
- ### Frontend
88
+ ## Target: Windows or MacOS
48
89
 
49
- #### Installation
90
+ ### Development
50
91
 
51
- From the root directory, run
92
+ The desktop version of the experiment needs no additional setup for development.
93
+
94
+ Run the app in the development mode with
52
95
 
53
96
  ```
54
- npm i && npm i --prefix backend
97
+ npm run electron:dev
55
98
  ```
99
+ in the root directory.
56
100
 
57
- #### Running
101
+ ### Packaging
58
102
 
59
- Run the app in the development mode with
103
+ To build platform specific executables, run either
60
104
 
61
105
  ```
62
- npm run dev:all
106
+ npm run package --mac
63
107
  ```
64
- in the root directory.
65
108
 
66
- By default, open [http://localhost:5173](http://localhost:5173) to view it in the browser.
67
- The page will reload if you make edits.
109
+ or
68
110
 
69
- #### Buidling the frontend locally (to test)
111
+ ```
112
+ npm run package --win
113
+ ```
70
114
 
115
+ There will be a directory called "release" that will contain your executables.
71
116
 
72
- From the `frontend` directory, run
117
+ #### Where is my data stored?
118
+
119
+ * Windows:
120
+ * On windows, a folder called "data" is created next the executable as soon as the first participant has completed the study
121
+
122
+ * MacOS:
123
+ * Right click on the application and go to TODO > TODO > TODO, where a folder called "data" is created as soon as the first participant has completed the study
124
+
125
+
126
+
127
+ ## Target: Android
128
+
129
+ For ease of use, it makes sense to run the web version for most of the development and only switch to the specific device emulators for implementing device-specific features and testing compatibility before packaging.
130
+
131
+ ### Setup
132
+
133
+ To build and test an Adroid app containing your experiment, you will need an installation of
134
+ [Android Studio](https://developer.android.com/studio) and [Java 21 or later](https://www.oracle.com/de/java/technologies/downloads/)
135
+
136
+ To set up the android project, run this command in the root directory:
137
+ ```
138
+ npx cap add android
139
+ ````
140
+
141
+ Next, find the [path to your local android sdk](https://stackoverflow.com/questions/25176594/android-sdk-location) and create a `local.properties` file in the `android` folder with the following content:
73
142
 
143
+ ```
144
+ sdk.dir=SDK_PATH_HERE
145
+ ```
146
+
147
+ For more information, refer to the [Capacitor documentation on Android](https://capacitorjs.com/docs/android)
148
+
149
+
150
+ ### Running
151
+
152
+ Whenever you have made changes to the code and want to run it on the Android emulator, run:
74
153
  ```
75
154
  npm run build
155
+ npx cap sync
156
+ npx cap run android
76
157
  ```
77
158
 
78
- the resulting output can be found in `frontend/dist/`
159
+ ### Packaging
160
+
161
+ From the root directory, run
162
+
163
+ ```
164
+ npx cap open android
165
+ ```
79
166
 
167
+ . Wait for Android Studio to install the project dependencies and then run Build > Generate APKs and Bundles > Generate APKs. Once your build is finished, there will be a popup guiding you to the location of the bundle's app that you can transfer to your Android device.
80
168
 
81
- ### Backend
169
+ #### Where is my data stored?
82
170
 
83
- #### Installing
171
+ In the "Internal Storage" section of the file browser, you will find a "Documents" folder that will contain subfolders with your data as soon as the first participant has finished the experiment.
84
172
 
85
- Run
173
+ ## Target: iOS
174
+
175
+ For ease of use, it makes sense to run the web version for most of the development and only switch to the specific device emulators for implementing device-specific features and testing compatibility before packaging.
176
+
177
+ ### Setup
178
+
179
+ To build an iOS app containing your experiment, you will need a machine running MacOS and an installation of [XCode](https://apps.apple.com/us/app/xcode/id497799835). Furthermore, you will need to install [CocoaPods](https://cocoapods.org/), which is most easily installed with [Homebrew](https://brew.sh/).
180
+
181
+ To set up the iOS project, run
182
+
183
+ ```
184
+ npx cap add android
185
+ ```
186
+
187
+ Then, go to `iso > App > App > Info.plist`
188
+
189
+ And add these lines next to the other keys:
86
190
 
87
191
  ```
88
- npm install
192
+ <key>UIFileSharingEnabled</key>
193
+ <string>YES</string>
194
+ <key>LSSupportsOpeningDocumentsInPlace</key>
195
+ <string>YES</string>
89
196
  ```
90
197
 
91
- in the `backend` directory to install all needed dependencies.
198
+ For more information, refer to the [Capacitor documentation on iOS](https://capacitorjs.com/docs/ios)
92
199
 
200
+ ### Running
93
201
 
94
- #### Running the backend
202
+ Whenever you have made changes to the code and want to run it on the iOS emulator, run:
95
203
 
204
+ ```
205
+ npm run build
206
+ npx cap sync
207
+ npx cap run ios
208
+ ```
209
+
210
+ ### Packaging
211
+
212
+ Packaging iOS apps is quite an undertaking, which is why the process is not overly streamlined/documented yet. The best way to get your experiment on an iOS device right now is to run
213
+
214
+ ```
215
+ npx cap run ios
216
+ ```
217
+
218
+ which will open the finished project in XCode. From there, follow a guide on how to sign, bundle and install your app/experiment.
219
+
220
+ #### Where is my data stored?
221
+
222
+ After the first participant is run, a folder with the name of your app will appear under "My iPhone/iPad" in the files app, which contains your data.
96
223
 
97
- TODO
98
224
 
99
225
 
100
226
  ## Authors
101
227
 
102
- * **Adrian Steffan** - [adriansteffan](https://github.com/adriansteffan)
228
+ * **Adrian Steffan** - [adriansteffan](https://github.com/adriansteffan)
229
+
230
+
231
+
@@ -0,0 +1,15 @@
1
+ import type { CapacitorConfig } from '@capacitor/cli';
2
+
3
+ const config: CapacitorConfig = {
4
+ appId: 'com.PROJECT_NAME.app',
5
+ appName: 'PROJECT_NAME',
6
+ webDir: 'dist',
7
+ server: {
8
+ hostname: 'localhost',
9
+ androidScheme: 'https',
10
+ cleartext: true,
11
+ allowNavigation: ['*']
12
+ }
13
+ };
14
+
15
+ export default config;