Compare commits
6 Commits
e3c27b304f
...
c55454cf3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c55454cf3d | ||
|
|
e9565471cb | ||
|
|
02734040cb | ||
|
|
dc2f68a2b4 | ||
|
|
19ffd48ac0 | ||
|
|
55401fd37b |
@@ -1,12 +1,4 @@
|
|||||||
---
|
---
|
||||||
description: General structure of the app
|
alwaysApply: true
|
||||||
globs:
|
|
||||||
---
|
---
|
||||||
|
Read, but don't edit @CLAUDE.md for all prompts
|
||||||
# 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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
description: Code rules
|
|
||||||
globs:
|
|
||||||
---
|
|
||||||
|
|
||||||
# Code
|
|
||||||
|
|
||||||
- This app uses Sveltekit. Where possible, the app should use reusable components and Svelte features for reactivity.
|
|
||||||
- This app is written in Typescript.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implementing database functions
|
|
||||||
globs:
|
|
||||||
---
|
|
||||||
|
|
||||||
# Your rule content
|
|
||||||
|
|
||||||
- This app uses DrizzleORM
|
|
||||||
- The app should use transactions and connection pooling where possible
|
|
||||||
- Utmost care needs to be taken to prevent SQL injection
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
description: Styling
|
|
||||||
globs:
|
|
||||||
---
|
|
||||||
|
|
||||||
# Styling
|
|
||||||
|
|
||||||
- Everything should be designed using shadcn for svelte components
|
|
||||||
= No plain CSS should be written - only Tailwind
|
|
||||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
cog-socket is a platform for hosting jsPsych and Pavlovia experiments with multiplayer support using WebSockets. Built with SvelteKit, it supports both single-player and multiplayer psychological experiments.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- `npm run db:start` - Start PostgreSQL server via Docker Compose
|
||||||
|
- `npm run db:push` - Push schema changes to database
|
||||||
|
- `npm run db:migrate` - Run database migrations
|
||||||
|
- `npm run db:studio` - Open Drizzle Studio for database management
|
||||||
|
|
||||||
|
### Development Server
|
||||||
|
- `npm run dev` - Start development server
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run preview` - Preview production build
|
||||||
|
|
||||||
|
### Multiplayer Server
|
||||||
|
- `npm run multiplayer:dev` - Start multiplayer server in watch mode
|
||||||
|
- `npm run multiplayer:start` - Start multiplayer server in production mode
|
||||||
|
|
||||||
|
### Testing & Quality
|
||||||
|
- `npm run test` - Run all tests (unit + e2e)
|
||||||
|
- `npm run test:unit` - Run unit tests with Vitest
|
||||||
|
- `npm run test:e2e` - Run end-to-end tests with Playwright
|
||||||
|
- `npm run check` - Run svelte-check for TypeScript checking
|
||||||
|
- `npm run lint` - Run linting (Prettier + ESLint)
|
||||||
|
- `npm run format` - Format code with Prettier
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Database Schema (DrizzleORM + PostgreSQL)
|
||||||
|
- **Users**: Authentication with Lucia, supports admin/user roles
|
||||||
|
- **Experiments**: jsPsych or PsychoJS experiments with multiplayer flag
|
||||||
|
- **Sessions**: User authentication sessions
|
||||||
|
- **ExperimentSessions**: Individual experiment runs with status tracking
|
||||||
|
|
||||||
|
### Multiplayer System (Colyseus)
|
||||||
|
- **Standalone server**: Runs on separate port (8080) from main app
|
||||||
|
- **ExperimentRoom**: Manages participant queuing, state synchronization
|
||||||
|
- **Session management**: Handles participant connections, disconnections, and queuing
|
||||||
|
- **Real-time communication**: WebSocket-based experiment state sharing
|
||||||
|
|
||||||
|
### SvelteKit Structure
|
||||||
|
- **Routes**:
|
||||||
|
- `/experiment/[id]` - Single experiment management
|
||||||
|
- `/public/multiplayer/run/[experimentId]` - Multiplayer experiment runner
|
||||||
|
- `/public/run/[experimentId]` - Single-player experiment runner
|
||||||
|
- `/api/experiments/[experimentId]` - Experiment API endpoints
|
||||||
|
- **Authentication**: Lucia-based session management with cookies
|
||||||
|
- **File handling**: S3 integration for experiment assets
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
- **ExperimentRoom.ts**: Core multiplayer room logic with participant management
|
||||||
|
- **ExperimentRoomState.ts**: Colyseus schema for synchronized state
|
||||||
|
- **colyseusServer.ts**: Multiplayer server setup and room management
|
||||||
|
- **sessionManager.ts**: Bridges SvelteKit app with Colyseus server
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Multiplayer Development
|
||||||
|
- Multiplayer server runs independently on port 8080
|
||||||
|
- Experiments must have `multiplayer: true` flag in database
|
||||||
|
- Room capacity determined by `maxParticipants` field
|
||||||
|
- Queue system handles overflow participants
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Default admin user (admin/admin) created in development
|
||||||
|
- Session tokens stored in cookies, managed by Lucia
|
||||||
|
- Authentication middleware in `hooks.server.ts`
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
- Use DrizzleORM for all database operations
|
||||||
|
- Schema defined in `src/lib/server/db/schema.ts`
|
||||||
|
- Migrations managed through drizzle-kit
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
- UI components in `src/lib/components/ui/` (bits-ui based)
|
||||||
|
- Server logic in `src/lib/server/`
|
||||||
|
- Route handlers follow SvelteKit conventions
|
||||||
|
- Multiplayer code isolated in `src/lib/server/multiplayer/`
|
||||||
|
|
||||||
|
## Testing Setup
|
||||||
|
- Unit tests: Vitest with Svelte browser testing
|
||||||
|
- E2E tests: Playwright
|
||||||
|
- TypeScript checking: svelte-check
|
||||||
|
- Linting: ESLint + Prettier with Svelte plugins
|
||||||
@@ -21,6 +21,18 @@ services:
|
|||||||
command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
volumes:
|
volumes:
|
||||||
- minio-data:/data
|
- minio-data:/data
|
||||||
|
chrome:
|
||||||
|
image: browserless/chrome:latest
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
environment:
|
||||||
|
- PREBOOT_CHROME=true
|
||||||
|
- CONNECTION_TIMEOUT=60000
|
||||||
|
- MAX_CONCURRENT_SESSIONS=10
|
||||||
|
- CHROME_REFRESH_TIME=2000
|
||||||
|
- DEBUG=*
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
minio-data:
|
minio-data:
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ S3_ACCESS_KEY="minioadmin"
|
|||||||
S3_SECRET_KEY="minioadmin"
|
S3_SECRET_KEY="minioadmin"
|
||||||
S3_BUCKET="cog-socket"
|
S3_BUCKET="cog-socket"
|
||||||
BASE_URL="http://localhost:5173"
|
BASE_URL="http://localhost:5173"
|
||||||
|
# Chrome for multiplayer functionality
|
||||||
|
CHROME_HOST="localhost"
|
||||||
|
CHROME_PORT="9222"
|
||||||
3334
package-lock.json
generated
3334
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -18,7 +18,9 @@
|
|||||||
"db:start": "docker compose up",
|
"db:start": "docker compose up",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"multiplayer:dev": "tsx --watch src/lib/server/multiplayer/standalone.ts",
|
||||||
|
"multiplayer:start": "tsx src/lib/server/multiplayer/standalone.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/browser": "^3.2.3",
|
"@vitest/browser": "^3.2.3",
|
||||||
"drizzle-kit": "^0.30.2",
|
"drizzle-kit": "^0.30.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"sveltekit-superforms": "^2.27.1",
|
"sveltekit-superforms": "^2.27.1",
|
||||||
"tailwind-variants": "^1.0.0",
|
"tailwind-variants": "^1.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
@@ -57,6 +61,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.844.0",
|
"@aws-sdk/client-s3": "^3.844.0",
|
||||||
|
"@colyseus/core": "^0.16.19",
|
||||||
|
"@colyseus/schema": "^3.0.42",
|
||||||
|
"@colyseus/ws-transport": "^0.16.5",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@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",
|
||||||
@@ -64,14 +71,19 @@
|
|||||||
"bits-ui": "^2.8.10",
|
"bits-ui": "^2.8.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"colyseus": "^0.16.4",
|
||||||
|
"colyseus.js": "^0.16.19",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"lucia": "^3.2.2",
|
"lucia": "^3.2.2",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
|
"morphdom": "^2.7.5",
|
||||||
"oslo": "^1.2.1",
|
"oslo": "^1.2.1",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
"puppeteer-core": "^24.14.0",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"ws": "^8.18.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import * as schema from '$lib/server/db/schema';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import argon2 from '@node-rs/argon2';
|
import argon2 from '@node-rs/argon2';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
import { sessionManager } from '$lib/server/multiplayer/sessionManager';
|
||||||
|
|
||||||
|
// Initialize session manager (this starts the WebSocket server)
|
||||||
|
console.log('Initializing multiplayer session manager...');
|
||||||
|
// The sessionManager is initialized when imported
|
||||||
|
|
||||||
const ensureDefaultAdmin = async () => {
|
const ensureDefaultAdmin = async () => {
|
||||||
if (!dev) return;
|
if (!dev) return;
|
||||||
|
|||||||
28
src/lib/components/ui/navigation-menu/index.ts
Normal file
28
src/lib/components/ui/navigation-menu/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Root from "./navigation-menu.svelte";
|
||||||
|
import Content from "./navigation-menu-content.svelte";
|
||||||
|
import Indicator from "./navigation-menu-indicator.svelte";
|
||||||
|
import Item from "./navigation-menu-item.svelte";
|
||||||
|
import Link from "./navigation-menu-link.svelte";
|
||||||
|
import List from "./navigation-menu-list.svelte";
|
||||||
|
import Trigger from "./navigation-menu-trigger.svelte";
|
||||||
|
import Viewport from "./navigation-menu-viewport.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Indicator,
|
||||||
|
Item,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
Viewport,
|
||||||
|
//
|
||||||
|
Root as NavigationMenuRoot,
|
||||||
|
Content as NavigationMenuContent,
|
||||||
|
Indicator as NavigationMenuIndicator,
|
||||||
|
Item as NavigationMenuItem,
|
||||||
|
Link as NavigationMenuLink,
|
||||||
|
List as NavigationMenuList,
|
||||||
|
Trigger as NavigationMenuTrigger,
|
||||||
|
Viewport as NavigationMenuViewport,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
class={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 left-0 top-0 w-full md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.IndicatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"></div>
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
class={cn("relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.LinkProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
class={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
class={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const navigationMenuTriggerStyle = tv({
|
||||||
|
base: "bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
class={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ViewportProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("absolute left-0 top-full isolate z-50 flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
class={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--bits-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--bits-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
32
src/lib/components/ui/navigation-menu/navigation-menu.svelte
Normal file
32
src/lib/components/ui/navigation-menu/navigation-menu.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
viewport = true,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.RootProps & {
|
||||||
|
viewport?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
class={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
{#if viewport}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
{/if}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
@@ -26,20 +26,26 @@ export const experiment = pgTable('experiment', {
|
|||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||||
multiplayer: boolean('multiplayer').default(false).notNull(),
|
multiplayer: boolean('multiplayer').default(false).notNull(),
|
||||||
|
maxParticipants: integer('max_participants').default(1).notNull(),
|
||||||
type: experimentTypeEnum('type').notNull()
|
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(),
|
id: text('id').primaryKey(),
|
||||||
experimentId: text('experiment_id')
|
experimentId: text('experiment_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => experiment.id, { onDelete: 'cascade' }),
|
.references(() => experiment.id, { onDelete: 'cascade' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
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'),
|
ipAddress: text('ip_address'),
|
||||||
userAgent: text('user_agent')
|
userAgent: text('user_agent')
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Session = typeof session.$inferSelect;
|
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;
|
export type Experiment = typeof experiment.$inferSelect;
|
||||||
|
export type ExperimentSession = typeof experimentSession.$inferSelect;
|
||||||
|
|||||||
197
src/lib/server/multiplayer/colyseusServer.ts
Normal file
197
src/lib/server/multiplayer/colyseusServer.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Server, matchMaker } from 'colyseus';
|
||||||
|
import { ExperimentRoom } from './rooms/ExperimentRoom.js';
|
||||||
|
import { WebSocketTransport } from '@colyseus/ws-transport';
|
||||||
|
import { db } from './db.js';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
|
||||||
|
export class MultiplayerServer {
|
||||||
|
private server: Server;
|
||||||
|
private port: number;
|
||||||
|
|
||||||
|
constructor(port: number = 8080) {
|
||||||
|
this.port = port;
|
||||||
|
|
||||||
|
this.server = new Server({
|
||||||
|
transport: new WebSocketTransport({
|
||||||
|
pingInterval: 30000,
|
||||||
|
pingMaxRetries: 3
|
||||||
|
}),
|
||||||
|
presence: undefined, // Use default in-memory presence
|
||||||
|
driver: undefined, // Use default in-memory driver
|
||||||
|
gracefullyShutdown: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add CORS middleware using Colyseus's built-in middleware support
|
||||||
|
this.server.use(undefined, (req, res, next) => {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
// Always set CORS headers for development
|
||||||
|
if (isDev) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
} else {
|
||||||
|
const allowedOrigins = ['https://your-production-domain.com']; // Update with actual production domain
|
||||||
|
if (origin && allowedOrigins.includes(origin)) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRooms() {
|
||||||
|
// Define the ExperimentRoom
|
||||||
|
this.server.define('experiment_room', ExperimentRoom);
|
||||||
|
|
||||||
|
// Set up matchmaking
|
||||||
|
this.server.onShutdown(() => {
|
||||||
|
console.log('[MultiplayerServer] Server shutting down...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
try {
|
||||||
|
await this.server.listen(this.port);
|
||||||
|
console.log(`[MultiplayerServer] Colyseus server listening on port ${this.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MultiplayerServer] Failed to start server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
try {
|
||||||
|
await this.server.gracefullyShutdown();
|
||||||
|
console.log('[MultiplayerServer] Server stopped gracefully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MultiplayerServer] Error stopping server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer() {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility method to find or create a room for an experiment
|
||||||
|
async findOrCreateRoom(experimentId: string, clientOptions: any = {}) {
|
||||||
|
try {
|
||||||
|
// Get experiment details to determine max participants
|
||||||
|
const experiment = await db.query.experiment.findFirst({
|
||||||
|
where: eq(schema.experiment.id, experimentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
throw new Error(`Experiment ${experimentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!experiment.multiplayer) {
|
||||||
|
throw new Error(`Experiment ${experimentId} is not a multiplayer experiment`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find existing room for this experiment
|
||||||
|
const rooms = await matchMaker.query({
|
||||||
|
name: 'experiment_room',
|
||||||
|
options: { experimentId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find a room that has space or is waiting
|
||||||
|
let availableRoom = rooms.find(room =>
|
||||||
|
room.clients < experiment.maxParticipants &&
|
||||||
|
room.metadata?.status !== 'completed'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!availableRoom) {
|
||||||
|
// Create new room
|
||||||
|
console.log(`[MultiplayerServer] Creating new room for experiment ${experimentId}`);
|
||||||
|
availableRoom = await matchMaker.createRoom('experiment_room', {
|
||||||
|
experimentId,
|
||||||
|
maxParticipants: experiment.maxParticipants
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableRoom;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MultiplayerServer] Error finding/creating room:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get room status
|
||||||
|
async getRoomStatus(roomId: string) {
|
||||||
|
try {
|
||||||
|
const room = matchMaker.getRoomById(roomId);
|
||||||
|
if (!room) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId: room.roomId,
|
||||||
|
clients: room.clients,
|
||||||
|
maxClients: room.maxClients,
|
||||||
|
metadata: room.metadata
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MultiplayerServer] Error getting room status:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rooms for an experiment
|
||||||
|
async getExperimentRooms(experimentId: string) {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({
|
||||||
|
name: 'experiment_room',
|
||||||
|
options: { experimentId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return rooms.map(room => ({
|
||||||
|
roomId: room.roomId,
|
||||||
|
clients: room.clients,
|
||||||
|
maxClients: room.maxClients,
|
||||||
|
metadata: room.metadata
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MultiplayerServer] Error getting experiment rooms:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let multiplayerServer: MultiplayerServer | null = null;
|
||||||
|
|
||||||
|
export function getMultiplayerServer(port: number = 8080): MultiplayerServer {
|
||||||
|
if (!multiplayerServer) {
|
||||||
|
multiplayerServer = new MultiplayerServer(port);
|
||||||
|
}
|
||||||
|
return multiplayerServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMultiplayerServer(port: number = 8080): Promise<MultiplayerServer> {
|
||||||
|
const server = getMultiplayerServer(port);
|
||||||
|
return server.start().then(() => server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMultiplayerServer(): Promise<void> {
|
||||||
|
if (multiplayerServer) {
|
||||||
|
return multiplayerServer.stop();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
13
src/lib/server/multiplayer/db.ts
Normal file
13
src/lib/server/multiplayer/db.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
const client = postgres(process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/postgres');
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
console.log('[Multiplayer DB] Connected to database');
|
||||||
40
src/lib/server/multiplayer/index.ts
Normal file
40
src/lib/server/multiplayer/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { startMultiplayerServer, getMultiplayerServer } from './colyseusServer.js';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
|
||||||
|
|
||||||
|
// Start the multiplayer server
|
||||||
|
startMultiplayerServer(PORT)
|
||||||
|
.then((server) => {
|
||||||
|
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[Multiplayer] Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
|
||||||
|
try {
|
||||||
|
const server = getMultiplayerServer();
|
||||||
|
await server.stop();
|
||||||
|
console.log('[Multiplayer] Server stopped gracefully');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Multiplayer] Error during shutdown:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
|
||||||
|
try {
|
||||||
|
const server = getMultiplayerServer();
|
||||||
|
await server.stop();
|
||||||
|
console.log('[Multiplayer] Server stopped gracefully');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Multiplayer] Error during shutdown:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
244
src/lib/server/multiplayer/rooms/ExperimentRoom.ts
Normal file
244
src/lib/server/multiplayer/rooms/ExperimentRoom.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Room, matchMaker, type Client } from 'colyseus';
|
||||||
|
import { ExperimentRoomState, Participant } from '../schemas/ExperimentRoomState.js';
|
||||||
|
import { db } from '../db.js';
|
||||||
|
import * as schema from '../../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export class ExperimentRoom extends Room<ExperimentRoomState> {
|
||||||
|
maxClients = 1; // Will be set dynamically from database
|
||||||
|
|
||||||
|
async onCreate(options: { experimentId: string }) {
|
||||||
|
this.setState(new ExperimentRoomState());
|
||||||
|
|
||||||
|
// Get experiment details from database
|
||||||
|
const experiment = await db.query.experiment.findFirst({
|
||||||
|
where: eq(schema.experiment.id, options.experimentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
throw new Error(`Experiment ${options.experimentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!experiment.multiplayer) {
|
||||||
|
throw new Error(`Experiment ${options.experimentId} is not a multiplayer experiment`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure room
|
||||||
|
this.maxClients = experiment.maxParticipants;
|
||||||
|
this.state.experimentId = options.experimentId;
|
||||||
|
this.state.maxParticipants = experiment.maxParticipants;
|
||||||
|
this.state.createdAt = Date.now();
|
||||||
|
|
||||||
|
console.log(`[ExperimentRoom] Created room for experiment ${options.experimentId} (max: ${experiment.maxParticipants})`);
|
||||||
|
|
||||||
|
// Set up message handlers
|
||||||
|
this.onMessage('ready', (client, message) => {
|
||||||
|
this.handleParticipantReady(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onMessage('experiment_action', (client, message) => {
|
||||||
|
this.handleExperimentAction(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onMessage('experiment_state', (client, message) => {
|
||||||
|
this.handleExperimentState(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-dispose empty rooms after 30 minutes
|
||||||
|
this.autoDispose = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.clients.length === 0) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onJoin(client: Client, options: any) {
|
||||||
|
console.log(`[ExperimentRoom] Client ${client.sessionId} attempting to join room ${this.roomId}`);
|
||||||
|
|
||||||
|
// Check if room is full
|
||||||
|
if (this.state.currentParticipants >= this.state.maxParticipants) {
|
||||||
|
// Add to queue
|
||||||
|
this.state.queue.push(client.sessionId);
|
||||||
|
console.log(`[ExperimentRoom] Client ${client.sessionId} added to queue (position: ${this.state.queue.length})`);
|
||||||
|
|
||||||
|
// Notify client they're in queue
|
||||||
|
client.send('queued', {
|
||||||
|
position: this.state.queue.length,
|
||||||
|
message: 'You are in the queue. You will be admitted when a spot opens up.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't create participant yet - they're just in queue
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create participant
|
||||||
|
const participant = new Participant();
|
||||||
|
participant.id = client.sessionId;
|
||||||
|
participant.sessionId = client.sessionId;
|
||||||
|
participant.joinedAt = Date.now();
|
||||||
|
participant.userAgent = options.userAgent || '';
|
||||||
|
participant.ipAddress = options.ipAddress || '';
|
||||||
|
|
||||||
|
this.state.participants.set(client.sessionId, participant);
|
||||||
|
this.state.currentParticipants++;
|
||||||
|
|
||||||
|
console.log(`[ExperimentRoom] Client ${client.sessionId} joined (${this.state.currentParticipants}/${this.state.maxParticipants})`);
|
||||||
|
|
||||||
|
// Check if experiment should start
|
||||||
|
this.checkExperimentStart();
|
||||||
|
|
||||||
|
// Send current state to client
|
||||||
|
client.send('joined', {
|
||||||
|
participantId: client.sessionId,
|
||||||
|
currentParticipants: this.state.currentParticipants,
|
||||||
|
maxParticipants: this.state.maxParticipants,
|
||||||
|
status: this.state.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLeave(client: Client, consented: boolean) {
|
||||||
|
console.log(`[ExperimentRoom] Client ${client.sessionId} left (consented: ${consented})`);
|
||||||
|
|
||||||
|
// Remove from participants if they were participating
|
||||||
|
if (this.state.participants.has(client.sessionId)) {
|
||||||
|
this.state.participants.delete(client.sessionId);
|
||||||
|
this.state.currentParticipants--;
|
||||||
|
|
||||||
|
// Process queue if there's space
|
||||||
|
this.processQueue();
|
||||||
|
} else {
|
||||||
|
// Remove from queue
|
||||||
|
const queueIndex = this.state.queue.indexOf(client.sessionId);
|
||||||
|
if (queueIndex !== -1) {
|
||||||
|
this.state.queue.splice(queueIndex, 1);
|
||||||
|
|
||||||
|
// Notify remaining queue members of new positions
|
||||||
|
this.notifyQueuePositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If experiment is running and someone left, handle accordingly
|
||||||
|
if (this.state.status === 'running' && this.state.currentParticipants === 0) {
|
||||||
|
this.state.status = 'completed';
|
||||||
|
this.state.completedAt = Date.now();
|
||||||
|
console.log(`[ExperimentRoom] Experiment completed - all participants left`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue() {
|
||||||
|
if (this.state.queue.length === 0 || this.state.currentParticipants >= this.state.maxParticipants) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSessionId = this.state.queue.shift();
|
||||||
|
if (nextSessionId) {
|
||||||
|
// Find the client in the room
|
||||||
|
const client = this.clients.find(c => c.sessionId === nextSessionId);
|
||||||
|
if (client) {
|
||||||
|
// Move from queue to participants
|
||||||
|
const participant = new Participant();
|
||||||
|
participant.id = client.sessionId;
|
||||||
|
participant.sessionId = client.sessionId;
|
||||||
|
participant.joinedAt = Date.now();
|
||||||
|
|
||||||
|
this.state.participants.set(client.sessionId, participant);
|
||||||
|
this.state.currentParticipants++;
|
||||||
|
|
||||||
|
console.log(`[ExperimentRoom] Client ${client.sessionId} moved from queue to participants`);
|
||||||
|
|
||||||
|
// Notify client they're now participating
|
||||||
|
client.send('admitted', {
|
||||||
|
participantId: client.sessionId,
|
||||||
|
currentParticipants: this.state.currentParticipants,
|
||||||
|
maxParticipants: this.state.maxParticipants,
|
||||||
|
status: this.state.status
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if experiment should start
|
||||||
|
this.checkExperimentStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue positions for remaining clients
|
||||||
|
this.notifyQueuePositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyQueuePositions() {
|
||||||
|
this.state.queue.forEach((sessionId, index) => {
|
||||||
|
const client = this.clients.find(c => c.sessionId === sessionId);
|
||||||
|
if (client) {
|
||||||
|
client.send('queue_update', {
|
||||||
|
position: index + 1,
|
||||||
|
message: `You are #${index + 1} in the queue`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkExperimentStart() {
|
||||||
|
// Auto-start when room is full (this can be customized)
|
||||||
|
if (this.state.currentParticipants === this.state.maxParticipants && this.state.status === 'waiting') {
|
||||||
|
this.startExperiment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startExperiment() {
|
||||||
|
this.state.status = 'running';
|
||||||
|
this.state.startedAt = Date.now();
|
||||||
|
|
||||||
|
console.log(`[ExperimentRoom] Experiment started for ${this.state.experimentId}`);
|
||||||
|
|
||||||
|
// Notify all participants
|
||||||
|
this.broadcast('experiment_started', {
|
||||||
|
message: 'Experiment is now starting',
|
||||||
|
participants: this.state.currentParticipants
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleParticipantReady(client: Client, message: any) {
|
||||||
|
const participant = this.state.participants.get(client.sessionId);
|
||||||
|
if (participant) {
|
||||||
|
participant.isReady = true;
|
||||||
|
|
||||||
|
// Check if all participants are ready
|
||||||
|
const allReady = Array.from(this.state.participants.values()).every(p => p.isReady);
|
||||||
|
|
||||||
|
if (allReady && this.state.status === 'waiting') {
|
||||||
|
this.startExperiment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleExperimentAction(client: Client, message: any) {
|
||||||
|
// Broadcast experiment actions to all participants
|
||||||
|
this.broadcast('experiment_action', {
|
||||||
|
participantId: client.sessionId,
|
||||||
|
action: message.action,
|
||||||
|
data: message.data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}, { except: client });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleExperimentState(client: Client, message: any) {
|
||||||
|
// Update experiment state
|
||||||
|
this.state.experimentState = JSON.stringify(message.state);
|
||||||
|
|
||||||
|
// Broadcast state update to all participants
|
||||||
|
this.broadcast('experiment_state_update', {
|
||||||
|
state: message.state,
|
||||||
|
updatedBy: client.sessionId,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}, { except: client });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDispose() {
|
||||||
|
console.log(`[ExperimentRoom] Room ${this.roomId} disposed`);
|
||||||
|
|
||||||
|
// Mark experiment as completed if it was running
|
||||||
|
if (this.state.status === 'running') {
|
||||||
|
this.state.status = 'completed';
|
||||||
|
this.state.completedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/lib/server/multiplayer/schemas/ExperimentRoomState.ts
Normal file
24
src/lib/server/multiplayer/schemas/ExperimentRoomState.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Schema, type, MapSchema } from '@colyseus/schema';
|
||||||
|
|
||||||
|
export class Participant extends Schema {
|
||||||
|
@type('string') id: string = '';
|
||||||
|
@type('string') sessionId: string = '';
|
||||||
|
@type('number') joinedAt: number = 0;
|
||||||
|
@type('boolean') isReady: boolean = false;
|
||||||
|
@type('boolean') isConnected: boolean = true;
|
||||||
|
@type('string') userAgent?: string = '';
|
||||||
|
@type('string') ipAddress?: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExperimentRoomState extends Schema {
|
||||||
|
@type('string') experimentId: string = '';
|
||||||
|
@type('string') status: 'waiting' | 'running' | 'completed' = 'waiting';
|
||||||
|
@type('number') maxParticipants: number = 1;
|
||||||
|
@type('number') currentParticipants: number = 0;
|
||||||
|
@type('number') createdAt: number = 0;
|
||||||
|
@type('number') startedAt?: number = 0;
|
||||||
|
@type('number') completedAt?: number = 0;
|
||||||
|
@type({ map: Participant }) participants = new MapSchema<Participant>();
|
||||||
|
@type(['string']) queue: string[] = [];
|
||||||
|
@type('string') experimentState?: string = ''; // JSON string of current experiment state
|
||||||
|
}
|
||||||
110
src/lib/server/multiplayer/sessionManager.ts
Normal file
110
src/lib/server/multiplayer/sessionManager.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import puppeteer, { Browser, Page } from 'puppeteer-core';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
page: Page;
|
||||||
|
browser: Browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionManager extends EventEmitter {
|
||||||
|
private sessions: Map<string, Session> = new Map();
|
||||||
|
private screenshotIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.cleanup = this.cleanup.bind(this);
|
||||||
|
process.on('exit', this.cleanup);
|
||||||
|
process.on('SIGINT', this.cleanup);
|
||||||
|
process.on('SIGTERM', this.cleanup);
|
||||||
|
process.on('uncaughtException', this.cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(experimentId: string): Promise<void> {
|
||||||
|
if (this.sessions.has(experimentId)) {
|
||||||
|
console.log(`Session already exists for experiment ${experimentId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating session for experiment ${experimentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browser = await puppeteer.connect({
|
||||||
|
browserWSEndpoint: `ws://${env.CHROME_HOST}:${env.CHROME_PORT}`
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Set a large viewport size to capture the whole experiment.
|
||||||
|
// This might need to be adjusted based on the experiment's design.
|
||||||
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
const experimentUrl = `http://localhost:5173/public/run/${experimentId}/`;
|
||||||
|
await page.goto(experimentUrl, { waitUntil: 'networkidle0' });
|
||||||
|
|
||||||
|
this.sessions.set(experimentId, { page, browser });
|
||||||
|
|
||||||
|
// Start taking screenshots every 500ms
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (!this.sessions.has(experimentId)) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const screenshot = await page.screenshot({ encoding: 'base64' });
|
||||||
|
this.emit(`screenshot:${experimentId}`, screenshot);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error taking screenshot for experiment ${experimentId}:`, error);
|
||||||
|
await this.cleanupSession(experimentId);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.screenshotIntervals.set(experimentId, interval);
|
||||||
|
|
||||||
|
page.on('close', () => {
|
||||||
|
console.log(`Page closed for experiment ${experimentId}`);
|
||||||
|
this.cleanupSession(experimentId);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating session for experiment ${experimentId}:`, error);
|
||||||
|
await this.cleanupSession(experimentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(experimentId: string): Session | undefined {
|
||||||
|
return this.sessions.get(experimentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupSession(experimentId: string): Promise<void> {
|
||||||
|
console.log(`Cleaning up session for experiment ${experimentId}`);
|
||||||
|
|
||||||
|
const interval = this.screenshotIntervals.get(experimentId);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
this.screenshotIntervals.delete(experimentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessions.get(experimentId);
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
if (!session.page.isClosed()) {
|
||||||
|
await session.page.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error closing page for experiment ${experimentId}:`, error);
|
||||||
|
}
|
||||||
|
this.sessions.delete(experimentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
console.log('Cleaning up all sessions...');
|
||||||
|
const allSessions = Array.from(this.sessions.keys());
|
||||||
|
for (const experimentId of allSessions) {
|
||||||
|
await this.cleanupSession(experimentId);
|
||||||
|
}
|
||||||
|
console.log('All sessions cleaned up.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionManager = new SessionManager();
|
||||||
32
src/lib/server/multiplayer/standalone.ts
Normal file
32
src/lib/server/multiplayer/standalone.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { startMultiplayerServer } from './colyseusServer.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
|
||||||
|
|
||||||
|
console.log('[Multiplayer] Starting standalone Colyseus server...');
|
||||||
|
|
||||||
|
// Start the multiplayer server
|
||||||
|
startMultiplayerServer(PORT)
|
||||||
|
.then((server) => {
|
||||||
|
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
|
||||||
|
console.log(`[Multiplayer] WebSocket endpoint: ws://localhost:${PORT}`);
|
||||||
|
console.log(`[Multiplayer] Ready to accept connections`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[Multiplayer] Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -2,8 +2,12 @@ import { S3Client } from '@aws-sdk/client-s3';
|
|||||||
import {
|
import {
|
||||||
S3_ENDPOINT,
|
S3_ENDPOINT,
|
||||||
S3_ACCESS_KEY,
|
S3_ACCESS_KEY,
|
||||||
S3_SECRET_KEY
|
S3_SECRET_KEY,
|
||||||
|
S3_BUCKET
|
||||||
} from '$env/static/private';
|
} from '$env/static/private';
|
||||||
|
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||||
|
import type { ListObjectsV2CommandOutput } from '@aws-sdk/client-s3';
|
||||||
|
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: 'us-east-1', // MinIO ignores region but AWS SDK requires it
|
region: 'us-east-1', // MinIO ignores region but AWS SDK requires it
|
||||||
@@ -15,4 +19,24 @@ const s3 = new S3Client({
|
|||||||
forcePathStyle: true // needed for MinIO
|
forcePathStyle: true // needed for MinIO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total storage used by a user (in bytes) by summing all S3 objects under their userId prefix.
|
||||||
|
*/
|
||||||
|
export async function getUserStorageUsage(userId: string): Promise<number> {
|
||||||
|
const BUCKET = S3_BUCKET!;
|
||||||
|
try {
|
||||||
|
const result = await s3.send(new HeadObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: userId,
|
||||||
|
}));
|
||||||
|
return result.ContentLength || 0;
|
||||||
|
} catch (err: any) {
|
||||||
|
// If the file does not exist, return 0
|
||||||
|
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default s3;
|
export default s3;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { Home, FlaskConical } from '@lucide/svelte';
|
import { Home, FlaskConical, Cog } from '@lucide/svelte';
|
||||||
import { PUBLIC_APP_NAME } from '$env/static/public';
|
import { PUBLIC_APP_NAME } from '$env/static/public';
|
||||||
|
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -17,8 +18,31 @@
|
|||||||
<span class="font-bold">{PUBLIC_APP_NAME || 'Cog Socket App'}</span>
|
<span class="font-bold">{PUBLIC_APP_NAME || 'Cog Socket App'}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex flex-1 items-center justify-end space-x-2">
|
<div class="flex flex-1 items-center justify-end space-x-2">
|
||||||
<nav class="flex items-center">
|
<nav class="flex items-center gap-2">
|
||||||
<!-- Top bar right side elements, e.g., user menu, settings -->
|
<!-- Top bar right side elements, e.g., user menu, settings -->
|
||||||
|
<NavigationMenu.Root viewport={false}>
|
||||||
|
<NavigationMenu.List>
|
||||||
|
<NavigationMenu.Item>
|
||||||
|
<NavigationMenu.Trigger>
|
||||||
|
<Cog class="size-5" />
|
||||||
|
<span class="sr-only">Settings</span>
|
||||||
|
</NavigationMenu.Trigger>
|
||||||
|
<NavigationMenu.Content class="right-0 left-auto">
|
||||||
|
<ul class="grid w-[200px] gap-2 p-2">
|
||||||
|
<li>
|
||||||
|
<NavigationMenu.Link href="/settings/profile">Profile</NavigationMenu.Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavigationMenu.Link href="/settings">Settings</NavigationMenu.Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavigationMenu.Link href="/settings/subscription">Subscription</NavigationMenu.Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</NavigationMenu.Content>
|
||||||
|
</NavigationMenu.Item>
|
||||||
|
</NavigationMenu.List>
|
||||||
|
</NavigationMenu.Root>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ async function handleSubmit() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Multiplayer</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>Created At</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -80,6 +82,8 @@ async function handleSubmit() {
|
|||||||
<a href={`/experiment/${exp.id}`} class="text-primary underline hover:text-primary/80">{exp.name}</a>
|
<a href={`/experiment/${exp.id}`} class="text-primary underline hover:text-primary/80">{exp.name}</a>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{exp.description}</TableCell>
|
<TableCell>{exp.description}</TableCell>
|
||||||
|
<TableCell>{exp.type}</TableCell>
|
||||||
|
<TableCell>{exp.multiplayer ? 'Yes' : 'No'}</TableCell>
|
||||||
<TableCell>{new Date(exp.createdAt).toLocaleString()}</TableCell>
|
<TableCell>{new Date(exp.createdAt).toLocaleString()}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ const s3 = new S3Client({
|
|||||||
});
|
});
|
||||||
const BUCKET = S3_BUCKET!;
|
const BUCKET = S3_BUCKET!;
|
||||||
|
|
||||||
export async function GET({ params }) {
|
export async function GET({ params, locals, url }) {
|
||||||
|
if (!locals.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const prefix = `experiments/${id}/`;
|
const userId = locals.user.id;
|
||||||
|
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||||
|
const prefix = `${userId}/${id}/${type}/`;
|
||||||
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }));
|
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }));
|
||||||
const files = (result.Contents || []).map(obj => ({
|
const files = (result.Contents || []).map(obj => ({
|
||||||
key: obj.Key,
|
key: obj.Key,
|
||||||
@@ -27,15 +32,18 @@ export async function GET({ params }) {
|
|||||||
return json({ files });
|
return json({ files });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST({ params, request }) {
|
export async function POST({ params, request, locals, url }) {
|
||||||
console.log(params);
|
if (!locals.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const userId = locals.user.id;
|
||||||
|
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const file = data.get('file');
|
const file = data.get('file');
|
||||||
const relativePath = data.get('relativePath');
|
const relativePath = data.get('relativePath');
|
||||||
if (!file || typeof file === 'string') throw error(400, 'No file uploaded');
|
if (!file || typeof file === 'string') throw error(400, 'No file uploaded');
|
||||||
const key = `experiments/${id}/${relativePath}`;
|
const key = `${userId}/${id}/${type}/${relativePath}`;
|
||||||
console.log(key);
|
|
||||||
await s3.send(new PutObjectCommand({
|
await s3.send(new PutObjectCommand({
|
||||||
Bucket: BUCKET,
|
Bucket: BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
@@ -45,13 +53,18 @@ export async function POST({ params, request }) {
|
|||||||
return json({ success: true, key });
|
return json({ success: true, key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE({ params, url }) {
|
export async function DELETE({ params, url, locals }) {
|
||||||
|
if (!locals.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const userId = locals.user.id;
|
||||||
|
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||||
const key = url.searchParams.get('key');
|
const key = url.searchParams.get('key');
|
||||||
const prefix = url.searchParams.get('prefix');
|
const prefix = url.searchParams.get('prefix');
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
// Delete all objects with this prefix
|
// Delete all objects with this prefix
|
||||||
const fullPrefix = `experiments/${id}/${prefix}`;
|
const fullPrefix = `${userId}/${id}/${type}/${prefix}`;
|
||||||
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: fullPrefix }));
|
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: fullPrefix }));
|
||||||
const objects = (result.Contents || []).map(obj => ({ Key: obj.Key }));
|
const objects = (result.Contents || []).map(obj => ({ Key: obj.Key }));
|
||||||
if (objects.length > 0) {
|
if (objects.length > 0) {
|
||||||
@@ -65,6 +78,7 @@ export async function DELETE({ params, url }) {
|
|||||||
return json({ success: true, deleted: objects.length });
|
return json({ success: true, deleted: objects.length });
|
||||||
}
|
}
|
||||||
if (!key) throw error(400, 'Missing key or prefix');
|
if (!key) throw error(400, 'Missing key or prefix');
|
||||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
|
const fullKey = `${userId}/${id}/${type}/${key}`;
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: fullKey }));
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
}
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/routes/api/experiments/[experimentId]/+server.ts
Normal file
32
src/routes/api/experiments/[experimentId]/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import * as schema from '$lib/server/db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const { experimentId } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const experiment = await db.query.experiment.findFirst({
|
||||||
|
where: eq(schema.experiment.id, experimentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
throw error(404, 'Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: experiment.id,
|
||||||
|
name: experiment.name,
|
||||||
|
description: experiment.description,
|
||||||
|
multiplayer: experiment.multiplayer,
|
||||||
|
maxParticipants: experiment.maxParticipants,
|
||||||
|
type: experiment.type,
|
||||||
|
createdAt: experiment.createdAt
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching experiment:', err);
|
||||||
|
throw error(500, 'Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/routes/api/user-storage.ts
Normal file
10
src/routes/api/user-storage.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { getUserStorageUsage } from '$lib/server/s3';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const usage = await getUserStorageUsage(locals.user.id);
|
||||||
|
return json({ usage });
|
||||||
|
};
|
||||||
@@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import FileBrowser from './FileBrowser.svelte';
|
import FileManager from './FileManager.svelte';
|
||||||
|
import ExperimentSessions from './ExperimentSessions.svelte';
|
||||||
|
|
||||||
let experimentId = '';
|
let experimentId = '';
|
||||||
let experiment: any = null;
|
let experiment: any = null;
|
||||||
@@ -15,234 +16,117 @@ let name = '';
|
|||||||
let description = '';
|
let description = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let success = '';
|
let success = '';
|
||||||
let files: any[] = [];
|
|
||||||
let uploading = false;
|
|
||||||
let uploadError = '';
|
|
||||||
let copied = false;
|
let copied = false;
|
||||||
let origin = '';
|
let origin = '';
|
||||||
|
let activeTab = 'info';
|
||||||
|
|
||||||
async function fetchFiles() {
|
$: publicLink = experiment
|
||||||
if (!experimentId) return;
|
? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}`
|
||||||
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) {
|
|
||||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}`;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
const link = `${origin}/public/run/${experiment.id}`;
|
navigator.clipboard.writeText(publicLink);
|
||||||
navigator.clipboard.writeText(link);
|
copied = true;
|
||||||
copied = true;
|
setTimeout(() => (copied = false), 2000);
|
||||||
setTimeout(() => (copied = false), 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFileOrFolder(key: string, isFolder: boolean) {
|
|
||||||
console.log('deleteFileOrFolder called:', { key, isFolder, experimentId });
|
|
||||||
// If folder, send a delete request with a prefix param
|
|
||||||
if (isFolder) {
|
|
||||||
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(key)}`;
|
|
||||||
console.log('Deleting folder with URL:', url);
|
|
||||||
const res = await fetch(url, { method: 'DELETE' });
|
|
||||||
console.log('Folder delete response:', res.status, res.ok);
|
|
||||||
if (res.ok) await fetchFiles();
|
|
||||||
} else {
|
|
||||||
console.log('Deleting file with key:', key);
|
|
||||||
await deleteFile(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
origin = window.location.origin;
|
origin = window.location.origin;
|
||||||
const params = get(page).params;
|
const params = get(page).params;
|
||||||
experimentId = params.id;
|
experimentId = params.id;
|
||||||
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
experiment = data.experiment;
|
experiment = data.experiment;
|
||||||
console.log(experiment)
|
console.log(experiment)
|
||||||
name = experiment.name;
|
name = experiment.name;
|
||||||
description = experiment.description;
|
description = experiment.description;
|
||||||
} else {
|
} else {
|
||||||
error = 'Failed to load experiment.';
|
error = 'Failed to load experiment.';
|
||||||
}
|
}
|
||||||
await fetchFiles();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
error = '';
|
error = '';
|
||||||
success = '';
|
success = '';
|
||||||
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, description })
|
body: JSON.stringify({ name, description })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
success = 'Experiment updated!';
|
success = 'Experiment updated!';
|
||||||
} else {
|
} else {
|
||||||
error = 'Failed to update experiment.';
|
error = 'Failed to update experiment.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if experiment}
|
{#if experiment}
|
||||||
<div class="flex flex-col min-h-screen bg-background py-8">
|
<div class="flex flex-col min-h-screen bg-background py-8">
|
||||||
<Card class="mb-8 w-full max-w-xl mx-auto">
|
<div class="w-full max-w-xl mx-auto mb-8">
|
||||||
<CardHeader>
|
<div class="flex border-b mb-4">
|
||||||
<CardTitle>Experiment Details</CardTitle>
|
<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>
|
||||||
</CardHeader>
|
<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>
|
||||||
<CardContent>
|
<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 class="mb-4">
|
</div>
|
||||||
<Label>ID</Label>
|
{#if activeTab === 'info'}
|
||||||
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
|
<Card class="w-full">
|
||||||
</div>
|
<CardHeader>
|
||||||
<form on:submit|preventDefault={handleSave} class="space-y-4">
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
<div>
|
</CardHeader>
|
||||||
<Label for="name">Name</Label>
|
<CardContent>
|
||||||
<Input id="name" type="text" bind:value={name} required />
|
<div class="mb-4">
|
||||||
</div>
|
<Label>ID</Label>
|
||||||
<div>
|
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
|
||||||
<Label for="description">Description</Label>
|
</div>
|
||||||
<Input id="description" type="text" bind:value={description} />
|
<div class="mb-4">
|
||||||
</div>
|
<Label>Type</Label>
|
||||||
<div>
|
<div class="text-sm text-muted-foreground mb-2">{experiment.type}</div>
|
||||||
<Label>Created At</Label>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
|
<div class="mb-4">
|
||||||
</div>
|
<Label>Multiplayer</Label>
|
||||||
{#if error}
|
<div class="text-sm text-muted-foreground mb-2">{experiment.multiplayer ? 'Yes' : 'No'}</div>
|
||||||
<div class="text-red-500 text-sm">{error}</div>
|
</div>
|
||||||
{/if}
|
<form on:submit|preventDefault={handleSave} class="space-y-4">
|
||||||
{#if success}
|
<div>
|
||||||
<div class="text-green-600 text-sm">{success}</div>
|
<Label for="name">Name</Label>
|
||||||
{/if}
|
<Input id="name" type="text" bind:value={name} required />
|
||||||
<Button type="submit">Save Changes</Button>
|
</div>
|
||||||
</form>
|
<div>
|
||||||
</CardContent>
|
<Label for="description">Description</Label>
|
||||||
</Card>
|
<Input id="description" type="text" bind:value={description} />
|
||||||
|
</div>
|
||||||
<Card class="mb-8 w-full max-w-xl mx-auto">
|
<div>
|
||||||
<CardHeader>
|
<Label>Created At</Label>
|
||||||
<CardTitle>Public Link</CardTitle>
|
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
{#if error}
|
||||||
<p class="text-sm text-muted-foreground mb-2">
|
<div class="text-red-500 text-sm">{error}</div>
|
||||||
Share this link with your participants to run the experiment.
|
{/if}
|
||||||
</p>
|
{#if success}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="text-green-600 text-sm">{success}</div>
|
||||||
<Input id="public-link" type="text" readonly value="{`${origin}/public/run/${experiment.id}`}" />
|
{/if}
|
||||||
<Button on:click={copyLink}>Copy</Button>
|
<Button type="submit">Save Changes</Button>
|
||||||
</div>
|
</form>
|
||||||
{#if copied}
|
<div class="mt-8">
|
||||||
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
|
<Label>Public Link</Label>
|
||||||
{/if}
|
<div class="flex items-center space-x-2 mt-2">
|
||||||
</CardContent>
|
<Input id="public-link" type="text" readonly value={publicLink} />
|
||||||
</Card>
|
<Button on:click={copyLink}>Copy</Button>
|
||||||
|
</div>
|
||||||
<Card class="w-full max-w-xl mx-auto">
|
{#if copied}
|
||||||
<CardHeader>
|
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
|
||||||
<CardTitle>Experiment Files</CardTitle>
|
{/if}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div class="mt-8">
|
</Card>
|
||||||
<Label>Experiment Files</Label>
|
{:else if activeTab === 'files'}
|
||||||
<div
|
<FileManager {experimentId} />
|
||||||
class="border-2 border-dashed rounded-md p-4 mb-4 text-center cursor-pointer bg-muted hover:bg-accent transition"
|
{:else if activeTab === 'sessions'}
|
||||||
on:click={() => document.getElementById('file-input')?.click()}
|
<ExperimentSessions {experimentId} />
|
||||||
>
|
{/if}
|
||||||
<div>Click to select files or a folder to upload (folder structure will be preserved)</div>
|
</div>
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
{#if files.length === 0}
|
|
||||||
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="flex justify-center items-center min-h-screen bg-background">
|
<div class="flex justify-center items-center min-h-screen bg-background">
|
||||||
|
|||||||
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>
|
||||||
@@ -1,45 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import FileIcon from '@lucide/svelte/icons/file';
|
||||||
|
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||||
|
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
|
||||||
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
|
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
export let tree: any;
|
export let tree: any;
|
||||||
export let parentPath = '';
|
export let parentPath = '';
|
||||||
export let expanded: Set<string>;
|
export let expanded: Set<string>;
|
||||||
export let onToggle: (path: string) => void;
|
export let onToggle: (path: string) => void;
|
||||||
export let onDelete: (key: string, isFolder: boolean) => void;
|
export let onDelete: (key: string, isFolder: boolean) => void;
|
||||||
|
export let onDownload: (key: string) => void = () => {};
|
||||||
|
export let isRoot: boolean = false;
|
||||||
|
|
||||||
function handleToggle(path: string) {
|
function handleToggle(path: string) {
|
||||||
onToggle(path);
|
onToggle(path);
|
||||||
}
|
}
|
||||||
function handleDelete(key: string, isFolder: boolean) {
|
function handleDelete(key: string, isFolder: boolean) {
|
||||||
console.log('FileBrowser handleDelete called:', { key, isFolder });
|
|
||||||
onDelete(key, isFolder);
|
onDelete(key, isFolder);
|
||||||
}
|
}
|
||||||
|
function handleDownload(key: string) {
|
||||||
|
onDownload(key);
|
||||||
|
}
|
||||||
function handleFolderClick(event: Event, path: string) {
|
function handleFolderClick(event: Event, path: string) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleToggle(path);
|
handleToggle(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderDelete(event: Event, path: string) {
|
function handleFolderDelete(event: Event, path: string) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
console.log('FileBrowser handleFolderDelete called:', { path });
|
|
||||||
handleDelete(path, true);
|
handleDelete(path, true);
|
||||||
}
|
}
|
||||||
|
function formatSize(size: number) {
|
||||||
|
if (!size && size !== 0) return '';
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
function formatDate(date: string | Date) {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul class="ml-2">
|
<ul class="ml-2">
|
||||||
|
{#if isRoot}
|
||||||
|
<li class="flex items-center gap-2 py-1 pl-2 text-xs font-semibold text-muted-foreground select-none border-b border-border mb-1">
|
||||||
|
<span class="flex-1 min-w-0">Name</span>
|
||||||
|
<span class="w-24 text-right">Size</span>
|
||||||
|
<span class="w-40 text-right">Last Uploaded</span>
|
||||||
|
<span class="w-16"></span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{#each Object.entries(tree) as [name, node] (parentPath + '/' + name)}
|
{#each Object.entries(tree) as [name, node] (parentPath + '/' + name)}
|
||||||
{#if (node as any).isFile}
|
{#if (node as any).isFile}
|
||||||
<li class="flex items-center justify-between py-1 pl-4">
|
<li class="flex items-center gap-2 py-1 pl-2 hover:bg-accent rounded group text-sm">
|
||||||
<span>{name} <span class="text-xs text-muted-foreground">({(node as any).size} bytes)</span></span>
|
<span class="flex-1 min-w-0 flex items-center gap-1">
|
||||||
<Button size="sm" variant="destructive" on:click={() => handleDelete((node as any).key, false)}>Delete</Button>
|
<FileIcon class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<span class="truncate" title={name || ''}>{name || ''}</span>
|
||||||
|
</span>
|
||||||
|
<span class="w-24 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatSize((node as any).size)}</span>
|
||||||
|
<span class="w-40 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatDate((node as any).lastModified)}</span>
|
||||||
|
<span class="w-16 flex items-center justify-end gap-1">
|
||||||
|
<button class="p-1 hover:text-primary" title="Download" on:click={() => handleDownload((node as any).key)}><DownloadIcon class="w-4 h-4" /></button>
|
||||||
|
<button class="p-1 hover:text-destructive" title="Delete" on:click={() => handleDelete((node as any).key, false)}><TrashIcon class="w-4 h-4" /></button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li>
|
<li>
|
||||||
<div class="flex items-center cursor-pointer" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
|
<div class="flex items-center gap-1 px-1 py-1 hover:bg-accent rounded cursor-pointer text-sm" >
|
||||||
<span class="mr-1">{expanded.has(parentPath ? `${parentPath}/${name}` : name) ? '▼' : '▶'}</span>
|
<span class="flex-1 min-w-0 flex items-center gap-1" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
|
||||||
<span class="font-semibold">{name}</span>
|
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
|
||||||
<Button size="sm" variant="destructive" class="ml-2" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}>Delete</Button>
|
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
|
||||||
|
<FolderOpenIcon class="w-4 h-4 text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<FolderIcon class="w-4 h-4 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold truncate" title={name || ''}>{name || ''}</span>
|
||||||
|
</span>
|
||||||
|
<span class="w-24"></span>
|
||||||
|
<span class="w-40"></span>
|
||||||
|
<span class="w-16 flex items-center justify-end">
|
||||||
|
<button class="p-1 hover:text-destructive" title="Delete folder" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}><TrashIcon class="w-4 h-4" /></button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
|
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
|
||||||
<svelte:self
|
<svelte:self
|
||||||
@@ -48,6 +94,8 @@
|
|||||||
{expanded}
|
{expanded}
|
||||||
{onToggle}
|
{onToggle}
|
||||||
{onDelete}
|
{onDelete}
|
||||||
|
{onDownload}
|
||||||
|
isRoot={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
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>
|
||||||
19
src/routes/public/multiplayer/+layout.svelte
Normal file
19
src/routes/public/multiplayer/+layout.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- This layout bypasses the main app layout for multiplayer experiments -->
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure multiplayer experiments are completely full-screen */
|
||||||
|
:global(html), :global(body) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset any inherited styles */
|
||||||
|
:global(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
652
src/routes/public/multiplayer/run/[experimentId]/+server.ts
Normal file
652
src/routes/public/multiplayer/run/[experimentId]/+server.ts
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
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 } = params;
|
||||||
|
|
||||||
|
// Check if the experiment exists
|
||||||
|
const experiment = await db.query.experiment.findFirst({
|
||||||
|
where: eq(schema.experiment.id, experimentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
throw error(404, 'Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multiplayer, we always serve index.html with WebSocket integration
|
||||||
|
const path = 'index.html';
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files are stored in the user's experiment_files directory
|
||||||
|
const createdBy = experiment.createdBy;
|
||||||
|
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||||
|
const key = `${s3Prefix}${path}`;
|
||||||
|
|
||||||
|
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 body = await stream.transformToString();
|
||||||
|
|
||||||
|
// Inject multiplayer WebSocket integration into the HTML
|
||||||
|
const multiplayerScript = `
|
||||||
|
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||||
|
<script>
|
||||||
|
// Multiplayer Colyseus Integration
|
||||||
|
let client = null;
|
||||||
|
let room = null;
|
||||||
|
let isConnected = false;
|
||||||
|
let overlayVisible = true;
|
||||||
|
let sessionStartTime = new Date();
|
||||||
|
let isLoading = true;
|
||||||
|
let participantId = null;
|
||||||
|
let roomStatus = 'connecting';
|
||||||
|
let queuePosition = 0;
|
||||||
|
let currentParticipants = 0;
|
||||||
|
let maxParticipants = 1;
|
||||||
|
|
||||||
|
// Connect to Colyseus server
|
||||||
|
async function connectMultiplayer() {
|
||||||
|
try {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const host = location.hostname;
|
||||||
|
const port = '8080'; // Colyseus server port
|
||||||
|
|
||||||
|
client = new Colyseus.Client(\`\${protocol}://\${host}:\${port}\`);
|
||||||
|
|
||||||
|
// Join or create room for this experiment
|
||||||
|
room = await client.joinOrCreate('experiment_room', {
|
||||||
|
experimentId: '${experimentId}',
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
ipAddress: null // Will be set by server
|
||||||
|
});
|
||||||
|
|
||||||
|
isConnected = true;
|
||||||
|
roomStatus = 'connected';
|
||||||
|
updateOverlay();
|
||||||
|
|
||||||
|
console.log('Connected to Colyseus room:', room.roomId);
|
||||||
|
|
||||||
|
// Handle room state changes
|
||||||
|
room.onStateChange((state) => {
|
||||||
|
console.log('Room state updated:', state);
|
||||||
|
handleStateUpdate(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle messages from server
|
||||||
|
room.onMessage('queued', (message) => {
|
||||||
|
handleQueued(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('admitted', (message) => {
|
||||||
|
handleAdmitted(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('joined', (message) => {
|
||||||
|
handleJoined(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('queue_update', (message) => {
|
||||||
|
handleQueueUpdate(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('experiment_started', (message) => {
|
||||||
|
handleExperimentStarted(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('experiment_action', (message) => {
|
||||||
|
handleExperimentAction(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('experiment_state_update', (message) => {
|
||||||
|
handleExperimentStateUpdate(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onError((code, message) => {
|
||||||
|
console.error('Room error:', code, message);
|
||||||
|
isConnected = false;
|
||||||
|
roomStatus = 'error';
|
||||||
|
updateOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onLeave((code) => {
|
||||||
|
console.log('Left room with code:', code);
|
||||||
|
isConnected = false;
|
||||||
|
roomStatus = 'disconnected';
|
||||||
|
updateOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to multiplayer server:', error);
|
||||||
|
isConnected = false;
|
||||||
|
roomStatus = 'error';
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStateUpdate(state) {
|
||||||
|
currentParticipants = state.currentParticipants;
|
||||||
|
maxParticipants = state.maxParticipants;
|
||||||
|
|
||||||
|
// Update participant info
|
||||||
|
if (state.participants && participantId) {
|
||||||
|
const participant = state.participants.get(participantId);
|
||||||
|
if (participant) {
|
||||||
|
console.log('Participant updated:', participant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueued(message) {
|
||||||
|
roomStatus = 'queued';
|
||||||
|
queuePosition = message.position;
|
||||||
|
console.log('Added to queue:', message);
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdmitted(message) {
|
||||||
|
roomStatus = 'admitted';
|
||||||
|
participantId = message.participantId;
|
||||||
|
currentParticipants = message.currentParticipants;
|
||||||
|
maxParticipants = message.maxParticipants;
|
||||||
|
console.log('Admitted to experiment:', message);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
isLoading = false;
|
||||||
|
hideLoadingScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJoined(message) {
|
||||||
|
roomStatus = 'joined';
|
||||||
|
participantId = message.participantId;
|
||||||
|
currentParticipants = message.currentParticipants;
|
||||||
|
maxParticipants = message.maxParticipants;
|
||||||
|
console.log('Joined experiment:', message);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
isLoading = false;
|
||||||
|
hideLoadingScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueueUpdate(message) {
|
||||||
|
queuePosition = message.position;
|
||||||
|
console.log('Queue position updated:', message);
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExperimentStarted(message) {
|
||||||
|
roomStatus = 'running';
|
||||||
|
console.log('Experiment started:', message);
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExperimentAction(message) {
|
||||||
|
// Handle actions from other participants
|
||||||
|
console.log('Experiment action from participant:', message);
|
||||||
|
// This would be where you'd handle specific experiment interactions
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExperimentStateUpdate(message) {
|
||||||
|
// Handle experiment state updates
|
||||||
|
console.log('Experiment state updated:', message);
|
||||||
|
// This would be where you'd sync experiment state across participants
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoadingScreen() {
|
||||||
|
const loadingScreen = document.getElementById('loading-screen');
|
||||||
|
if (loadingScreen) {
|
||||||
|
loadingScreen.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlay() {
|
||||||
|
const statusDot = document.getElementById('status-dot');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const participantCount = document.getElementById('participant-count');
|
||||||
|
const queueInfo = document.getElementById('queue-info');
|
||||||
|
|
||||||
|
if (statusDot) {
|
||||||
|
statusDot.className = 'status-dot ' + (isConnected ? 'connected' : 'disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) {
|
||||||
|
let statusMessage = 'Disconnected';
|
||||||
|
if (isConnected) {
|
||||||
|
switch (roomStatus) {
|
||||||
|
case 'connected':
|
||||||
|
statusMessage = 'Connected';
|
||||||
|
break;
|
||||||
|
case 'queued':
|
||||||
|
statusMessage = \`Queue #\${queuePosition}\`;
|
||||||
|
break;
|
||||||
|
case 'admitted':
|
||||||
|
case 'joined':
|
||||||
|
statusMessage = 'In Session';
|
||||||
|
break;
|
||||||
|
case 'running':
|
||||||
|
statusMessage = 'Running';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusMessage = 'Connected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusText.textContent = statusMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participantCount) {
|
||||||
|
participantCount.textContent = \`\${currentParticipants}/\${maxParticipants}\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queueInfo) {
|
||||||
|
if (roomStatus === 'queued') {
|
||||||
|
queueInfo.textContent = \`Position \${queuePosition} in queue\`;
|
||||||
|
queueInfo.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
queueInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOverlay() {
|
||||||
|
const overlay = document.getElementById('multiplayer-overlay');
|
||||||
|
const minimized = document.getElementById('minimized-overlay');
|
||||||
|
|
||||||
|
if (overlay && minimized) {
|
||||||
|
overlayVisible = !overlayVisible;
|
||||||
|
overlay.style.display = overlayVisible ? 'block' : 'none';
|
||||||
|
minimized.style.display = overlayVisible ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send experiment action to other participants
|
||||||
|
function sendExperimentAction(action, data) {
|
||||||
|
if (room && roomStatus === 'running') {
|
||||||
|
room.send('experiment_action', { action, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send experiment state update
|
||||||
|
function sendExperimentState(state) {
|
||||||
|
if (room && roomStatus === 'running') {
|
||||||
|
room.send('experiment_state', { state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark participant as ready
|
||||||
|
function markReady() {
|
||||||
|
if (room && participantId) {
|
||||||
|
room.send('ready', { participantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle user interactions
|
||||||
|
function handleInteraction(event) {
|
||||||
|
if (event instanceof KeyboardEvent && event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, just log interactions - experiment-specific handling would go here
|
||||||
|
if (roomStatus === 'running') {
|
||||||
|
console.log('User interaction:', event.type, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize multiplayer when DOM is ready
|
||||||
|
function initMultiplayer() {
|
||||||
|
// Add event listeners
|
||||||
|
document.addEventListener('keydown', handleInteraction, true);
|
||||||
|
|
||||||
|
// Connect to Colyseus
|
||||||
|
connectMultiplayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start multiplayer when page loads
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initMultiplayer);
|
||||||
|
} else {
|
||||||
|
initMultiplayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions available globally for experiment use
|
||||||
|
window.multiplayerAPI = {
|
||||||
|
sendAction: sendExperimentAction,
|
||||||
|
sendState: sendExperimentState,
|
||||||
|
markReady: markReady,
|
||||||
|
getStatus: () => ({ roomStatus, participantId, currentParticipants, maxParticipants })
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const multiplayerCSS = `
|
||||||
|
<style>
|
||||||
|
/* Multiplayer overlay styles */
|
||||||
|
#multiplayer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minimized-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-separator {
|
||||||
|
margin: 8px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top: 3px solid #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtext {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure overlay elements don't interfere with experiment */
|
||||||
|
#multiplayer-overlay,
|
||||||
|
#minimized-overlay {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#multiplayer-overlay *,
|
||||||
|
#minimized-overlay * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full screen styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const multiplayerOverlay = `
|
||||||
|
<!-- Loading screen -->
|
||||||
|
<div id="loading-screen">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text">Loading experiment...</div>
|
||||||
|
<div class="loading-subtext">Connecting to multiplayer session</div>
|
||||||
|
<div class="loading-hint">Your experiment will open in full screen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multiplayer overlay -->
|
||||||
|
<div id="multiplayer-overlay">
|
||||||
|
<div class="overlay-header">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<div id="status-dot" class="status-dot disconnected"></div>
|
||||||
|
<span id="status-text">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick="toggleOverlay()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="overlay-info">
|
||||||
|
<div>🎮 Multiplayer Session</div>
|
||||||
|
<div>👥 Participants: <span id="participant-count">0/1</span></div>
|
||||||
|
<div>🕐 Started: <span id="session-time"></span></div>
|
||||||
|
<div>📋 ID: ${experimentId.slice(0, 8)}...</div>
|
||||||
|
<div id="queue-info" style="display: none; color: #fbbf24; margin-top: 4px;">
|
||||||
|
Position in queue
|
||||||
|
</div>
|
||||||
|
<div class="overlay-separator">
|
||||||
|
<div style="color: rgba(255, 255, 255, 0.6);">Press Tab to toggle this overlay</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Minimized overlay -->
|
||||||
|
<div id="minimized-overlay" onclick="toggleOverlay()">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<div id="minimized-status-dot" class="status-dot disconnected"></div>
|
||||||
|
<span>MP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update session time
|
||||||
|
document.getElementById('session-time').textContent = new Date().toLocaleTimeString();
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Inject multiplayer functionality into the HTML
|
||||||
|
const injectedBody = body
|
||||||
|
.replace(/<head[^>]*>/, `$&<base href="/public/multiplayer/run/${experimentId}/">${multiplayerCSS}`)
|
||||||
|
.replace(/<\/body>/, `${multiplayerOverlay}${multiplayerScript}</body>`);
|
||||||
|
|
||||||
|
return new Response(injectedBody, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'NoSuchKey') {
|
||||||
|
throw error(404, 'Not found');
|
||||||
|
}
|
||||||
|
throw error(500, 'Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files are stored in the user's experiment_files directory
|
||||||
|
const createdBy = experiment.createdBy;
|
||||||
|
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||||
|
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: s3Prefix
|
||||||
|
});
|
||||||
|
const listResponse = await s3.send(listCommand);
|
||||||
|
|
||||||
|
// Create resource manifest for PsychoJS
|
||||||
|
const resources = (listResponse.Contents || [])
|
||||||
|
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
|
||||||
|
.map(obj => {
|
||||||
|
const relativePath = obj.Key!.replace(s3Prefix, '');
|
||||||
|
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 { dev } from '$app/environment';
|
||||||
import { promises as fs } from 'fs';
|
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
|
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||||
const vendorFileRules = [
|
const vendorFileRules = [
|
||||||
@@ -31,6 +31,15 @@ const vendorFileRules = [
|
|||||||
export async function GET({ params, cookies, getClientAddress, request }) {
|
export async function GET({ params, cookies, getClientAddress, request }) {
|
||||||
const { experimentId, path } = params;
|
const { experimentId, path } = params;
|
||||||
|
|
||||||
|
// Check if the experiment exists
|
||||||
|
const experiment = await db.query.experiment.findFirst({
|
||||||
|
where: eq(schema.experiment.id, experimentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
throw error(404, 'Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Map of requested CSS files to their location in the static directory.
|
// Map of requested CSS files to their location in the static directory.
|
||||||
const staticCssMap: Record<string, string> = {
|
const staticCssMap: Record<string, string> = {
|
||||||
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
|
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
|
||||||
@@ -99,22 +108,25 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`;
|
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||||
let participantSessionId = cookies.get(cookieName);
|
let experimentSessionId = cookies.get(cookieName);
|
||||||
|
|
||||||
if (!participantSessionId) {
|
if (!experimentSessionId) {
|
||||||
// First request for this experiment. Create a new participant session.
|
// First request for this experiment. Create a new participant session.
|
||||||
participantSessionId = randomUUID();
|
experimentSessionId = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
await db.insert(schema.participantSession).values({
|
await db.insert(schema.experimentSession).values({
|
||||||
id: participantSessionId,
|
id: experimentSessionId,
|
||||||
experimentId,
|
experimentId,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'in progress',
|
||||||
ipAddress: getClientAddress(),
|
ipAddress: getClientAddress(),
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined
|
userAgent: request.headers.get('user-agent') ?? undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
cookies.set(cookieName, participantSessionId, {
|
cookies.set(cookieName, experimentSessionId, {
|
||||||
path: `/public/run/${experimentId}`,
|
path: `/public/run/${experimentId}`,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !dev,
|
secure: !dev,
|
||||||
@@ -124,19 +136,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
// subsequent requests, check if cookie is valid
|
// subsequent requests, check if cookie is valid
|
||||||
const [session] = await db
|
const [session] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.participantSession)
|
.from(schema.experimentSession)
|
||||||
.where(eq(schema.participantSession.id, participantSessionId));
|
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||||
if (!session) {
|
if (!session) {
|
||||||
// invalid cookie, create new session
|
// invalid cookie, create new session
|
||||||
const newParticipantSessionId = randomUUID();
|
const newExperimentSessionId = randomUUID();
|
||||||
await db.insert(schema.participantSession).values({
|
const now = new Date();
|
||||||
id: newParticipantSessionId,
|
await db.insert(schema.experimentSession).values({
|
||||||
|
id: newExperimentSessionId,
|
||||||
experimentId,
|
experimentId,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'in progress',
|
||||||
ipAddress: getClientAddress(),
|
ipAddress: getClientAddress(),
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined
|
userAgent: request.headers.get('user-agent') ?? undefined
|
||||||
});
|
});
|
||||||
cookies.set(cookieName, newParticipantSessionId, {
|
cookies.set(cookieName, newExperimentSessionId, {
|
||||||
path: `/public/run/${experimentId}`,
|
path: `/public/run/${experimentId}`,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !dev,
|
secure: !dev,
|
||||||
@@ -145,7 +160,9 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3Prefix = `experiments/${experimentId}/`;
|
// Files are stored in the user's experiment_files directory
|
||||||
|
const createdBy = experiment.createdBy;
|
||||||
|
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||||
const filePath = path === '' ? 'index.html' : path;
|
const filePath = path === '' ? 'index.html' : path;
|
||||||
const key = `${s3Prefix}${filePath}`;
|
const key = `${s3Prefix}${filePath}`;
|
||||||
|
|
||||||
@@ -174,20 +191,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
|
|
||||||
// For PsychoJS experiments, we need to set the base href to the experiment root
|
// For PsychoJS experiments, we need to set the base href to the experiment root
|
||||||
// so that relative paths like "stimuli/image.jpg" resolve correctly
|
// so that relative paths like "stimuli/image.jpg" resolve correctly
|
||||||
const basePath = `/public/run/${experimentId}/`;
|
// Use the request URL to determine if this is multiplayer or single player
|
||||||
|
const isMultiplayer = request.url.includes('/multiplayer/');
|
||||||
|
const basePath = isMultiplayer ? `/public/multiplayer/run/${experimentId}/` : `/public/run/${experimentId}/`;
|
||||||
|
|
||||||
// Get all files in the experiment directory to create a resource manifest
|
// Get all files in the experiment directory to create a resource manifest
|
||||||
const listCommand = new ListObjectsV2Command({
|
const listCommand = new ListObjectsV2Command({
|
||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
Prefix: `experiments/${experimentId}/`
|
Prefix: s3Prefix
|
||||||
});
|
});
|
||||||
const listResponse = await s3.send(listCommand);
|
const listResponse = await s3.send(listCommand);
|
||||||
|
|
||||||
// Create resource manifest for PsychoJS
|
// Create resource manifest for PsychoJS
|
||||||
const resources = (listResponse.Contents || [])
|
const resources = (listResponse.Contents || [])
|
||||||
.filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`)
|
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
|
||||||
.map(obj => {
|
.map(obj => {
|
||||||
const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, '');
|
const relativePath = obj.Key!.replace(s3Prefix, '');
|
||||||
return {
|
return {
|
||||||
name: relativePath,
|
name: relativePath,
|
||||||
path: relativePath
|
path: relativePath
|
||||||
|
|||||||
10
src/routes/settings/+page.svelte
Normal file
10
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Dummy settings page
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Settings</h1>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 bg-muted rounded">Option 1: [dummy toggle]</div>
|
||||||
|
<div class="p-4 bg-muted rounded">Option 2: [dummy select]</div>
|
||||||
|
<div class="p-4 bg-muted rounded">More settings coming soon...</div>
|
||||||
|
</div>
|
||||||
98
src/routes/settings/profile/+page.svelte
Normal file
98
src/routes/settings/profile/+page.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
let email = '';
|
||||||
|
let currentPassword = '';
|
||||||
|
let newPassword = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
|
||||||
|
// Use $page as a reactive value
|
||||||
|
$: fullName = $page.data?.user?.username || '';
|
||||||
|
|
||||||
|
let storageUsed = 0;
|
||||||
|
const MAX_STORAGE = 1024 * 1024 * 1024; // 1GB in bytes
|
||||||
|
let loadingStorage = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
loadingStorage = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user-storage');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
storageUsed = data.usage;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingStorage = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmailChange(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Email change submitted (dummy)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasswordChange(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('Passwords do not match!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Password change submitted (dummy)');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Profile</h1>
|
||||||
|
|
||||||
|
<!-- Storage Usage Bar -->
|
||||||
|
<div class="mb-8 p-4 bg-muted rounded">
|
||||||
|
<div class="font-medium mb-2">Storage Usage</div>
|
||||||
|
{#if loadingStorage}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<div class="flex-1 h-4 bg-gray-200 rounded overflow-hidden">
|
||||||
|
<div class="h-4 bg-primary" style="width: {Math.min(100, (storageUsed / MAX_STORAGE) * 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground whitespace-nowrap">{formatBytes(storageUsed)} / 1 GB</div>
|
||||||
|
</div>
|
||||||
|
{#if storageUsed > MAX_STORAGE}
|
||||||
|
<div class="text-red-600 text-xs mt-1">You have exceeded your storage limit!</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 p-4 bg-muted rounded">
|
||||||
|
<div class="font-medium">Full Name</div>
|
||||||
|
<div class="text-lg">{fullName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4 mb-8" on:submit|preventDefault={handleEmailChange}>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1 font-medium" for="email">Email Address</label>
|
||||||
|
<input id="email" type="email" bind:value={email} class="border rounded px-3 py-2 w-full" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Email</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={handlePasswordChange}>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1 font-medium" for="currentPassword">Current Password</label>
|
||||||
|
<input id="currentPassword" type="password" bind:value={currentPassword} class="border rounded px-3 py-2 w-full" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1 font-medium" for="newPassword">New Password</label>
|
||||||
|
<input id="newPassword" type="password" bind:value={newPassword} class="border rounded px-3 py-2 w-full" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1 font-medium" for="confirmPassword">Confirm New Password</label>
|
||||||
|
<input id="confirmPassword" type="password" bind:value={confirmPassword} class="border rounded px-3 py-2 w-full" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Password</button>
|
||||||
|
</form>
|
||||||
10
src/routes/settings/subscription/+page.svelte
Normal file
10
src/routes/settings/subscription/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Dummy subscription page
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Subscription</h1>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 bg-muted rounded">Current Plan: [dummy plan]</div>
|
||||||
|
<div class="p-4 bg-muted rounded">Renewal Date: [dummy date]</div>
|
||||||
|
<div class="p-4 bg-muted rounded">Upgrade options coming soon...</div>
|
||||||
|
</div>
|
||||||
229
test-multiplayer.html
Normal file
229
test-multiplayer.html
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Multiplayer Test</title>
|
||||||
|
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.connected {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
.disconnected {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.queued {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 5px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Multiplayer Test</h1>
|
||||||
|
|
||||||
|
<div id="status" class="status disconnected">
|
||||||
|
Status: Disconnected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="connectBtn" class="btn-primary" onclick="connectToRoom()">Connect to Room</button>
|
||||||
|
<button id="disconnectBtn" class="btn-secondary" onclick="disconnectFromRoom()" disabled>Disconnect</button>
|
||||||
|
<button id="readyBtn" class="btn-primary" onclick="markReady()" disabled>Mark Ready</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Room Info</h3>
|
||||||
|
<p>Room ID: <span id="roomId">-</span></p>
|
||||||
|
<p>Participants: <span id="participants">0/1</span></p>
|
||||||
|
<p>Queue Position: <span id="queuePosition">-</span></p>
|
||||||
|
<p>Status: <span id="roomStatus">-</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Log</h3>
|
||||||
|
<div id="log" class="log"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let client = null;
|
||||||
|
let room = null;
|
||||||
|
let isConnected = false;
|
||||||
|
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||||
|
const readyBtn = document.getElementById('readyBtn');
|
||||||
|
const roomIdSpan = document.getElementById('roomId');
|
||||||
|
const participantsSpan = document.getElementById('participants');
|
||||||
|
const queuePositionSpan = document.getElementById('queuePosition');
|
||||||
|
const roomStatusSpan = document.getElementById('roomStatus');
|
||||||
|
const logDiv = document.getElementById('log');
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const logMessage = `[${timestamp}] ${message}`;
|
||||||
|
logDiv.textContent += logMessage + '\n';
|
||||||
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
|
console.log(logMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(status, className) {
|
||||||
|
statusDiv.textContent = `Status: ${status}`;
|
||||||
|
statusDiv.className = `status ${className}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
connectBtn.disabled = isConnected;
|
||||||
|
disconnectBtn.disabled = !isConnected;
|
||||||
|
readyBtn.disabled = !isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectToRoom() {
|
||||||
|
try {
|
||||||
|
log('Connecting to Colyseus server...');
|
||||||
|
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const host = location.hostname;
|
||||||
|
const port = '8080';
|
||||||
|
|
||||||
|
client = new Colyseus.Client(`${protocol}://${host}:${port}`);
|
||||||
|
|
||||||
|
log('Joining experiment room...');
|
||||||
|
room = await client.joinOrCreate('experiment_room', {
|
||||||
|
experimentId: 'test-experiment-123',
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
ipAddress: null
|
||||||
|
});
|
||||||
|
|
||||||
|
isConnected = true;
|
||||||
|
updateStatus('Connected', 'connected');
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
roomIdSpan.textContent = room.roomId;
|
||||||
|
log(`Connected to room: ${room.roomId}`);
|
||||||
|
|
||||||
|
// Handle room state changes
|
||||||
|
room.onStateChange((state) => {
|
||||||
|
log(`Room state updated: ${JSON.stringify(state, null, 2)}`);
|
||||||
|
participantsSpan.textContent = `${state.currentParticipants}/${state.maxParticipants}`;
|
||||||
|
roomStatusSpan.textContent = state.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle messages
|
||||||
|
room.onMessage('queued', (message) => {
|
||||||
|
log(`Queued: ${JSON.stringify(message)}`);
|
||||||
|
updateStatus(`Queued (Position: ${message.position})`, 'queued');
|
||||||
|
queuePositionSpan.textContent = message.position;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('admitted', (message) => {
|
||||||
|
log(`Admitted: ${JSON.stringify(message)}`);
|
||||||
|
updateStatus('Admitted', 'connected');
|
||||||
|
queuePositionSpan.textContent = '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('joined', (message) => {
|
||||||
|
log(`Joined: ${JSON.stringify(message)}`);
|
||||||
|
updateStatus('Joined', 'connected');
|
||||||
|
queuePositionSpan.textContent = '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('queue_update', (message) => {
|
||||||
|
log(`Queue update: ${JSON.stringify(message)}`);
|
||||||
|
queuePositionSpan.textContent = message.position;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onMessage('experiment_started', (message) => {
|
||||||
|
log(`Experiment started: ${JSON.stringify(message)}`);
|
||||||
|
updateStatus('Experiment Running', 'connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onError((code, message) => {
|
||||||
|
log(`Room error: ${code} - ${message}`);
|
||||||
|
updateStatus('Error', 'disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.onLeave((code) => {
|
||||||
|
log(`Left room with code: ${code}`);
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus('Disconnected', 'disconnected');
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`Connection error: ${error.message}`);
|
||||||
|
updateStatus('Connection Failed', 'disconnected');
|
||||||
|
isConnected = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectFromRoom() {
|
||||||
|
if (room) {
|
||||||
|
log('Disconnecting from room...');
|
||||||
|
room.leave();
|
||||||
|
room = null;
|
||||||
|
client = null;
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus('Disconnected', 'disconnected');
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
roomIdSpan.textContent = '-';
|
||||||
|
participantsSpan.textContent = '0/1';
|
||||||
|
queuePositionSpan.textContent = '-';
|
||||||
|
roomStatusSpan.textContent = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markReady() {
|
||||||
|
if (room) {
|
||||||
|
log('Marking as ready...');
|
||||||
|
room.send('ready', { participantId: room.sessionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial UI update
|
||||||
|
updateUI();
|
||||||
|
log('Multiplayer test page loaded');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
}
|
}
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { defineConfig } from 'vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0', // Listen on all interfaces so Docker containers can access
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ['host.docker.internal'] // Allow Docker containers to access via host.docker.internal
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user