basic jspsych functionality

This commit is contained in:
Shaheed Azaad
2025-07-13 17:45:00 +02:00
parent e776b7f041
commit 9fba3becb8
16 changed files with 3931 additions and 144 deletions

View File

@@ -5,6 +5,8 @@ globs:
# Structure # Structure
- Read, but don't edit @README.md to get an idea of how the app should work.
- Where possible, the app should be separated into an API and frontend components. - Where possible, the app should be separated into an API and frontend components.
- Tests should be written as the app is developed. - Tests should be written as the app is developed.
- Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel. - Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel.
- User superforms for all forms

View File

@@ -5,5 +5,5 @@ globs:
# Styling # Styling
- Where possible, the app should use shadcn for svelte components - Everything should be designed using shadcn for svelte components
= No plain CSS should be written - only Tailwind = No plain CSS should be written - only Tailwind

View File

@@ -1 +0,0 @@
DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"

1
.gitignore vendored
View File

@@ -16,7 +16,6 @@ Thumbs.db
# Env # Env
.env .env
.env.* .env.*
!.env.example
!.env.test !.env.test
# Vite # Vite

View File

@@ -1,38 +1,16 @@
# sv # cog-socket
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). A platform for hosting jsPsych (and soon Pavovia) experiments with multiplayer support using websockets.
## Creating a project # Stack
If you're seeing this, you've probably already done this step. Congrats! - SvelteKit
- Lucia (authentication)
- Vite and Playwright (testing)
- DrizzleORM (database)
- Typescript
```bash # Development
# create a new project in the current directory
npx sv create
# create a new project in my-app - `npm run db:start` to start the PostgreSQL server.
npx sv create my-app Th
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -10,5 +10,17 @@ services:
POSTGRES_DB: local POSTGRES_DB: local
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
minio:
image: minio/minio:latest
ports:
- 9000:9000
- 9001:9001
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server --console-address ":9001" /data
volumes:
- minio-data:/data
volumes: volumes:
pgdata: pgdata:
minio-data:

View File

@@ -1,11 +1,10 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from 'drizzle-kit';
import 'dotenv/config';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({ export default defineConfig({
schema: './src/lib/server/db/schema.ts', schema: './src/lib/server/db/schema.ts',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL }, dbCredentials: { url: process.env.DATABASE_URL }
verbose: true,
strict: true
}); });

3595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.515.0",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
@@ -34,6 +36,7 @@
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"formsnap": "^2.0.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"playwright": "^1.53.0", "playwright": "^1.53.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
@@ -41,7 +44,10 @@
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"sveltekit-superforms": "^2.27.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"vite": "^7.0.4", "vite": "^7.0.4",
@@ -50,9 +56,22 @@
"vitest-browser-svelte": "^0.1.0" "vitest-browser-svelte": "^0.1.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.844.0",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@types/mime-types": "^3.0.1",
"bits-ui": "^2.8.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.0",
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"postgres": "^3.4.5" "lucia": "^3.2.2",
"mime-types": "^3.0.1",
"oslo": "^1.2.1",
"postgres": "^3.4.5",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.76"
} }
} }

View File

@@ -1 +1,121 @@
@import 'tailwindcss'; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
src/app.d.ts vendored
View File

@@ -7,9 +7,9 @@ declare global {
session: import('$lib/server/auth').SessionValidationResult['session']; session: import('$lib/server/auth').SessionValidationResult['session'];
} }
} // interface Error {} } // interface Error {}
// interface Locals {} } // interface Locals {}
} // interface PageData {} // interface PageData {}
// interface PageState {}
// interface PageState {}
// interface Platform {} // interface Platform {}
export {}; export {};

View File

@@ -1,7 +1,24 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import * as auth from '$lib/server/auth'; import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import argon2 from '@node-rs/argon2';
import { dev } from '$app/environment';
const ensureDefaultAdmin = async () => {
if (!dev) return;
const [admin] = await db.select().from(schema.user).where(eq(schema.user.username, 'admin'));
if (!admin) {
const passwordHash = await argon2.hash('admin');
await db.insert(schema.user).values({ id: 'admin', username: 'admin', passwordHash });
// Optionally log creation
console.log('Default admin user created: admin/admin');
}
};
const handleAuth: Handle = async ({ event, resolve }) => { const handleAuth: Handle = async ({ event, resolve }) => {
await ensureDefaultAdmin();
const sessionToken = event.cookies.get(auth.sessionCookieName); const sessionToken = event.cookies.get(auth.sessionCookieName);
if (!sessionToken) { if (!sessionToken) {

View File

@@ -12,26 +12,33 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() { export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18)); const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes); const token = encodeBase64url(bytes);
return token; return token;
} }
export async function createSession(token: string, userId: string) { export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: table.Session = { const session: table.Session = {
id: sessionId, id: sessionId,
userId, userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30) expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
}; };
await db.insert(table.session).values(session); await db.insert(table.session).values(session);
return session; return session;
} }
export async function validateSessionToken(token: string) { export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username }, user: {
id: table.user.id,
username: table.user.username
},
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)
@@ -41,15 +48,17 @@ export async function validateSessionToken(token: string) {
if (!result) { if (!result) {
return { session: null, user: null }; return { session: null, user: null };
} }
const { session, user } = result;
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime(); const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) { if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id)); await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null }; return { session: null, user: null };
} }
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) { if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db await db
@@ -68,14 +77,9 @@ export async function invalidateSession(sessionId: string) {
} }
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, { event.cookies.set(sessionCookieName, token, { expires: expiresAt, path: '/' });
expires: expiresAt,
path: '/'
});
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, { event.cookies.delete(sessionCookieName, { path: '/' });
path: '/'
});
} }

View File

@@ -1,8 +1,10 @@
import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core'; import { pgTable, serial, integer, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
export const user = pgTable('user', { export const user = pgTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
age: integer('age') age: integer('age'),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull()
}); });
export const session = pgTable('session', { export const session = pgTable('session', {
@@ -13,6 +15,31 @@ export const session = pgTable('session', {
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull()
}); });
export type Session = typeof session.$inferSelect; export const experimentTypeEnum = pgEnum('experiment_type', ['jsPsych', 'PsychoJS']);
export const experiment = pgTable('experiment', {
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
createdBy: text('created_by')
.notNull()
.references(() => user.id),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
multiplayer: boolean('multiplayer').default(false).notNull(),
type: experimentTypeEnum('type').notNull()
});
export const participantSession = pgTable('participant_session', {
id: text('id').primaryKey(),
experimentId: text('experiment_id')
.notNull()
.references(() => experiment.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent')
});
export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
export type ParticipantSession = typeof participantSession.$inferSelect;
export type Experiment = typeof experiment.$inferSelect;

View File

@@ -1,7 +1,30 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { Home, FlaskConical } from '@lucide/svelte';
import { PUBLIC_APP_NAME } from '$env/static/public';
let { children } = $props(); let { children } = $props();
const links = [
{ href: '/', icon: Home, label: 'Dashboard' },
{ href: '/experiments', icon: FlaskConical, label: 'Experiments' } // Placeholder for experiments list
];
</script> </script>
{@render children()} <header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center justify-between w-full px-4">
<a href="/" class="mr-6 flex items-center space-x-2">
<span class="font-bold">{PUBLIC_APP_NAME || 'Cog Socket App'}</span>
</a>
<div class="flex flex-1 items-center justify-end space-x-2">
<nav class="flex items-center">
<!-- Top bar right side elements, e.g., user menu, settings -->
</nav>
</div>
</div>
</header>
<main class="flex-1 overflow-y-auto flex justify-center items-start px-4 py-8 min-h-screen">
<div class="w-full max-w-3xl md:w-3/4 lg:w-9/12">
{@render children()}
</div>
</main>

View File

@@ -1,2 +1,129 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let isLogin = true;
let username = '';
let password = '';
let error = '';
const dispatch = createEventDispatcher();
let user: boolean | null = null;
let experiments: any[] = [];
onMount(async () => {
const res = await fetch('/api/experiment');
if (res.ok) {
const data = await res.json();
if (data.error === 'Unauthorized') {
user = null;
} else {
user = true;
experiments = data.experiments;
}
}
});
async function handleSubmit() {
error = '';
const endpoint = isLogin ? '/api/login' : '/api/register';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (res.ok) {
dispatch('auth', { user: username });
window.location.reload();
} else {
const data = await res.json().catch(() => ({}));
error = data?.error || 'Authentication failed';
}
}
</script>
{#if user}
<div class="flex flex-col min-h-screen bg-background py-8">
<Card class="mb-8 w-full">
<CardHeader>
<CardTitle>Dashboard</CardTitle>
</CardHeader>
<CardContent>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Your Experiments</h2>
<Button on:click={() => goto('/experiment/create')}>Create Experiment</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if experiments.length === 0}
<TableRow>
<TableCell colspan={4} class="text-center text-muted-foreground">No experiments yet.</TableCell>
</TableRow>
{:else}
{#each experiments as exp}
<TableRow>
<TableCell>
<a href={`/experiment/${exp.id}`} class="text-primary underline hover:text-primary/80">{exp.name}</a>
</TableCell>
<TableCell>{exp.description}</TableCell>
<TableCell>{new Date(exp.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
size="sm"
on:click={() => window.open(`/public/run/${exp.id}`, '_blank')}
>
Run
</Button>
</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{:else}
<div class="flex justify-center items-center min-h-screen bg-background">
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle>{isLogin ? 'Login' : 'Register'}</CardTitle>
</CardHeader>
<CardContent>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<Label for="username">Username</Label>
<Input id="username" type="text" bind:value={username} required autocomplete="username" />
</div>
<div>
<Label for="password">Password</Label>
<Input id="password" type="password" bind:value={password} required autocomplete={isLogin ? 'current-password' : 'new-password'} />
</div>
{#if error}
<div class="text-red-500 text-sm">{error}</div>
{/if}
<Button type="submit" class="w-full">{isLogin ? 'Login' : 'Register'}</Button>
</form>
<div class="mt-4 text-center">
<button class="text-sm text-primary underline" type="button" on:click={() => { isLogin = !isLogin; error = ''; }}>
{isLogin ? "Don't have an account? Register" : 'Already have an account? Login'}
</button>
</div>
</CardContent>
</Card>
</div>
{/if}