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

@@ -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'];
}
} // interface Error {}
// interface Locals {}
} // interface PageData {}
// interface PageState {}
} // interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
export {};

View File

@@ -1,26 +1,43 @@
import type { Handle } from '@sveltejs/kit';
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 sessionToken = event.cookies.get(auth.sessionCookieName);
await ensureDefaultAdmin();
const sessionToken = event.cookies.get(auth.sessionCookieName);
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await auth.validateSessionToken(sessionToken);
const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} else {
auth.deleteSessionTokenCookie(event);
}
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} else {
auth.deleteSessionTokenCookie(event);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};
export const handle: Handle = handleAuth;

View File

@@ -12,26 +12,33 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: table.Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
};
await db.insert(table.session).values(session);
return session;
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// 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
})
.from(table.session)
@@ -41,15 +48,17 @@ export async function validateSessionToken(token: string) {
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
@@ -68,14 +77,9 @@ export async function invalidateSession(sessionId: string) {
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
event.cookies.set(sessionCookieName, token, { expires: expiresAt, path: '/' });
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
event.cookies.delete(sessionCookieName, { 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', {
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', {
@@ -13,6 +15,31 @@ export const session = pgTable('session', {
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 ParticipantSession = typeof participantSession.$inferSelect;
export type Experiment = typeof experiment.$inferSelect;

View File

@@ -1,7 +1,30 @@
<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>
{@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>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
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}