basic jspsych functionality
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,7 +16,6 @@ Thumbs.db
|
|||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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.
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
3595
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/app.css
122
src/app.css
@@ -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
6
src/app.d.ts
vendored
@@ -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 {};
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
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 }) => {
|
||||||
const sessionToken = event.cookies.get(auth.sessionCookieName);
|
await ensureDefaultAdmin();
|
||||||
|
const sessionToken = event.cookies.get(auth.sessionCookieName);
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.session = null;
|
event.locals.session = null;
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session, user } = await auth.validateSessionToken(sessionToken);
|
const { session, user } = await auth.validateSessionToken(sessionToken);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||||
} else {
|
} else {
|
||||||
auth.deleteSessionTokenCookie(event);
|
auth.deleteSessionTokenCookie(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.locals.user = user;
|
event.locals.user = user;
|
||||||
event.locals.session = session;
|
event.locals.session = session;
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle: Handle = handleAuth;
|
export const handle: Handle = handleAuth;
|
||||||
|
|||||||
@@ -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: '/'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user