Skip to content

Commit 6e82d01

Browse files
authored
Merge branch 'DouglasNeuroInformatics:main' into working-on-todos
2 parents db39508 + aed3c5e commit 6e82d01

15 files changed

Lines changed: 205 additions & 38 deletions

File tree

apps/playground/src/components/Editor/Editor.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,14 @@ export const Editor = () => {
145145
/>
146146
<FileUploadDialog
147147
accept={{
148+
'application/json': ['.json'],
148149
'image/jpeg': ['.jpg', '.jpeg'],
149150
'image/png': ['.png'],
150151
'image/webp': ['.webp'],
151152
'text/css': ['.css'],
152153
'text/html': ['.html'],
153-
'text/plain': ['.js', '.jsx', '.ts', '.tsx']
154+
'text/plain': ['.js', '.jsx', '.ts', '.tsx'],
155+
'video/mp4': ['.mp4']
154156
}}
155157
isOpen={isFileUploadDialogOpen}
156158
setIsOpen={setIsFileUploadDialogOpen}

apps/playground/src/components/Editor/EditorFileIcon.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const EditorFileIcon = ({ filename }: EditorFileIconProps) => {
9292
/>
9393
</svg>
9494
))
95+
.with('.mp4', () => (
96+
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
97+
<path
98+
d="m24 6 2 6h-4l-2-6h-3l2 6h-4l-2-6h-3l2 6H8L6 6H5a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h22a3 3 0 0 0 3-3V6Z"
99+
fill="#ff9800"
100+
/>
101+
</svg>
102+
))
95103
.with('.html', () => (
96104
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
97105
<path
@@ -100,6 +108,14 @@ export const EditorFileIcon = ({ filename }: EditorFileIconProps) => {
100108
/>
101109
</svg>
102110
))
111+
.with('.json', () => (
112+
<svg viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg">
113+
<path
114+
d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"
115+
fill="#f9a825"
116+
/>
117+
</svg>
118+
))
103119
.otherwise(() => (
104120
<FileIcon />
105121
))}

