@adriansteffan/reactive 0.0.24 → 0.0.27

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 (36) hide show
  1. package/dist/{mod-DFT88PVZ.js → mod-CbGhKi2f.js} +11270 -9987
  2. package/dist/mod.d.ts +188 -48
  3. package/dist/reactive.es.js +26 -15
  4. package/dist/reactive.umd.js +37 -39
  5. package/dist/style.css +1 -1
  6. package/dist/{web-BkxeXT_o.js → web-BFGLx41c.js} +1 -1
  7. package/dist/{web-DLRbsabT.js → web-DOFokKz7.js} +1 -1
  8. package/package.json +7 -2
  9. package/src/components/canvasblock.tsx +519 -0
  10. package/src/components/checkdevice.tsx +158 -0
  11. package/src/components/enterfullscreen.tsx +114 -31
  12. package/src/components/exitfullscreen.tsx +98 -21
  13. package/src/components/experimentprovider.tsx +34 -20
  14. package/src/components/experimentrunner.tsx +387 -0
  15. package/src/components/index.ts +13 -0
  16. package/src/components/mobilefilepermission.tsx +63 -0
  17. package/src/components/plaininput.tsx +7 -8
  18. package/src/components/prolificending.tsx +10 -4
  19. package/src/components/quest.tsx +27 -31
  20. package/src/components/settingsscreen.tsx +770 -0
  21. package/src/components/text.tsx +48 -3
  22. package/src/components/upload.tsx +218 -47
  23. package/src/mod.tsx +3 -11
  24. package/src/types/array.d.ts +6 -0
  25. package/src/utils/array.ts +79 -0
  26. package/src/utils/bytecode.ts +178 -0
  27. package/src/utils/common.ts +170 -39
  28. package/template/.env.template +2 -1
  29. package/template/README.md +20 -5
  30. package/template/package.json +1 -0
  31. package/template/shared.vite.config.js +18 -0
  32. package/template/src/{App.tsx → Experiment.tsx} +4 -4
  33. package/template/src/main.tsx +4 -4
  34. package/template/tsconfig.json +4 -2
  35. package/tsconfig.json +1 -0
  36. package/src/components/experiment.tsx +0 -369
