multiplayer distinction
This commit is contained in:
@@ -29,17 +29,22 @@ export const experiment = pgTable('experiment', {
|
||||
type: experimentTypeEnum('type').notNull()
|
||||
});
|
||||
|
||||
export const participantSession = pgTable('participant_session', {
|
||||
export const sessionStatusEnum = pgEnum('session_status', ['in progress', 'completed', 'aborted']);
|
||||
|
||||
export const experimentSession = pgTable('experiment_session', {
|
||||
id: text('id').primaryKey(),
|
||||
experimentId: text('experiment_id')
|
||||
.notNull()
|
||||
.references(() => experiment.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
status: sessionStatusEnum('status').notNull(),
|
||||
externalId: text('external_id'),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent')
|
||||
});
|
||||
|
||||
export type Session = typeof session.$inferSelect;
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type ParticipantSession = typeof participantSession.$inferSelect;
|
||||
export type Experiment = typeof experiment.$inferSelect;
|
||||
export type ExperimentSession = typeof experimentSession.$inferSelect;
|
||||
|
||||
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal file
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { experimentSession } from '$lib/server/db/schema';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { and, eq, desc, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().default(10)
|
||||
});
|
||||
|
||||
export async function GET({ params, url }) {
|
||||
const { id } = params;
|
||||
const searchParams = searchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
try {
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(experimentSession)
|
||||
.where(eq(experimentSession.experimentId, id))
|
||||
.orderBy(desc(experimentSession.createdAt))
|
||||
.limit(searchParams.limit)
|
||||
.offset((searchParams.page - 1) * searchParams.limit);
|
||||
|
||||
const total = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(experimentSession)
|
||||
.where(eq(experimentSession.experimentId, id));
|
||||
|
||||
return json({
|
||||
sessions,
|
||||
total: total[0].count,
|
||||
page: searchParams.page,
|
||||
limit: searchParams.limit
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return json({ error: 'Something went wrong' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { goto } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
import FileManager from './FileManager.svelte';
|
||||
import ExperimentSessions from './ExperimentSessions.svelte';
|
||||
|
||||
let experimentId = '';
|
||||
let experiment: any = null;
|
||||
@@ -15,192 +16,49 @@ let name = '';
|
||||
let description = '';
|
||||
let error = '';
|
||||
let success = '';
|
||||
let files: any[] = [];
|
||||
let uploading = false;
|
||||
let uploadError = '';
|
||||
let copied = false;
|
||||
let origin = '';
|
||||
let activeTab = 'info';
|
||||
|
||||
async function fetchFiles() {
|
||||
if (!experimentId) return;
|
||||
console.log('fetchFiles called for experimentId:', experimentId);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('fetchFiles response:', data);
|
||||
files = data.files;
|
||||
} else {
|
||||
console.log('fetchFiles failed:', res.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) await uploadFiles(input.files);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList: FileList) {
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
// Use webkitRelativePath if present, else fallback to file.name
|
||||
form.append('relativePath', file.webkitRelativePath || file.name);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
}
|
||||
await fetchFiles();
|
||||
} catch (e: any) {
|
||||
uploadError = e.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(key: string) {
|
||||
// If key contains experiment_files/, strip everything before and including it
|
||||
let relativeKey = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
// fallback: if key contains experimentId, strip up to and including it
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativeKey = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
}
|
||||
|
||||
// Helper: Convert flat file list to tree
|
||||
function buildFileTree(files: any[]): any {
|
||||
const root: any = {};
|
||||
for (const file of files) {
|
||||
const parts = file.name.split('/');
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
if (i === parts.length - 1) {
|
||||
// File
|
||||
node[part] = { ...file, isFile: true };
|
||||
} else {
|
||||
// Folder
|
||||
node[part] = node[part] || { children: {}, isFile: false };
|
||||
node = node[part].children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function downloadFile(key: string) {
|
||||
// Download file from S3 via API
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`;
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to download');
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
let filename = key.split('/').pop() || 'file';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
return res.blob().then(blob => ({ blob, filename }));
|
||||
})
|
||||
.then(({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}, 100);
|
||||
})
|
||||
.catch(() => alert('Failed to download file.'));
|
||||
}
|
||||
|
||||
let expanded = new Set<string>();
|
||||
function toggleFolder(path: string) {
|
||||
if (expanded.has(path)) expanded.delete(path);
|
||||
else expanded.add(path);
|
||||
expanded = new Set(expanded); // trigger reactivity
|
||||
}
|
||||
$: publicLink = experiment
|
||||
? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}`
|
||||
: '';
|
||||
|
||||
function copyLink() {
|
||||
const link = `${origin}/public/run/${experiment.id}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
async function deleteFileOrFolder(key: string, isFolder: boolean) {
|
||||
console.log('deleteFileOrFolder called:', { key, isFolder, experimentId });
|
||||
if (isFolder) {
|
||||
// For folders, use the same logic to get the relative prefix
|
||||
let relativePrefix = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativePrefix = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(relativePrefix)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
} else {
|
||||
await deleteFile(key);
|
||||
}
|
||||
navigator.clipboard.writeText(publicLink);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
origin = window.location.origin;
|
||||
const params = get(page).params;
|
||||
experimentId = params.id;
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
experiment = data.experiment;
|
||||
console.log(experiment)
|
||||
name = experiment.name;
|
||||
description = experiment.description;
|
||||
} else {
|
||||
error = 'Failed to load experiment.';
|
||||
}
|
||||
await fetchFiles();
|
||||
origin = window.location.origin;
|
||||
const params = get(page).params;
|
||||
experimentId = params.id;
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
experiment = data.experiment;
|
||||
console.log(experiment)
|
||||
name = experiment.name;
|
||||
description = experiment.description;
|
||||
} else {
|
||||
error = 'Failed to load experiment.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
error = '';
|
||||
success = '';
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
if (res.ok) {
|
||||
success = 'Experiment updated!';
|
||||
} else {
|
||||
error = 'Failed to update experiment.';
|
||||
}
|
||||
error = '';
|
||||
success = '';
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
if (res.ok) {
|
||||
success = 'Experiment updated!';
|
||||
} else {
|
||||
error = 'Failed to update experiment.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -210,6 +68,7 @@ async function handleSave() {
|
||||
<div class="flex border-b mb-4">
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'info'} class:border-transparent={activeTab !== 'info'} on:click={() => activeTab = 'info'}>Info</button>
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'files'} class:border-transparent={activeTab !== 'files'} on:click={() => activeTab = 'files'}>Files</button>
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'sessions'} class:border-transparent={activeTab !== 'sessions'} on:click={() => activeTab = 'sessions'}>Sessions</button>
|
||||
</div>
|
||||
{#if activeTab === 'info'}
|
||||
<Card class="w-full">
|
||||
@@ -253,7 +112,7 @@ async function handleSave() {
|
||||
<div class="mt-8">
|
||||
<Label>Public Link</Label>
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<Input id="public-link" type="text" readonly value={`${origin}/public/run/${experiment.id}`} />
|
||||
<Input id="public-link" type="text" readonly value={publicLink} />
|
||||
<Button on:click={copyLink}>Copy</Button>
|
||||
</div>
|
||||
{#if copied}
|
||||
@@ -262,47 +121,10 @@ async function handleSave() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
{#if activeTab === 'files'}
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mt-8">
|
||||
<div class="flex justify-end mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
|
||||
on:click={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
Upload Files or Folder
|
||||
</button>
|
||||
<input id="file-input" type="file" multiple webkitdirectory class="hidden" on:change={handleFileInput} />
|
||||
</div>
|
||||
{#if uploading}
|
||||
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
|
||||
{/if}
|
||||
<ul class="divide-y divide-border">
|
||||
<FileBrowser
|
||||
tree={buildFileTree(files)}
|
||||
parentPath=""
|
||||
{expanded}
|
||||
onToggle={toggleFolder}
|
||||
onDelete={deleteFileOrFolder}
|
||||
onDownload={downloadFile}
|
||||
isRoot={true}
|
||||
/>
|
||||
{#if files.length === 0}
|
||||
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if activeTab === 'files'}
|
||||
<FileManager {experimentId} />
|
||||
{:else if activeTab === 'sessions'}
|
||||
<ExperimentSessions {experimentId} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal file
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table';
|
||||
|
||||
export let experimentId: string;
|
||||
|
||||
let sessions: any[] = [];
|
||||
let page = 1;
|
||||
let limit = 10;
|
||||
let total = 0;
|
||||
let loading = false;
|
||||
|
||||
async function fetchSessions() {
|
||||
loading = true;
|
||||
const res = await fetch(
|
||||
`/api/experiment/${experimentId}/sessions?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
sessions = data.sessions;
|
||||
total = data.total;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page * limit < total) {
|
||||
page++;
|
||||
fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page > 1) {
|
||||
page--;
|
||||
fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (experimentId) {
|
||||
fetchSessions();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Updated At</TableHead>
|
||||
<TableHead>External ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if loading}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-center">Loading...</TableCell>
|
||||
</TableRow>
|
||||
{:else if sessions.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-center">No sessions found.</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
{#each sessions as session}
|
||||
<TableRow>
|
||||
<TableCell>{session.id}</TableCell>
|
||||
<TableCell>{session.status}</TableCell>
|
||||
<TableCell>{new Date(session.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{new Date(session.updatedAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{session.externalId || 'N/A'}</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Showing {Math.min((page - 1) * limit + 1, total)} to {Math.min(page * limit, total)} of {total} sessions
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<Button on:click={prevPage} disabled={page === 1}>Previous</Button>
|
||||
<Button on:click={nextPage} disabled={page * limit >= total}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
193
src/routes/experiment/[id]/FileManager.svelte
Normal file
193
src/routes/experiment/[id]/FileManager.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { onMount } from 'svelte';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
export let experimentId: string;
|
||||
|
||||
let files: any[] = [];
|
||||
let uploading = false;
|
||||
let uploadError = '';
|
||||
|
||||
async function fetchFiles() {
|
||||
if (!experimentId) return;
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
files = data.files;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) await uploadFiles(input.files);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList: FileList) {
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('relativePath', file.webkitRelativePath || file.name);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
}
|
||||
await fetchFiles();
|
||||
} catch (e: any) {
|
||||
uploadError = e.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(key: string) {
|
||||
let relativeKey = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativeKey = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
}
|
||||
|
||||
function buildFileTree(files: any[]): any {
|
||||
const root: any = {};
|
||||
for (const file of files) {
|
||||
const parts = file.name.split('/');
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
if (i === parts.length - 1) {
|
||||
node[part] = { ...file, isFile: true };
|
||||
} else {
|
||||
node[part] = node[part] || { children: {}, isFile: false };
|
||||
node = node[part].children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function downloadFile(key: string) {
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`;
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to download');
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
let filename = key.split('/').pop() || 'file';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
return res.blob().then((blob) => ({ blob, filename }));
|
||||
})
|
||||
.then(({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}, 100);
|
||||
})
|
||||
.catch(() => alert('Failed to download file.'));
|
||||
}
|
||||
|
||||
let expanded = new Set<string>();
|
||||
function toggleFolder(path: string) {
|
||||
if (expanded.has(path)) expanded.delete(path);
|
||||
else expanded.add(path);
|
||||
expanded = new Set(expanded);
|
||||
}
|
||||
|
||||
async function deleteFileOrFolder(key: string, isFolder: boolean) {
|
||||
if (isFolder) {
|
||||
let relativePrefix = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativePrefix = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(
|
||||
relativePrefix
|
||||
)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
} else {
|
||||
await deleteFile(key);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mt-8">
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
|
||||
on:click={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
Upload Files or Folder
|
||||
</Button>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory
|
||||
class="hidden"
|
||||
on:change={handleFileInput}
|
||||
/>
|
||||
</div>
|
||||
{#if uploading}
|
||||
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
|
||||
{/if}
|
||||
<ul class="divide-y divide-border">
|
||||
<FileBrowser
|
||||
tree={buildFileTree(files)}
|
||||
parentPath=""
|
||||
{expanded}
|
||||
onToggle={toggleFolder}
|
||||
onDelete={deleteFileOrFolder}
|
||||
onDownload={downloadFile}
|
||||
isRoot={true}
|
||||
/>
|
||||
{#if files.length === 0}
|
||||
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,274 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import * as schema from '$lib/server/db/schema.js';
|
||||
import s3 from '$lib/server/s3.js';
|
||||
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import mime from 'mime-types';
|
||||
import { S3_BUCKET } from '$env/static/private';
|
||||
import { dev } from '$app/environment';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
|
||||
|
||||
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||
const vendorFileRules = [
|
||||
{
|
||||
regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css`
|
||||
}
|
||||
];
|
||||
|
||||
export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
const { experimentId, path } = params;
|
||||
|
||||
// Check if the experiment is a multiplayer experiment
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw error(404, 'Experiment not found');
|
||||
}
|
||||
|
||||
if (!experiment.multiplayer) {
|
||||
throw error(403, 'This is not a multiplayer experiment');
|
||||
}
|
||||
|
||||
|
||||
// Map of requested CSS files to their location in the static directory.
|
||||
const staticCssMap: Record<string, string> = {
|
||||
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
|
||||
'lib/vendors/survey.grey_style.css': 'static/lib/psychoJS/surveyJS/survey.grey_style.css'
|
||||
};
|
||||
|
||||
if (path in staticCssMap) {
|
||||
const filePath = staticCssMap[path];
|
||||
try {
|
||||
const fileContents = await fs.readFile(filePath, 'utf-8');
|
||||
return new Response(fileContents, {
|
||||
headers: { 'Content-Type': 'text/css' }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.code === 'ENOENT') {
|
||||
throw error(404, 'File not found in static directory');
|
||||
}
|
||||
throw error(500, 'Error reading static file');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the requested path is a vendor file and proxy to CDN
|
||||
for (const rule of vendorFileRules) {
|
||||
const match = path.match(rule.regex);
|
||||
if (match) {
|
||||
try {
|
||||
// The first element of match is the full string, subsequent elements are capture groups.
|
||||
const cdnUrl = (rule.url as Function).apply(null, match.slice(1));
|
||||
const cdnResponse = await fetch(cdnUrl);
|
||||
|
||||
if (!cdnResponse.ok) {
|
||||
throw error(
|
||||
cdnResponse.status,
|
||||
`Failed to fetch from CDN: ${cdnResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Buffer the entire response to avoid potential streaming issues.
|
||||
// This is less memory-efficient but more robust for debugging.
|
||||
const body = await cdnResponse.arrayBuffer();
|
||||
const headers = new Headers(cdnResponse.headers);
|
||||
|
||||
// The `fetch` API automatically decompresses content, so we need to remove
|
||||
// the Content-Encoding header to avoid the browser trying to decompress it again.
|
||||
// We also remove Content-Length because it's now incorrect for the decompressed body.
|
||||
headers.delete('Content-Encoding');
|
||||
headers.delete('Content-Length');
|
||||
|
||||
// Ensure the Content-Type is set correctly, as it's crucial for the browser.
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set(
|
||||
'Content-Type',
|
||||
path.endsWith('.css') ? 'text/css' : 'application/javascript'
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the response from the CDN
|
||||
return new Response(body, {
|
||||
status: cdnResponse.status,
|
||||
statusText: cdnResponse.statusText,
|
||||
headers: headers
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw error(500, 'Failed to proxy vendor file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||
let experimentSessionId = cookies.get(cookieName);
|
||||
|
||||
if (!experimentSessionId) {
|
||||
// First request for this experiment. Create a new participant session.
|
||||
experimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: experimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(cookieName, experimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
} else {
|
||||
// subsequent requests, check if cookie is valid
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.experimentSession)
|
||||
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||
if (!session) {
|
||||
// invalid cookie, create new session
|
||||
const newExperimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: newExperimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
cookies.set(cookieName, newExperimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const s3Prefix = `experiments/${experimentId}/`;
|
||||
const filePath = path === '' ? 'index.html' : path;
|
||||
const key = `${s3Prefix}${filePath}`;
|
||||
|
||||
// Check if user is trying to access files outside of the experiment directory
|
||||
if (!key.startsWith(s3Prefix)) {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
});
|
||||
const s3Response = await s3.send(command);
|
||||
|
||||
if (!s3Response.Body) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const stream = s3Response.Body;
|
||||
const contentType =
|
||||
mime.lookup(filePath) || s3Response.ContentType || 'application/octet-stream';
|
||||
|
||||
if (filePath.endsWith('index.html')) {
|
||||
const body = await stream.transformToString();
|
||||
|
||||
// For PsychoJS experiments, we need to set the base href to the experiment root
|
||||
// so that relative paths like "stimuli/image.jpg" resolve correctly
|
||||
const basePath = `/public/multiplayer/run/${experimentId}/`;
|
||||
|
||||
// Get all files in the experiment directory to create a resource manifest
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: S3_BUCKET,
|
||||
Prefix: `experiments/${experimentId}/`
|
||||
});
|
||||
const listResponse = await s3.send(listCommand);
|
||||
|
||||
// Create resource manifest for PsychoJS
|
||||
const resources = (listResponse.Contents || [])
|
||||
.filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`)
|
||||
.map(obj => {
|
||||
const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, '');
|
||||
return {
|
||||
name: relativePath,
|
||||
path: relativePath
|
||||
};
|
||||
});
|
||||
|
||||
// Create the resource injection script that runs after PsychoJS loads
|
||||
const resourceInjectionScript = `
|
||||
<script>
|
||||
// Wait for PsychoJS to be available and inject resources
|
||||
function injectPsychoJSResources() {
|
||||
const resources = ${JSON.stringify(resources)};
|
||||
|
||||
// Check if PsychoJS and its components are available
|
||||
if (typeof psychoJS !== 'undefined' && psychoJS.serverManager) {
|
||||
|
||||
// Add resources to the server manager's resource list
|
||||
resources.forEach(resource => {
|
||||
psychoJS.serverManager._resources.set(resource.name, {
|
||||
name: resource.name,
|
||||
path: resource.path,
|
||||
status: psychoJS.serverManager.constructor.ResourceStatus.NOT_DOWNLOADED
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
// PsychoJS not ready yet, try again in 100ms
|
||||
setTimeout(injectPsychoJSResources, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Start trying to inject resources when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectPsychoJSResources);
|
||||
} else {
|
||||
injectPsychoJSResources();
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
const injectedBody = body
|
||||
.replace(/<head[^>]*>/, `$&<base href="${basePath}">`)
|
||||
.replace(/<\/body>/, `${resourceInjectionScript}<script>console.log('injection')</script></body>`);
|
||||
|
||||
return new Response(injectedBody, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fileBuffer = await stream.transformToByteArray();
|
||||
return new Response(fileBuffer, {
|
||||
headers: { 'Content-Type': contentType }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name === 'NoSuchKey') {
|
||||
throw error(404, 'File not found');
|
||||
} else {
|
||||
throw error(500, `Error fetching from S3: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { S3_BUCKET } from '$env/static/private';
|
||||
import { dev } from '$app/environment';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const PARTICIPANT_COOKIE_PREFIX = 'participant-session-';
|
||||
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
|
||||
|
||||
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||
const vendorFileRules = [
|
||||
@@ -99,22 +99,25 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
}
|
||||
}
|
||||
|
||||
const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`;
|
||||
let participantSessionId = cookies.get(cookieName);
|
||||
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||
let experimentSessionId = cookies.get(cookieName);
|
||||
|
||||
if (!participantSessionId) {
|
||||
if (!experimentSessionId) {
|
||||
// First request for this experiment. Create a new participant session.
|
||||
participantSessionId = randomUUID();
|
||||
experimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(schema.participantSession).values({
|
||||
id: participantSessionId,
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: experimentSessionId,
|
||||
experimentId,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(cookieName, participantSessionId, {
|
||||
cookies.set(cookieName, experimentSessionId, {
|
||||
path: `/public/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
@@ -124,19 +127,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
// subsequent requests, check if cookie is valid
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.participantSession)
|
||||
.where(eq(schema.participantSession.id, participantSessionId));
|
||||
.from(schema.experimentSession)
|
||||
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||
if (!session) {
|
||||
// invalid cookie, create new session
|
||||
const newParticipantSessionId = randomUUID();
|
||||
await db.insert(schema.participantSession).values({
|
||||
id: newParticipantSessionId,
|
||||
const newExperimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: newExperimentSessionId,
|
||||
experimentId,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
cookies.set(cookieName, newParticipantSessionId, {
|
||||
cookies.set(cookieName, newExperimentSessionId, {
|
||||
path: `/public/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
|
||||
Reference in New Issue
Block a user