apps/playground/src/components/Editor/EditorPane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const EditorPane = React.forwardRef<EditorPaneRef, EditorPaneProps>(funct
136136
return (
137137
<MonacoEditor
138138
className="h-full min-h-[576px]"
139-
defaultLanguage={fileType satisfies 'css' | 'html' | 'javascript' | 'typescript'}
139+
defaultLanguage={fileType satisfies 'css' | 'html' | 'javascript' | 'json' | 'typescript'}
140140
defaultValue={defaultFile.content}
141141
keepCurrentModel={true}
142142
key={defaultFile.id}

apps/playground/src/components/Header/UploadButton/UploadButton.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,46 @@ export const UploadButton = () => {
2020
const instruments = useAppStore((store) => store.instruments);
2121

2222
const handleSubmit = async (files: File[]) => {
23-
const zip = new JSZip() as JSZip & { comment?: unknown };
24-
await zip.loadAsync(files[0]!);
25-
let label: string;
2623
try {
27-
const comment = JSON.parse(String(zip.comment)) as unknown;
28-
if (isPlainObject(comment) && typeof comment.label === 'string') {
29-
label = comment.label;
30-
} else {
24+
const zip = new JSZip() as JSZip & { comment?: unknown };
25+
await zip.loadAsync(files[0]!);
26+
let label: string;
27+
try {
28+
const comment = JSON.parse(String(zip.comment)) as unknown;
29+
if (isPlainObject(comment) && typeof comment.label === 'string') {
30+
label = comment.label;
31+
} else {
32+
label = 'Unlabeled';
33+
}
34+
} catch {
3135
label = 'Unlabeled';
3236
}
33-
} catch {
34-
label = 'Unlabeled';
35-
}
36-
let suffixNumber = 1;
37-
let uniqueLabel = label;
38-
while (instruments.find((instrument) => instrument.label === uniqueLabel)) {
39-
uniqueLabel = `${label} (${suffixNumber})`;
40-
suffixNumber++;
37+
let suffixNumber = 1;
38+
let uniqueLabel = label;
39+
while (instruments.find((instrument) => instrument.label === uniqueLabel)) {
40+
uniqueLabel = `${label} (${suffixNumber})`;
41+
suffixNumber++;
42+
}
43+
const item: InstrumentRepository = {
44+
category: 'Saved',
45+
files: await loadEditorFilesFromZip(zip),
46+
id: crypto.randomUUID(),
47+
kind: null,
48+
label: uniqueLabel
49+
};
50+
addInstrument(item);
51+
setSelectedInstrument(item.id);
52+
addNotification({ type: 'success' });
53+
} catch (err) {
54+
console.error(err);
55+
addNotification({
56+
message: 'Please refer to browser console for details',
57+
title: 'Upload Failed',
58+
type: 'error'
59+
});
60+
} finally {
61+
setIsDialogOpen(false);
4162
}
42-
const item: InstrumentRepository = {
43-
category: 'Saved',
44-
files: await loadEditorFilesFromZip(zip),
45-
id: crypto.randomUUID(),
46-
kind: null,
47-
label: uniqueLabel
48-
};
49-
addInstrument(item);
50-
setSelectedInstrument(item.id);
51-
setIsDialogOpen(false);
52-
addNotification({ type: 'success' });
5363
};
5464

5565
return (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"timeLimit": 15
3+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core';
2+
import { useEffect, useState } from '/runtime/v1/react@18.x';
3+
import { createRoot } from '/runtime/v1/react-dom@18.x/client.js';
4+
import { z } from '/runtime/v1/zod@3.23.x';
5+
6+
import catVideo from './cat-video.mp4';
7+
import config from './config.json';
8+
9+
const Task: React.FC<{ done: (data: { success: boolean }) => void }> = ({ done }) => {
10+
const [secondsRemaining, setSecondsRemaining] = useState<number>(config.timeLimit);
11+
const [value, setValue] = useState('');
12+
13+
useEffect(() => {
14+
const interval = setInterval(() => {
15+
setSecondsRemaining((value) => value && value - 1);
16+
}, 1000);
17+
return () => clearInterval(interval);
18+
}, []);
19+
20+
useEffect(() => {
21+
if (!secondsRemaining) {
22+
done({ success: false });
23+
}
24+
}, [done, secondsRemaining]);
25+
26+
useEffect(() => {
27+
if (value.toLowerCase() === 'cat') {
28+
done({ success: true });
29+
}
30+
}, [value]);
31+
32+
return (
33+
<div
34+
style={{
35+
alignItems: 'center',
36+
display: 'flex',
37+
flexDirection: 'column',
38+
gap: '20px',
39+
justifyContent: 'center',
40+
minHeight: '80vh'
41+
}}
42+
>
43+
<h3>Which animal is in the video?</h3>
44+
<div>
45+
<label htmlFor="response">Response: </label>
46+
<input id="response" name="response" value={value} onChange={(event) => setValue(event.target.value)} />
47+
</div>
48+
<div>
49+
<span>Time Remaining: {secondsRemaining}</span>
50+
</div>
51+
<video controls muted>
52+
<source src={catVideo} type="video/mp4" />
53+
</video>
54+
</div>
55+
);
56+
};
57+
58+
export default defineInstrument({
59+
clientDetails: {
60+
estimatedDuration: 1,
61+
instructions: ['Please watch the video and then answer the question.']
62+
},
63+
content: {
64+
render(done) {
65+
const rootElement = document.createElement('div');
66+
document.body.appendChild(rootElement);
67+
const root = createRoot(rootElement);
68+
root.render(<Task done={done} />);
69+
}
70+
},
71+
details: {
72+
authors: ['Joshua Unrau'],
73+
description: 'This test assesses whether a person knows what a cat is',
74+
license: 'Apache-2.0',
75+
title: 'Interactive With Embedded Video'
76+
},
77+
internal: {
78+
edition: 1,
79+
name: 'CAT_VIDEO_TASK'
80+
},
81+
kind: 'INTERACTIVE',
82+
language: 'en',
83+
measures: {
84+
success: {
85+
kind: 'const',
86+
ref: 'success'
87+
}
88+
},
89+
tags: ['Interactive', 'React'],
90+
validationSchema: z.object({
91+
success: z.boolean()
92+
})
93+
});

apps/playground/src/instruments/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import { loadAssetAsBase64 } from '@/utils/load';
77
// Instruments in development
88
const EXCLUDED_LABELS: string[] = [];
99

10-
const textFiles: { [key: string]: string } = import.meta.glob('./**/*.{css,js,jsx,ts,tsx,svg}', {
10+
const textFiles: { [key: string]: string } = import.meta.glob('./**/*.{css,js,jsx,json,ts,tsx,svg}', {
1111
eager: true,
1212
import: 'default',
1313
query: '?raw'
1414
});
1515

16-
const binaryFiles: { [key: string]: string } = import.meta.glob('./**/*.{jpg,jpeg,png,webp}', {
16+
const binaryFiles: { [key: string]: string } = import.meta.glob('./**/*.{jpg,jpeg,png,webp,mp4}', {
1717
eager: true,
1818
import: 'default',
1919
query: '?url'

apps/playground/src/store/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ export const useAppStore = create(
2727
}))
2828
),
2929
{
30+
merge: (_persistedState, currentState) => {
31+
const persistedState = _persistedState as
32+
| Partial<Pick<AppStore, 'instruments' | 'selectedInstrument' | 'settings'>>
33+
| undefined;
34+
const instruments = [
35+
...currentState.instruments,
36+
...(persistedState?.instruments ?? []).filter((instrument) => {
37+
return instrument.category === 'Saved';
38+
})
39+
];
40+
const selectedInstrument =
41+
instruments.find(({ id }) => id === persistedState?.selectedInstrument?.id) ??
42+
currentState.selectedInstrument;
43+
const settings = persistedState?.settings ?? currentState.settings;
44+
return { ...currentState, instruments, selectedInstrument, settings };
45+
},
3046
name: 'app',
3147
partialize: (state) => pick(state, ['instruments', 'selectedInstrument', 'settings']),
3248
storage: createJSONStorage(() => localStorage),

apps/playground/src/utils/file.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import { match, P } from 'ts-pattern';
44

55
import type { EditorFile } from '@/models/editor-file.model';
66

7-
export type FileType = 'asset' | 'css' | 'html' | 'javascript' | 'typescript';
7+
export type FileType = 'asset' | 'css' | 'html' | 'javascript' | 'json' | 'typescript';
88

99
export function inferFileType(filename: string): FileType | null {
1010
return match(extractInputFileExtension(filename))
1111
.with('.css', () => 'css' as const)
1212
.with(P.union('.js', '.jsx'), () => 'javascript' as const)
1313
.with(P.union('.ts', '.tsx'), () => 'typescript' as const)
14-
.with(P.union('.jpeg', '.jpg', '.png', '.webp'), () => 'asset' as const)
14+
.with(P.union('.jpeg', '.jpg', '.png', '.webp', '.mp4'), () => 'asset' as const)
1515
.with(P.union('.html', '.svg'), () => 'html' as const)
16+
.with('.json', () => 'json' as const)
1617
.with(null, () => null)
1718
.exhaustive();
1819
}
@@ -30,7 +31,7 @@ export function editorFileToInput(file: EditorFile): BundlerInput {
3031
}
3132

3233
export function isBase64EncodedFileType(filename: string) {
33-
return ['.jpeg', '.jpg', '.png', '.webp'].includes(extractInputFileExtension(filename)!);
34+
return ['.jpeg', '.jpg', '.mp4', '.png', '.webp'].includes(extractInputFileExtension(filename)!);
3435
}
3536

3637
export function resolveIndexFile(files: EditorFile[]) {

0 commit comments

Comments
 (0)