@@ -0,0 +1,770 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ type CustomParam = { name: string; type: string; value: any };
4
+ type TimelineRepItem = { type: string; name?: string };
5
+
6
+ export const SettingsScreen = ({
7
+ paramRegistry,
8
+ timelineRepresentation,
9
+ }: {
10
+ paramRegistry: any[];
11
+ timelineRepresentation: TimelineRepItem[];
12
+ }) => {
13
+ const [paramValues, setParamValues] = useState<Record<string, any>>({});
14
+ const [customParams, setCustomParams] = useState<CustomParam[]>([]);
15
+ const [generatedUrl, setGeneratedUrl] = useState('');
16
+ const [copyButtonText, setCopyButtonText] = useState('Copy URL');
17
+ const [useBase64, setUseBase64] = useState(false);
18
+ const [isExcludeMode, setIsExcludeMode] = useState(false);
19
+ const [selectedTrials, setSelectedTrials] = useState<string[]>([]);
20
+ const urlInputRef = useRef<HTMLInputElement>(null);
21
+
22
+ const sortedParams = [...paramRegistry]
23
+ .filter((param) => param.name !== 'includeSubset' && param.name !== 'excludeSubset')
24
+ .sort((a, b) => a.name.localeCompare(b.name));
25
+
26
+ // Initialize param values from URL if present
27
+ useEffect(() => {
28
+ const searchParams = new URLSearchParams(window.location.search);
29
+ const initialValues: Record<string, any> = {};
30
+ const extractedCustomParams: CustomParam[] = [];
31
+
32
+ let initialSelectedTrials: string[] = [];
33
+ let initialIsExcludeMode = false;
34
+
35
+ const includeSubset = searchParams.get('includeSubset');
36
+ const excludeSubset = searchParams.get('excludeSubset');
37
+
38
+ const urlEncodedJson = searchParams.get('_b');
39
+ if (urlEncodedJson) {
40
+ try {
41
+ const jsonString = atob(urlEncodedJson);
42
+ const decodedParams = JSON.parse(jsonString);
43
+
44
+ if (decodedParams.excludeSubset) {
45
+ initialIsExcludeMode = true;
46
+ initialSelectedTrials = decodedParams.excludeSubset.split(',');
47
+ } else if (decodedParams.includeSubset) {
48
+ initialIsExcludeMode = false;
49
+ initialSelectedTrials = decodedParams.includeSubset.split(',');
50
+ }
51
+ } catch {
52
+ // Handle fallback to regular params below
53
+
54
+ if (excludeSubset) {
55
+ initialIsExcludeMode = true;
56
+ initialSelectedTrials = excludeSubset.split(',');
57
+ } else if (includeSubset) {
58
+ initialIsExcludeMode = false;
59
+ initialSelectedTrials = includeSubset.split(',');
60
+ }
61
+ }
62
+ } else {
63
+ if (excludeSubset) {
64
+ initialIsExcludeMode = true;
65
+ initialSelectedTrials = excludeSubset.split(',');
66
+ } else if (includeSubset) {
67
+ initialIsExcludeMode = false;
68
+ initialSelectedTrials = includeSubset.split(',');
69
+ }
70
+ }
71
+
72
+ setIsExcludeMode(initialIsExcludeMode);
73
+ if (excludeSubset || includeSubset) {
74
+ setSelectedTrials(initialSelectedTrials);
75
+ } else if (timelineRepresentation.length > 0) {
76
+ if (initialIsExcludeMode) {
77
+ setSelectedTrials([]);
78
+ } else {
79
+ setSelectedTrials(
80
+ Array.from({ length: timelineRepresentation.length }, (_, index) =>
81
+ (index + 1).toString(),
82
+ ),
83
+ );
84
+ }
85
+ }
86
+
87
+ const encodedJson = searchParams.get('_b');
88
+ if (encodedJson) {
89
+ try {
90
+ const jsonString = atob(encodedJson);
91
+ const decodedParams = JSON.parse(jsonString);
92
+
93
+ const registeredParamNames = paramRegistry.map((param) => param.name);
94
+
95
+ // Separate registered and custom params
96
+ Object.entries(decodedParams).forEach(([key, value]) => {
97
+ if (registeredParamNames.includes(key)) {
98
+ initialValues[key] = String(value);
99
+ } else if (key !== '_b') {
100
+ // This is a custom param
101
+ extractedCustomParams.push({
102
+ name: key,
103
+ value: typeof value === 'object' ? JSON.stringify(value) : String(value),
104
+ type:
105
+ typeof value === 'boolean'
106
+ ? 'boolean'
107
+ : typeof value === 'number'
108
+ ? 'number'
109
+ : Array.isArray(value)
110
+ ? 'array'
111
+ : typeof value === 'object'
112
+ ? 'json'
113
+ : 'string',
114
+ });
115
+ }
116
+ });
117
+
118
+ // Set defaults for registered params that weren't in the URL
119
+ paramRegistry.forEach((param) => {
120
+ if (!(param.name in initialValues)) {
121
+ if (param.type === 'boolean' && param.defaultValue !== undefined) {
122
+ initialValues[param.name] = String(param.defaultValue);
123
+ } else {
124
+ initialValues[param.name] = '';
125
+ }
126
+ }
127
+ });
128
+
129
+ // Since we found base64 params, set the toggle to true
130
+ setUseBase64(true);
131
+ } catch {
132
+ processRegularParams(searchParams);
133
+ }
134
+ } else {
135
+ processRegularParams(searchParams);
136
+ }
137
+
138
+ function processRegularParams(searchParams: URLSearchParams) {
139
+ // Process registered params
140
+
141
+ paramRegistry.forEach((param) => {
142
+ const value = searchParams.get(param.name);
143
+ if (value !== null) {
144
+ initialValues[param.name] = value;
145
+ } else if (param.type === 'boolean' && param.defaultValue !== undefined) {
146
+ // For boolean params with defaults, explicitly set them
147
+ initialValues[param.name] = String(param.defaultValue);
148
+ }
149
+ });
150
+
151
+ // Extract custom params (any param not in the registry)
152
+ const registeredParamNames = paramRegistry.map((param) => param.name);
153
+ searchParams.forEach((value: string, key: string) => {
154
+ if (!registeredParamNames.includes(key) && key !== '_b') {
155
+ let paramType = 'string';
156
+
157
+ // Try to determine the type
158
+ if (value === 'true' || value === 'false') {
159
+ paramType = 'boolean';
160
+ } else if (!isNaN(Number(value))) {
161
+ paramType = 'number';
162
+ } else {
163
+ try {
164
+ const parsed = JSON.parse(value);
165
+ paramType = Array.isArray(parsed) ? 'array' : 'json';
166
+ } catch {
167
+ // It's a string if we can't parse it as JSON
168
+ }
169
+ }
170
+
171
+ extractedCustomParams.push({
172
+ name: key,
173
+ value: value,
174
+ type: paramType,
175
+ });
176
+ }
177
+ });
178
+ }
179
+
180
+ setParamValues(initialValues);
181
+ setCustomParams(extractedCustomParams);
182
+ }, [paramRegistry]);
183
+
184
+ // Generate URL whenever param values, custom params, trials selection, or encoding type changes
185
+ useEffect(() => {
186
+ const baseUrl = window.location.pathname.replace(/\/settings$/, '');
187
+ let queryString = '';
188
+
189
+ const allParams: Record<string, any> = { ...paramValues };
190
+
191
+ if (selectedTrials.length > 0) {
192
+ if (isExcludeMode) {
193
+ allParams['excludeSubset'] = selectedTrials.sort().join(',');
194
+ delete allParams['includeSubset'];
195
+ } else if (selectedTrials.length != timelineRepresentation.length) {
196
+ allParams['includeSubset'] = selectedTrials.sort().join(',');
197
+ delete allParams['excludeSubset'];
198
+ }
199
+ } else {
200
+ delete allParams['includeSubset'];
201
+ delete allParams['excludeSubset'];
202
+ }
203
+
204
+ customParams.forEach((param) => {
205
+ if (
206
+ param.name &&
207
+ param.value !== '' &&
208
+ param.name !== 'includeSubset' &&
209
+ param.name !== 'excludeSubset'
210
+ ) {
211
+ allParams[param.name] = param.value;
212
+ }
213
+ });
214
+
215
+ if (useBase64) {
216
+ const paramsObj: Record<string, any> = {};
217
+ Object.entries(allParams).forEach(([key, value]) => {
218
+ if (value !== '') {
219
+ const param = paramRegistry.find((p) => p.name === key);
220
+ const customParam = customParams.find((p) => p.name === key);
221
+ const paramType = param?.type || customParam?.type || 'string';
222
+
223
+ if (
224
+ paramType === 'boolean' &&
225
+ param?.defaultValue !== undefined &&
226
+ String(param.defaultValue) === value
227
+ ) {
228
+ return;
229
+ }
230
+
231
+ switch (paramType) {
232
+ case 'number':
233
+ paramsObj[key] = Number(value);
234
+ break;
235
+ case 'boolean':
236
+ paramsObj[key] = value === 'true';
237
+ break;
238
+ case 'array':
239
+ case 'json':
240
+ try {
241
+ paramsObj[key] = JSON.parse(value as string);
242
+ } catch {
243
+ paramsObj[key] = value;
244
+ }
245
+ break;
246
+ default:
247
+ paramsObj[key] = value;
248
+ }
249
+ }
250
+ });
251
+
252
+ // Create base64 encoded parameter
253
+ const jsonString = JSON.stringify(paramsObj);
254
+ const encodedParams = btoa(jsonString);
255
+ queryString = `?_b=${encodedParams}`;
256
+ } else {
257
+ const searchParams = new URLSearchParams();
258
+
259
+ Object.entries(allParams).forEach(([key, value]) => {
260
+ if (value !== '') {
261
+ const param = paramRegistry.find((p) => p.name === key);
262
+ // Skip boolean params that match their default value
263
+ if (
264
+ param?.type === 'boolean' &&
265
+ param.defaultValue !== undefined &&
266
+ String(param.defaultValue) === value
267
+ ) {
268
+ return;
269
+ }
270
+ searchParams.set(key, String(value));
271
+ }
272
+ });
273
+
274
+ queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
275
+ }
276
+
277
+ const fullUrl = `${window.location.origin}${baseUrl}${queryString}`;
278
+ setGeneratedUrl(fullUrl);
279
+ }, [paramValues, customParams, useBase64, paramRegistry, selectedTrials, isExcludeMode]);
280
+
281
+ const handleParamChange = (name: string, value: any) => {
282
+ setParamValues((prev) => ({
283
+ ...prev,
284
+ [name]: value,
285
+ }));
286
+ };
287
+
288
+ const handleCustomParamChange = (index: number, field: string, value: any) => {
289
+ setCustomParams((prev) => {
290
+ const updated = [...prev];
291
+ updated[index] = {
292
+ ...updated[index],
293
+ [field]: value,
294
+ };
295
+ return updated;
296
+ });
297
+ };
298
+
299
+ const toggleTrialSelection = (index: number) => {
300
+ const trialIdentifier = `${index + 1}`;
301
+
302
+ setSelectedTrials((prev) => {
303
+ const newSelection = prev.includes(trialIdentifier)
304
+ ? prev.filter((id) => id !== trialIdentifier)
305
+ : [...prev, trialIdentifier];
306
+
307
+ return newSelection;
308
+ });
309
+ };
310
+
311
+ const toggleSelectionMode = () => {
312
+ setIsExcludeMode((prev) => !prev);
313
+ };
314
+
315
+ const addCustomParam = () => {
316
+ setCustomParams((prev) => [...prev, { name: '', value: '', type: 'string' }]);
317
+ };
318
+
319
+ // Filter out custom params that match includeSubset or excludeSubset
320
+ const filteredCustomParams = customParams.filter(
321
+ (param) => param.name !== 'includeSubset' && param.name !== 'excludeSubset',
322
+ );
323
+
324
+ const removeCustomParam = (index: number) => {
325
+ setCustomParams((prev) => prev.filter((_, i) => i !== index));
326
+ };
327
+
328
+ const handleCopyUrl = () => {
329
+ if (urlInputRef.current) {
330
+ urlInputRef.current.select();
331
+ document.execCommand('copy');
332
+ setCopyButtonText('Copied!');
333
+ setTimeout(() => setCopyButtonText('Copy URL'), 2000);
334
+ }
335
+ };
336
+
337
+ const toggleUrlFormat = () => {
338
+ setUseBase64(!useBase64);
339
+ };
340
+
341
+ const renderParamInput = (param: {
342
+ name: string;
343
+ type: string | undefined;
344
+ defaultValue: any;
345
+ }) => {
346
+ const { name, type, defaultValue } = param;
347
+
348
+ // Skip rendering for includeSubset and excludeSubset as they're handled separately
349
+ if (name === 'includeSubset' || name === 'excludeSubset') {
350
+ return null;
351
+ }
352
+
353
+ const value = paramValues[name] || '';
354
+
355
+ switch (type) {
356
+ case 'boolean':
357
+ return (
358
+ <>
359
+ <button
360
+ onClick={() => handleParamChange(name, value === 'true' ? 'false' : 'true')}
361
+ className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${value === 'true' ? 'bg-blue-600' : 'bg-gray-200'}`}
362
+ role='switch'
363
+ aria-checked={value === 'true'}
364
+ >
365
+ <span
366
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${value === 'true' ? 'translate-x-5' : 'translate-x-0'}`}
367
+ />
368
+ </button>
369
+ <span className='ml-4 text-gray-500'>{value === 'true' ? 'True' : 'False'}</span>
370
+ </>
371
+ );
372
+
373
+ case 'number':
374
+ return (
375
+ <input
376
+ type='number'
377
+ className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
378
+ value={value}
379
+ onChange={(e) => handleParamChange(name, e.target.value)}
380
+ placeholder={
381
+ defaultValue !== undefined ? `Default: ${defaultValue}` : 'Enter number...'
382
+ }
383
+ />
384
+ );
385
+
386
+ case 'array':
387
+ case 'json':
388
+ return (
389
+ <textarea
390
+ className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
391
+ value={value}
392
+ onChange={(e) => handleParamChange(name, e.target.value)}
393
+ placeholder={
394
+ type === 'array' ? 'Enter comma-separated values or JSON array' : 'Enter JSON object'
395
+ }
396
+ rows={3}
397
+ />
398
+ );
399
+
400
+ case 'string':
401
+ default:
402
+ return (
403
+ <input
404
+ type='text'
405
+ className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
406
+ value={value}
407
+ onChange={(e) => handleParamChange(name, e.target.value)}
408
+ placeholder={
409
+ defaultValue !== undefined && defaultValue !== ''
410
+ ? `Default: ${defaultValue}`
411
+ : 'Enter value...'
412
+ }
413
+ />
414
+ );
415
+ }
416
+ };
417
+
418
+ const renderCustomParamInput = (
419
+ param: { name: string; type: string | undefined; value: any },
420
+ index: number,
421
+ ) => {
422
+ // Skip rendering for includeSubset and excludeSubset as they're handled separately
423
+ if (param.name === 'includeSubset' || param.name === 'excludeSubset') {
424
+ return null;
425
+ }
426
+
427
+ return (
428
+ <div className='grid grid-cols-12 gap-2 items-center mb-2'>
429
+ <div className='col-span-3'>
430
+ <input
431
+ type='text'
432
+ className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
433
+ value={param.name}
434
+ onChange={(e) => handleCustomParamChange(index, 'name', e.target.value)}
435
+ placeholder='Parameter name'
436
+ />
437
+ </div>
438
+
439
+ <div className='col-span-2'>
440
+ <select
441
+ className='block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md'
442
+ value={param.type}
443
+ onChange={(e) => handleCustomParamChange(index, 'type', e.target.value)}
444
+ >
445
+ <option value='string'>String</option>
446
+ <option value='number'>Number</option>
447
+ <option value='boolean'>Boolean</option>
448
+ <option value='array'>Array</option>
449
+ <option value='json'>JSON</option>
450
+ </select>
451
+ </div>
452
+
453
+ <div className='col-span-6'>
454
+ {param.type === 'boolean' ? (
455
+ <>
456
+ <button
457
+ onClick={() =>
458
+ handleCustomParamChange(index, 'value', param.value === 'true' ? 'false' : 'true')
459
+ }
460
+ className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${param.value === 'true' ? 'bg-blue-600' : 'bg-gray-200'}`}
461
+ role='switch'
462
+ aria-checked={param.value === 'true'}
463
+ >
464
+ <span
465
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${param.value === 'true' ? 'translate-x-5' : 'translate-x-0'}`}
466
+ />
467
+ </button>
468
+ </>
469
+ ) : param.type === 'array' || param.type === 'json' ? (
470
+ <textarea
471
+ className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
472
+ value={param.value}
473
+ onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
474
+ placeholder={
475
+ param.type === 'array'
476
+ ? 'Enter JSON array [1,2,3]'
477
+ : 'Enter JSON object {"key":"value"}'
478
+ }
479
+ rows={1}
480
+ />
481
+ ) : param.type === 'number' ? (
482
+ <input
483
+ type='number'
484
+ className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
485
+ value={param.value}
486
+ onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
487
+ placeholder='Enter number...'
488
+ />
489
+ ) : (
490
+ <input
491
+ type='text'
492
+ className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
493
+ value={param.value}
494
+ onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
495
+ placeholder='Enter value...'
496
+ />
497
+ )}
498
+ </div>
499
+
500
+ <div className='col-span-1'>
501
+ <button
502
+ onClick={() => removeCustomParam(index)}
503
+ className='inline-flex cursor-pointer items-center justify-center p-2 border border-transparent rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
504
+ aria-label='Remove parameter'
505
+ >
506
+ <svg
507
+ xmlns='http://www.w3.org/2000/svg'
508
+ className='h-5 w-5'
509
+ viewBox='0 0 20 20'
510
+ fill='currentColor'
511
+ >
512
+ <path
513
+ fillRule='evenodd'
514
+ d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
515
+ clipRule='evenodd'
516
+ />
517
+ </svg>
518
+ </button>
519
+ </div>
520
+ </div>
521
+ );
522
+ };
523
+
524
+ return (
525
+ <div className='max-w-4xl mx-auto p-6'>
526
+ <h1 className='text-3xl font-bold mb-4'>Experiment Parameters</h1>
527
+ <p className='text-gray-700 mb-6'>
528
+ Configure the experiment by setting parameter values below. Fields left empty will be
529
+ assigned their default value. Once configured, you can copy the URL to run the experiment
530
+ with these settings.
531
+ </p>
532
+
533
+ <div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
534
+ <div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
535
+ <h2 className='text-lg font-medium text-gray-900'>Registered Parameters</h2>
536
+ <p className='mt-1 text-sm text-gray-500'>
537
+ Parameters defined in the experiment configuration.
538
+ </p>
539
+ </div>
540
+ <ul className='divide-y divide-gray-200'>
541
+ {sortedParams.map((param) => (
542
+ <li key={param.name} className='px-4 py-4 sm:px-6'>
543
+ <div className='grid grid-cols-3 gap-6'>
544
+ <div className='col-span-2'>
545
+ <div className='flex flex-col h-full'>
546
+ <div className='flex items-center'>
547
+ <label className='text-base font-medium text-gray-900'>{param.name}</label>
548
+ <span className='ml-2 inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600'>
549
+ {param.type}
550
+ </span>
551
+ </div>
552
+ {param.description && (
553
+ <p className='mt-1 text-sm text-gray-500 flex-grow'>{param.description}</p>
554
+ )}
555
+ {param.defaultValue !== undefined && (
556
+ <p className='mt-auto pt-2 text-xs text-gray-500'>
557
+ Default: <code> {JSON.stringify(param.defaultValue)}</code>
558
+ </p>
559
+ )}
560
+ </div>
561
+ </div>
562
+ <div className='col-span-1 flex items-center'>{renderParamInput(param)}</div>
563
+ </div>
564
+ </li>
565
+ ))}
566
+ </ul>
567
+ </div>
568
+
569
+ <div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
570
+ <div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
571
+ <div className='flex justify-between items-center'>
572
+ <div>
573
+ <h2 className='text-lg font-medium text-gray-900'>Custom Parameters</h2>
574
+ <p className='mt-1 text-sm text-gray-500'>
575
+ Add any additional parameters to be stored in the data for this run.
576
+ </p>
577
+ </div>
578
+ <button
579
+ onClick={addCustomParam}
580
+ className='inline-flex cursor-pointer items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
581
+ >
582
+ <svg
583
+ xmlns='http://www.w3.org/2000/svg'
584
+ className='-ml-1 mr-2 h-5 w-5'
585
+ viewBox='0 0 20 20'
586
+ fill='currentColor'
587
+ >
588
+ <path
589
+ fillRule='evenodd'
590
+ d='M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z'
591
+ clipRule='evenodd'
592
+ />
593
+ </svg>
594
+ Add Parameter
595
+ </button>
596
+ </div>
597
+ </div>
598
+
599
+ <div className='px-4 py-5 sm:px-6'>
600
+ {filteredCustomParams.length === 0 ? (
601
+ <p className='text-sm text-gray-500 italic'>
602
+ No custom parameters added. Click "Add Parameter" to create one.
603
+ </p>
604
+ ) : (
605
+ <div>
606
+ <div className='grid grid-cols-12 gap-2 mb-2'>
607
+ <div className='col-span-3'>
608
+ <label className='block text-xs font-medium text-gray-500'>PARAMETER NAME</label>
609
+ </div>
610
+ <div className='col-span-2'>
611
+ <label className='block text-xs font-medium text-gray-500'>TYPE</label>
612
+ </div>
613
+ <div className='col-span-6'>
614
+ <label className='block text-xs font-medium text-gray-500'>VALUE</label>
615
+ </div>
616
+ <div className='col-span-1'></div>
617
+ </div>
618
+ {filteredCustomParams.map((param, index) => (
619
+ <div key={index}>{renderCustomParamInput(param, index)}</div>
620
+ ))}
621
+ </div>
622
+ )}
623
+ </div>
624
+ </div>
625
+
626
+ {timelineRepresentation.length > 0 && (
627
+ <div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
628
+ <div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
629
+ <div className='flex justify-between items-center'>
630
+ <div>
631
+ <h2 className='text-lg font-medium text-gray-900'>Trial Selection</h2>
632
+ <p className='mt-1 text-sm text-gray-500'>
633
+ Select which trials to include or exclude from the experiment.
634
+ </p>
635
+ </div>
636
+ <div className='flex items-center'>
637
+ <button
638
+ onClick={toggleSelectionMode}
639
+ className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${isExcludeMode ? 'bg-red-600' : 'bg-green-600'}`}
640
+ role='switch'
641
+ aria-checked={isExcludeMode}
642
+ >
643
+ <span
644
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${isExcludeMode ? 'translate-x-5' : 'translate-x-0'}`}
645
+ />
646
+ </button>
647
+ <span className='ml-2 text-sm text-gray-600'>
648
+ {isExcludeMode ? 'Exclude mode' : 'Include mode'}
649
+ </span>
650
+ </div>
651
+ </div>
652
+ </div>
653
+
654
+ <div className='px-4 py-5 sm:px-6'>
655
+ <p className='mb-4 text-sm font-medium text-gray-500'>
656
+ {isExcludeMode
657
+ ? 'Check trials you want to EXCLUDE from the experiment'
658
+ : 'Check trials you want to INCLUDE in the experiment'}
659
+ </p>
660
+
661
+ <div className='border border-gray-200 rounded-md overflow-hidden'>
662
+ <ul className='divide-y divide-gray-200'>
663
+ {timelineRepresentation.map(
664
+ (trial: { name?: string; type: string }, index: number) => (
665
+ <li
666
+ key={index}
667
+ className='cursor-pointer px-4 py-3 hover:bg-gray-50'
668
+ onClick={(e) => {
669
+ // Check if the click was on or within the checkbox or label
670
+ if (
671
+ (e.target as any).type !== 'checkbox' &&
672
+ !(e.target as any).closest('label')
673
+ ) {
674
+ toggleTrialSelection(index);
675
+ }
676
+ }}
677
+ >
678
+ <div className='flex items-center'>
679
+ <div className='flex items-center flex-grow'>
680
+ <input
681
+ id={`trial-${index}`}
682
+ type='checkbox'
683
+ className='h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded cursor-pointer'
684
+ checked={selectedTrials.includes(`${index + 1}`)}
685
+ onChange={() => toggleTrialSelection(index)}
686
+ />
687
+ <label
688
+ htmlFor={`trial-${index}`}
689
+ className='ml-3 flex-grow flex items-center cursor-pointer'
690
+ >
691
+ <div className='w-8 text-center text-xs text-gray-500 font-medium'>
692
+ #{index + 1}
693
+ </div>
694
+ <div className='flex-grow'>
695
+ {trial.name ? (
696
+ <span className='font-medium text-gray-900'>{trial.name}</span>
697
+ ) : (
698
+ <span className='font-medium text-gray-500'>{trial.type}</span>
699
+ )}
700
+ </div>
701
+ <div className='ml-2'>
702
+ <span className='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800'>
703
+ {trial.type}
704
+ </span>
705
+ </div>
706
+ </label>
707
+ </div>
708
+ </div>
709
+ </li>
710
+ ),
711
+ )}
712
+ </ul>
713
+ </div>
714
+ </div>
715
+ </div>
716
+ )}
717
+
718
+ <div className='bg-white shadow sm:rounded-md p-6 mb-8'>
719
+ <div className='flex justify-between items-center mb-4'>
720
+ <h2 className='text-lg font-medium'>Generated URL</h2>
721
+ <div className='flex items-center'>
722
+ <button
723
+ onClick={toggleUrlFormat}
724
+ className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${useBase64 ? 'bg-blue-600' : 'bg-gray-200'}`}
725
+ role='switch'
726
+ aria-checked={useBase64}
727
+ >
728
+ <span
729
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${useBase64 ? 'translate-x-5' : 'translate-x-0'}`}
730
+ />
731
+ </button>
732
+ <span className='ml-2 text-sm text-gray-600'>
733
+ {useBase64 ? 'Base64 Encoded' : 'Plain Parameters'}
734
+ </span>
735
+ </div>
736
+ </div>
737
+ <div className='flex flex-col md:flex-row gap-3'>
738
+ <input
739
+ ref={urlInputRef}
740
+ type='text'
741
+ className='flex-grow px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
742
+ value={generatedUrl}
743
+ readOnly
744
+ />
745
+ <button
746
+ onClick={handleCopyUrl}
747
+ className='inline-flex cursor-pointer justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
748
+ >
749
+ {copyButtonText}
750
+ </button>
751
+ </div>
752
+ {useBase64 && (
753
+ <p className='mt-4 text-sm text-gray-600'>
754
+ <span className='font-medium'>Note:</span> Base64 encoding combines all parameters into
755
+ a single "_b" parameter, creating shorter URLs that hide parameter details.
756
+ </p>
757
+ )}
758
+ </div>
759
+
760
+ <div className='flex justify-center'>
761
+ <a
762
+ href={generatedUrl}
763
+ className='inline-flex justify-center py-4 px-5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
764
+ >
765
+ Launch Experiment with these Settings
766
+ </a>
767
+ </div>
768
+ </div>
769
+ );
770
+ };