basic jspsych functionality
This commit is contained in:
@@ -5,6 +5,8 @@ globs:
|
||||
|
||||
# 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.
|
||||
- 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.
|
||||
- User superforms for all forms
|
||||
@@ -5,5 +5,5 @@ globs:
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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.example
|
||||
!.env.test
|
||||
|
||||
# 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
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
# Development
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## 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.
|
||||
- `npm run db:start` to start the PostgreSQL server.
|
||||
Th
|
||||
|
||||
@@ -10,5 +10,17 @@ services:
|
||||
POSTGRES_DB: local
|
||||
volumes:
|
||||
- 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:
|
||||
pgdata:
|
||||
minio-data:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import 'dotenv/config';
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
dbCredentials: { url: process.env.DATABASE_URL }
|
||||
});
|
||||
|
||||
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": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.515.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
@@ -34,6 +36,7 @@
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"globals": "^16.0.0",
|
||||
"playwright": "^1.53.0",
|
||||
"prettier": "^3.4.2",
|
||||
@@ -41,7 +44,10 @@
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"sveltekit-superforms": "^2.27.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^7.0.4",
|
||||
@@ -50,9 +56,22 @@
|
||||
"vitest-browser-svelte": "^0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@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",
|
||||
"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'];
|
||||
}
|
||||
} // interface Error {}
|
||||
// interface Locals {}
|
||||
} // interface PageData {}
|
||||
// interface PageState {}
|
||||
} // interface Locals {}
|
||||
// interface PageData {}
|
||||
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
export {};
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
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 }) => {
|
||||
await ensureDefaultAdmin();
|
||||
const sessionToken = event.cookies.get(auth.sessionCookieName);
|
||||
|
||||
if (!sessionToken) {
|
||||
|
||||
@@ -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: '/' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { Home, FlaskConical } from '@lucide/svelte';
|
||||
import { PUBLIC_APP_NAME } from '$env/static/public';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const links = [
|
||||
{ href: '/', icon: Home, label: 'Dashboard' },
|
||||
{ href: '/experiments', icon: FlaskConical, label: 'Experiments' } // Placeholder for experiments list
|
||||
];
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user