From cde21e9286a585aa50e039aa48ecb81c521c5e05 Mon Sep 17 00:00:00 2001 From: Shaheed Azaad <4594-shaheedazaad@users.noreply.gitlab.pavlovia.org> Date: Mon, 14 Jul 2025 00:24:44 +0200 Subject: [PATCH] fixed psychoJS serving --- components.json | 16 ++ drizzle/0000_stiff_miracleman.sql | 4 + drizzle/meta/0000_snapshot.json | 252 +++++++++++++++++ drizzle/meta/_journal.json | 13 + e2e/s3-upload-download.test.ts | 46 +++ env.example | 8 + src/lib/components/ui/button/button.svelte | 82 ++++++ src/lib/components/ui/button/index.ts | 17 ++ src/lib/components/ui/card/card-action.svelte | 20 ++ .../components/ui/card/card-content.svelte | 15 + .../ui/card/card-description.svelte | 20 ++ src/lib/components/ui/card/card-footer.svelte | 20 ++ src/lib/components/ui/card/card-header.svelte | 23 ++ src/lib/components/ui/card/card-title.svelte | 20 ++ src/lib/components/ui/card/card.svelte | 23 ++ src/lib/components/ui/card/index.ts | 25 ++ .../components/ui/checkbox/checkbox.svelte | 36 +++ src/lib/components/ui/checkbox/index.ts | 6 + src/lib/components/ui/form/form-button.svelte | 7 + .../ui/form/form-description.svelte | 17 ++ .../ui/form/form-element-field.svelte | 24 ++ .../ui/form/form-field-errors.svelte | 30 ++ src/lib/components/ui/form/form-field.svelte | 29 ++ .../components/ui/form/form-fieldset.svelte | 15 + src/lib/components/ui/form/form-label.svelte | 24 ++ src/lib/components/ui/form/form-legend.svelte | 16 ++ src/lib/components/ui/form/index.ts | 33 +++ src/lib/components/ui/input/index.ts | 7 + src/lib/components/ui/input/input.svelte | 51 ++++ src/lib/components/ui/label/index.ts | 7 + src/lib/components/ui/label/label.svelte | 20 ++ src/lib/components/ui/select/index.ts | 37 +++ .../ui/select/select-content.svelte | 40 +++ .../ui/select/select-group-heading.svelte | 21 ++ .../components/ui/select/select-group.svelte | 7 + .../components/ui/select/select-item.svelte | 38 +++ .../components/ui/select/select-label.svelte | 20 ++ .../select/select-scroll-down-button.svelte | 20 ++ .../ui/select/select-scroll-up-button.svelte | 20 ++ .../ui/select/select-separator.svelte | 18 ++ .../ui/select/select-trigger.svelte | 29 ++ .../components/ui/select/select-value.svelte | 14 + src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 20 ++ src/lib/components/ui/sheet/index.ts | 36 +++ .../components/ui/sheet/sheet-close.svelte | 7 + .../components/ui/sheet/sheet-content.svelte | 58 ++++ .../ui/sheet/sheet-description.svelte | 17 ++ .../components/ui/sheet/sheet-footer.svelte | 20 ++ .../components/ui/sheet/sheet-header.svelte | 20 ++ .../components/ui/sheet/sheet-overlay.svelte | 20 ++ .../components/ui/sheet/sheet-title.svelte | 17 ++ .../components/ui/sheet/sheet-trigger.svelte | 7 + src/lib/components/ui/sidebar/constants.ts | 6 + .../components/ui/sidebar/context.svelte.ts | 81 ++++++ src/lib/components/ui/sidebar/index.ts | 75 +++++ .../ui/sidebar/sidebar-content.svelte | 24 ++ .../ui/sidebar/sidebar-footer.svelte | 21 ++ .../ui/sidebar/sidebar-group-action.svelte | 36 +++ .../ui/sidebar/sidebar-group-content.svelte | 21 ++ .../ui/sidebar/sidebar-group-label.svelte | 34 +++ .../ui/sidebar/sidebar-group.svelte | 21 ++ .../ui/sidebar/sidebar-header.svelte | 21 ++ .../ui/sidebar/sidebar-input.svelte | 21 ++ .../ui/sidebar/sidebar-inset.svelte | 24 ++ .../ui/sidebar/sidebar-menu-action.svelte | 43 +++ .../ui/sidebar/sidebar-menu-badge.svelte | 29 ++ .../ui/sidebar/sidebar-menu-button.svelte | 103 +++++++ .../ui/sidebar/sidebar-menu-item.svelte | 21 ++ .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 +++ .../ui/sidebar/sidebar-menu-sub-button.svelte | 43 +++ .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 ++ .../ui/sidebar/sidebar-menu-sub.svelte | 25 ++ .../components/ui/sidebar/sidebar-menu.svelte | 21 ++ .../ui/sidebar/sidebar-provider.svelte | 53 ++++ .../components/ui/sidebar/sidebar-rail.svelte | 36 +++ .../ui/sidebar/sidebar-separator.svelte | 19 ++ .../ui/sidebar/sidebar-trigger.svelte | 35 +++ src/lib/components/ui/sidebar/sidebar.svelte | 104 +++++++ src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 ++ src/lib/components/ui/table/index.ts | 28 ++ src/lib/components/ui/table/table-body.svelte | 20 ++ .../components/ui/table/table-caption.svelte | 20 ++ src/lib/components/ui/table/table-cell.svelte | 23 ++ .../components/ui/table/table-footer.svelte | 20 ++ src/lib/components/ui/table/table-head.svelte | 23 ++ .../components/ui/table/table-header.svelte | 20 ++ src/lib/components/ui/table/table-row.svelte | 23 ++ src/lib/components/ui/table/table.svelte | 22 ++ src/lib/components/ui/tooltip/index.ts | 21 ++ .../ui/tooltip/tooltip-content.svelte | 47 ++++ .../ui/tooltip/tooltip-trigger.svelte | 7 + src/lib/hooks/is-mobile.svelte.ts | 9 + src/lib/server/s3.ts | 18 ++ src/lib/utils.ts | 13 + src/routes/api/experiment/+server.ts | 71 +++++ .../api/experiment/[id]/files/+server.ts | 70 +++++ src/routes/api/login/+server.ts | 25 ++ src/routes/api/register/+server.ts | 25 ++ src/routes/experiment/[id]/+page.svelte | 262 ++++++++++++++++++ src/routes/experiment/[id]/FileBrowser.svelte | 56 ++++ src/routes/experiment/create/+page.svelte | 102 +++++++ src/routes/images/[...path]/+server.ts | 53 ++++ .../run/[experimentId]/[...path]/+server.ts | 256 +++++++++++++++++ .../psychoJS/surveyJS/survey.grey_style.css | 116 ++++++++ .../lib/psychoJS/surveyJS/survey.widgets.css | 207 ++++++++++++++ 107 files changed, 3974 insertions(+) create mode 100644 components.json create mode 100644 drizzle/0000_stiff_miracleman.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 e2e/s3-upload-download.test.ts create mode 100644 env.example create mode 100644 src/lib/components/ui/button/button.svelte create mode 100644 src/lib/components/ui/button/index.ts create mode 100644 src/lib/components/ui/card/card-action.svelte create mode 100644 src/lib/components/ui/card/card-content.svelte create mode 100644 src/lib/components/ui/card/card-description.svelte create mode 100644 src/lib/components/ui/card/card-footer.svelte create mode 100644 src/lib/components/ui/card/card-header.svelte create mode 100644 src/lib/components/ui/card/card-title.svelte create mode 100644 src/lib/components/ui/card/card.svelte create mode 100644 src/lib/components/ui/card/index.ts create mode 100644 src/lib/components/ui/checkbox/checkbox.svelte create mode 100644 src/lib/components/ui/checkbox/index.ts create mode 100644 src/lib/components/ui/form/form-button.svelte create mode 100644 src/lib/components/ui/form/form-description.svelte create mode 100644 src/lib/components/ui/form/form-element-field.svelte create mode 100644 src/lib/components/ui/form/form-field-errors.svelte create mode 100644 src/lib/components/ui/form/form-field.svelte create mode 100644 src/lib/components/ui/form/form-fieldset.svelte create mode 100644 src/lib/components/ui/form/form-label.svelte create mode 100644 src/lib/components/ui/form/form-legend.svelte create mode 100644 src/lib/components/ui/form/index.ts create mode 100644 src/lib/components/ui/input/index.ts create mode 100644 src/lib/components/ui/input/input.svelte create mode 100644 src/lib/components/ui/label/index.ts create mode 100644 src/lib/components/ui/label/label.svelte create mode 100644 src/lib/components/ui/select/index.ts create mode 100644 src/lib/components/ui/select/select-content.svelte create mode 100644 src/lib/components/ui/select/select-group-heading.svelte create mode 100644 src/lib/components/ui/select/select-group.svelte create mode 100644 src/lib/components/ui/select/select-item.svelte create mode 100644 src/lib/components/ui/select/select-label.svelte create mode 100644 src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 src/lib/components/ui/select/select-separator.svelte create mode 100644 src/lib/components/ui/select/select-trigger.svelte create mode 100644 src/lib/components/ui/select/select-value.svelte create mode 100644 src/lib/components/ui/separator/index.ts create mode 100644 src/lib/components/ui/separator/separator.svelte create mode 100644 src/lib/components/ui/sheet/index.ts create mode 100644 src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 src/lib/components/ui/sidebar/constants.ts create mode 100644 src/lib/components/ui/sidebar/context.svelte.ts create mode 100644 src/lib/components/ui/sidebar/index.ts create mode 100644 src/lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar.svelte create mode 100644 src/lib/components/ui/skeleton/index.ts create mode 100644 src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 src/lib/components/ui/table/index.ts create mode 100644 src/lib/components/ui/table/table-body.svelte create mode 100644 src/lib/components/ui/table/table-caption.svelte create mode 100644 src/lib/components/ui/table/table-cell.svelte create mode 100644 src/lib/components/ui/table/table-footer.svelte create mode 100644 src/lib/components/ui/table/table-head.svelte create mode 100644 src/lib/components/ui/table/table-header.svelte create mode 100644 src/lib/components/ui/table/table-row.svelte create mode 100644 src/lib/components/ui/table/table.svelte create mode 100644 src/lib/components/ui/tooltip/index.ts create mode 100644 src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 src/lib/hooks/is-mobile.svelte.ts create mode 100644 src/lib/server/s3.ts create mode 100644 src/lib/utils.ts create mode 100644 src/routes/api/experiment/+server.ts create mode 100644 src/routes/api/experiment/[id]/files/+server.ts create mode 100644 src/routes/api/login/+server.ts create mode 100644 src/routes/api/register/+server.ts create mode 100644 src/routes/experiment/[id]/+page.svelte create mode 100644 src/routes/experiment/[id]/FileBrowser.svelte create mode 100644 src/routes/experiment/create/+page.svelte create mode 100644 src/routes/images/[...path]/+server.ts create mode 100644 src/routes/public/run/[experimentId]/[...path]/+server.ts create mode 100644 static/lib/psychoJS/surveyJS/survey.grey_style.css create mode 100644 static/lib/psychoJS/surveyJS/survey.widgets.css diff --git a/components.json b/components.json new file mode 100644 index 0000000..c5d91b4 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/drizzle/0000_stiff_miracleman.sql b/drizzle/0000_stiff_miracleman.sql new file mode 100644 index 0000000..e7d1362 --- /dev/null +++ b/drizzle/0000_stiff_miracleman.sql @@ -0,0 +1,4 @@ +DROP TYPE IF EXISTS "public"."experiment_type"; +CREATE TYPE "public"."experiment_type" AS ENUM('jsPsych', 'PsychoJS'); +ALTER TABLE "experiment" ADD COLUMN "multiplayer" boolean DEFAULT false NOT NULL; +ALTER TABLE "experiment" ADD COLUMN "type" "experiment_type" NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..98044b2 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,252 @@ +{ + "id": "0d17b791-c2c5-4f27-885d-20386dae844f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.experiment": { + "name": "experiment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dummy": { + "name": "dummy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "multiplayer": { + "name": "multiplayer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "experiment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "experiment_created_by_user_id_fk": { + "name": "experiment_created_by_user_id_fk", + "tableFrom": "experiment", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant_session": { + "name": "participant_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "experiment_id": { + "name": "experiment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_session_experiment_id_experiment_id_fk": { + "name": "participant_session_experiment_id_experiment_id_fk", + "tableFrom": "participant_session", + "tableTo": "experiment", + "columnsFrom": [ + "experiment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.experiment_type": { + "name": "experiment_type", + "schema": "public", + "values": [ + "jsPsych", + "PsychoJS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..90d97b5 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1752388656434, + "tag": "0000_grey_domino", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/e2e/s3-upload-download.test.ts b/e2e/s3-upload-download.test.ts new file mode 100644 index 0000000..01a6df3 --- /dev/null +++ b/e2e/s3-upload-download.test.ts @@ -0,0 +1,46 @@ +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { randomUUID } from 'crypto'; +import { expect, test } from 'vitest'; +import 'dotenv/config'; + +const s3 = new S3Client({ + region: 'us-east-1', + endpoint: process.env.S3_ENDPOINT, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, +}); + +const BUCKET = process.env.S3_BUCKET!; + +test('can upload and download a file from S3', async () => { + const key = `test-file-${randomUUID()}.txt`; + const content = 'hello s3 world!'; + + // Upload + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: content, + })); + + // Download + const getObj = await s3.send(new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + })); + const downloaded = await streamToString(getObj.Body); + + expect(downloaded).toBe(content); +}); + +function streamToString(stream: any): Promise { + return new Promise((resolve, reject) => { + const chunks: any[] = []; + stream.on('data', (chunk: any) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + }); +} \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..37965ad --- /dev/null +++ b/env.example @@ -0,0 +1,8 @@ +DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local" +ENVIRONMENT=development +PUBLIC_APP_NAME="cog-socket" +S3_ENDPOINT="http://localhost:9000" +S3_ACCESS_KEY="minioadmin" +S3_SECRET_KEY="minioadmin" +S3_BUCKET="cog-socket" +BASE_URL="http://localhost:5173" \ No newline at end of file diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..8c96a0c --- /dev/null +++ b/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/src/lib/components/ui/card/card-action.svelte b/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8a91abb --- /dev/null +++ b/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..22586e6 --- /dev/null +++ b/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/src/lib/components/ui/checkbox/checkbox.svelte b/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..1622e05 --- /dev/null +++ b/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/src/lib/components/ui/checkbox/index.ts b/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte new file mode 100644 index 0000000..cc0c590 --- /dev/null +++ b/src/lib/components/ui/form/form-button.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/form/form-description.svelte b/src/lib/components/ui/form/form-description.svelte new file mode 100644 index 0000000..a5f42be --- /dev/null +++ b/src/lib/components/ui/form/form-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/form/form-element-field.svelte b/src/lib/components/ui/form/form-element-field.svelte new file mode 100644 index 0000000..c3ba111 --- /dev/null +++ b/src/lib/components/ui/form/form-element-field.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/src/lib/components/ui/form/form-field-errors.svelte b/src/lib/components/ui/form/form-field-errors.svelte new file mode 100644 index 0000000..b4c6fba --- /dev/null +++ b/src/lib/components/ui/form/form-field-errors.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children({ errors, errorProps })} + {#if childrenProp} + {@render childrenProp({ errors, errorProps })} + {:else} + {#each errors as error (error)} +
{error}
+ {/each} + {/if} + {/snippet} +
diff --git a/src/lib/components/ui/form/form-field.svelte b/src/lib/components/ui/form/form-field.svelte new file mode 100644 index 0000000..7481fda --- /dev/null +++ b/src/lib/components/ui/form/form-field.svelte @@ -0,0 +1,29 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/src/lib/components/ui/form/form-fieldset.svelte b/src/lib/components/ui/form/form-fieldset.svelte new file mode 100644 index 0000000..2c85857 --- /dev/null +++ b/src/lib/components/ui/form/form-fieldset.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/lib/components/ui/form/form-label.svelte b/src/lib/components/ui/form/form-label.svelte new file mode 100644 index 0000000..8749360 --- /dev/null +++ b/src/lib/components/ui/form/form-label.svelte @@ -0,0 +1,24 @@ + + + + {#snippet child({ props })} + + {/snippet} + diff --git a/src/lib/components/ui/form/form-legend.svelte b/src/lib/components/ui/form/form-legend.svelte new file mode 100644 index 0000000..9d52f6a --- /dev/null +++ b/src/lib/components/ui/form/form-legend.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/form/index.ts b/src/lib/components/ui/form/index.ts new file mode 100644 index 0000000..0713927 --- /dev/null +++ b/src/lib/components/ui/form/index.ts @@ -0,0 +1,33 @@ +import * as FormPrimitive from "formsnap"; +import Description from "./form-description.svelte"; +import Label from "./form-label.svelte"; +import FieldErrors from "./form-field-errors.svelte"; +import Field from "./form-field.svelte"; +import Fieldset from "./form-fieldset.svelte"; +import Legend from "./form-legend.svelte"; +import ElementField from "./form-element-field.svelte"; +import Button from "./form-button.svelte"; + +const Control = FormPrimitive.Control; + +export { + Field, + Control, + Label, + Button, + FieldErrors, + Description, + Fieldset, + Legend, + ElementField, + // + Field as FormField, + Control as FormControl, + Description as FormDescription, + Label as FormLabel, + FieldErrors as FormFieldErrors, + Fieldset as FormFieldset, + Legend as FormLegend, + ElementField as FormElementField, + Button as FormButton, +}; diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..19c6dae --- /dev/null +++ b/src/lib/components/ui/input/input.svelte @@ -0,0 +1,51 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d0afda3 --- /dev/null +++ b/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/select/index.ts b/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..9e8d3e9 --- /dev/null +++ b/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; + +const Root = SelectPrimitive.Root; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, +}; diff --git a/src/lib/components/ui/select/select-content.svelte b/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..dc16d65 --- /dev/null +++ b/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,40 @@ + + + + + + + {@render children?.()} + + + + diff --git a/src/lib/components/ui/select/select-group-heading.svelte b/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/select/select-group.svelte b/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..5454fdb --- /dev/null +++ b/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/select/select-item.svelte b/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..49dbbd7 --- /dev/null +++ b/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/src/lib/components/ui/select/select-label.svelte b/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/select/select-scroll-down-button.svelte b/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-scroll-up-button.svelte b/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-separator.svelte b/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/ui/select/select-trigger.svelte b/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..d405187 --- /dev/null +++ b/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/src/lib/components/ui/select/select-value.svelte b/src/lib/components/ui/select/select-value.svelte new file mode 100644 index 0000000..e7ddd9a --- /dev/null +++ b/src/lib/components/ui/select/select-value.svelte @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..09d88f4 --- /dev/null +++ b/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..01d40c8 --- /dev/null +++ b/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,36 @@ +import { Dialog as SheetPrimitive } from "bits-ui"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/src/lib/components/ui/sheet/sheet-close.svelte b/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..856922e --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,58 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-trigger.svelte b/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sidebar/constants.ts b/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..fb84e4a --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..e292945 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..d862761 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..fa3fb0c --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..69e5a3c --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..4bef683 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..cc63b04 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..987f104 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..8ab1111 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..5b0d0aa --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..c180cf5 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..1825182 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..3e9eba9 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/src/lib/components/ui/table/index.ts b/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/src/lib/components/ui/table/table-body.svelte b/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-caption.svelte b/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-cell.svelte b/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..1a2f033 --- /dev/null +++ b/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-footer.svelte b/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-head.svelte b/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..e9dd237 --- /dev/null +++ b/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-header.svelte b/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-row.svelte b/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/src/lib/components/ui/table/table.svelte b/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
    + + {@render children?.()} +
    +
    diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..313a7f0 --- /dev/null +++ b/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..e495efe --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/src/lib/server/s3.ts b/src/lib/server/s3.ts new file mode 100644 index 0000000..b2a8d8d --- /dev/null +++ b/src/lib/server/s3.ts @@ -0,0 +1,18 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { + S3_ENDPOINT, + S3_ACCESS_KEY, + S3_SECRET_KEY +} from '$env/static/private'; + +const s3 = new S3Client({ + region: 'us-east-1', // MinIO ignores region but AWS SDK requires it + endpoint: S3_ENDPOINT, + credentials: { + accessKeyId: S3_ACCESS_KEY, + secretAccessKey: S3_SECRET_KEY + }, + forcePathStyle: true // needed for MinIO +}); + +export default s3; \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/src/routes/api/experiment/+server.ts b/src/routes/api/experiment/+server.ts new file mode 100644 index 0000000..16a4c8b --- /dev/null +++ b/src/routes/api/experiment/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import * as schema from '$lib/server/db/schema'; +import { randomUUID } from 'crypto'; +import { eq, and } from 'drizzle-orm'; + +export async function GET({ locals, url }) { + if (!locals.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + const userId = locals.user.id; + const id = url.searchParams.get('id'); + if (id) { + const experiment = await db.query.experiment.findFirst({ + where: (exp, { eq }) => eq(exp.id, id) + }); + if (!experiment) { + return json({ error: 'Not found' }, { status: 404 }); + } + return json({ experiment }); + } + const experiments = await db.select().from(schema.experiment).where(eq(schema.experiment.createdBy, userId)); + return json({ experiments }); +} + +export async function POST({ request, locals }) { + if (!locals.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + const { name, description, multiplayer, type } = await request.json(); + if (!name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + if (!type || !['jsPsych', 'PsychoJS'].includes(type)) { + return json({ error: 'Invalid type' }, { status: 400 }); + } + const experiment = { + id: randomUUID(), + name, + description, + createdBy: locals.user.id, + createdAt: new Date(), + multiplayer: multiplayer || false, + type + }; + await db.insert(schema.experiment).values(experiment); + return json({ experiment }); +} + +export async function PUT({ request, locals, url }) { + if (!locals.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + const id = url.searchParams.get('id'); + if (!id) { + return json({ error: 'Missing id' }, { status: 400 }); + } + const { name, description } = await request.json(); + if (!name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + const [updated] = await db + .update(schema.experiment) + .set({ name, description }) + .where(and(eq(schema.experiment.id, id), eq(schema.experiment.createdBy, locals.user.id))) + .returning(); + if (!updated) { + return json({ error: 'Not found or not authorized' }, { status: 404 }); + } + return json({ experiment: updated }); +} \ No newline at end of file diff --git a/src/routes/api/experiment/[id]/files/+server.ts b/src/routes/api/experiment/[id]/files/+server.ts new file mode 100644 index 0000000..2869bbc --- /dev/null +++ b/src/routes/api/experiment/[id]/files/+server.ts @@ -0,0 +1,70 @@ +import { S3Client, ListObjectsV2Command, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { json, error } from '@sveltejs/kit'; +import { randomUUID } from 'crypto'; +import { S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET } from '$env/static/private'; + +const s3 = new S3Client({ + region: 'us-east-1', + endpoint: S3_ENDPOINT, + credentials: { + accessKeyId: S3_ACCESS_KEY!, + secretAccessKey: S3_SECRET_KEY!, + }, + forcePathStyle: true, +}); +const BUCKET = S3_BUCKET!; + +export async function GET({ params }) { + const { id } = params; + const prefix = `experiments/${id}/`; + const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix })); + const files = (result.Contents || []).map(obj => ({ + key: obj.Key, + name: obj.Key?.replace(prefix, ''), + size: obj.Size, + lastModified: obj.LastModified, + })).filter(f => f.name); + return json({ files }); +} + +export async function POST({ params, request }) { + console.log(params); + const { id } = params; + const data = await request.formData(); + const file = data.get('file'); + const relativePath = data.get('relativePath'); + if (!file || typeof file === 'string') throw error(400, 'No file uploaded'); + const key = `experiments/${id}/${relativePath}`; + console.log(key); + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: Buffer.from(await file.arrayBuffer()), + ContentType: file.type, + })); + return json({ success: true, key }); +} + +export async function DELETE({ params, url }) { + const { id } = params; + const key = url.searchParams.get('key'); + const prefix = url.searchParams.get('prefix'); + if (prefix) { + // Delete all objects with this prefix + const fullPrefix = `experiments/${id}/${prefix}`; + const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: fullPrefix })); + const objects = (result.Contents || []).map(obj => ({ Key: obj.Key })); + if (objects.length > 0) { + // Use DeleteObjectsCommand for batch deletion + const { DeleteObjectsCommand } = await import('@aws-sdk/client-s3'); + await s3.send(new DeleteObjectsCommand({ + Bucket: BUCKET, + Delete: { Objects: objects }, + })); + } + return json({ success: true, deleted: objects.length }); + } + if (!key) throw error(400, 'Missing key or prefix'); + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); + return json({ success: true }); +} \ No newline at end of file diff --git a/src/routes/api/login/+server.ts b/src/routes/api/login/+server.ts new file mode 100644 index 0000000..17f137f --- /dev/null +++ b/src/routes/api/login/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import * as schema from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import argon2 from '@node-rs/argon2'; +import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth'; + +export async function POST({ request, cookies }) { + const { username, password } = await request.json(); + if (!username || !password) { + return json({ error: 'Missing username or password' }, { status: 400 }); + } + const [user] = await db.select().from(schema.user).where(eq(schema.user.username, username)); + if (!user) { + return json({ error: 'Invalid username or password' }, { status: 401 }); + } + const valid = await argon2.verify(user.passwordHash, password); + if (!valid) { + return json({ error: 'Invalid username or password' }, { status: 401 }); + } + const token = generateSessionToken(); + const session = await createSession(token, user.id); + cookies.set('auth-session', token, { expires: session.expiresAt, path: '/' }); + return json({ success: true }); +} \ No newline at end of file diff --git a/src/routes/api/register/+server.ts b/src/routes/api/register/+server.ts new file mode 100644 index 0000000..132c435 --- /dev/null +++ b/src/routes/api/register/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import * as schema from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import argon2 from '@node-rs/argon2'; +import { generateSessionToken, createSession } from '$lib/server/auth'; +import { randomUUID } from 'crypto'; + +export async function POST({ request, cookies }) { + const { username, password } = await request.json(); + if (!username || !password) { + return json({ error: 'Missing username or password' }, { status: 400 }); + } + const [existing] = await db.select().from(schema.user).where(eq(schema.user.username, username)); + if (existing) { + return json({ error: 'User already exists' }, { status: 409 }); + } + const passwordHash = await argon2.hash(password); + const userId = randomUUID(); + await db.insert(schema.user).values({ id: userId, username, passwordHash }); + const token = generateSessionToken(); + const session = await createSession(token, userId); + cookies.set('auth-session', token, { expires: session.expiresAt, path: '/' }); + return json({ success: true }); +} \ No newline at end of file diff --git a/src/routes/experiment/[id]/+page.svelte b/src/routes/experiment/[id]/+page.svelte new file mode 100644 index 0000000..2feeef9 --- /dev/null +++ b/src/routes/experiment/[id]/+page.svelte @@ -0,0 +1,262 @@ + + +{#if experiment} +
    + + + Experiment Details + + +
    + +
    {experiment.id}
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    {new Date(experiment.createdAt).toLocaleString()}
    +
    + {#if error} +
    {error}
    + {/if} + {#if success} +
    {success}
    + {/if} + +
    +
    +
    + + + + Public Link + + +

    + Share this link with your participants to run the experiment. +

    +
    + + +
    + {#if copied} +

    Copied to clipboard!

    + {/if} +
    +
    + + + + Experiment Files + + +
    + +
    document.getElementById('file-input')?.click()} + > +
    Click to select files or a folder to upload (folder structure will be preserved)
    + +
    + {#if uploading} +
    Uploading...
    + {/if} + {#if uploadError} +
    {uploadError}
    + {/if} +
      + + {#if files.length === 0} +
    • No files uploaded yet.
    • + {/if} +
    +
    +
    +
    +
    +{:else if error} +
    + + + Error + + +
    {error}
    +
    +
    +
    +{:else} +
    +
    Loading...
    +
    +{/if} \ No newline at end of file diff --git a/src/routes/experiment/[id]/FileBrowser.svelte b/src/routes/experiment/[id]/FileBrowser.svelte new file mode 100644 index 0000000..83cfe60 --- /dev/null +++ b/src/routes/experiment/[id]/FileBrowser.svelte @@ -0,0 +1,56 @@ + + +
      + {#each Object.entries(tree) as [name, node] (parentPath + '/' + name)} + {#if (node as any).isFile} +
    • + {name} ({(node as any).size} bytes) + +
    • + {:else} +
    • +
      handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}> + {expanded.has(parentPath ? `${parentPath}/${name}` : name) ? '▼' : '▶'} + {name} + +
      + {#if expanded.has(parentPath ? `${parentPath}/${name}` : name)} + + {/if} +
    • + {/if} + {/each} +
    \ No newline at end of file diff --git a/src/routes/experiment/create/+page.svelte b/src/routes/experiment/create/+page.svelte new file mode 100644 index 0000000..eb0b0f7 --- /dev/null +++ b/src/routes/experiment/create/+page.svelte @@ -0,0 +1,102 @@ + + +
    + + + Create Experiment + + +
    + + + Name + + + + + + + Description + + + + + + +
    + + Multiplayer +
    +
    + +
    + + + Type + + + {$formData.type || 'Select a type'} + + + jsPsych + PsychoJS + + + + + + Create +
    +
    +
    +
    diff --git a/src/routes/images/[...path]/+server.ts b/src/routes/images/[...path]/+server.ts new file mode 100644 index 0000000..979f0e2 --- /dev/null +++ b/src/routes/images/[...path]/+server.ts @@ -0,0 +1,53 @@ +import s3 from '$lib/server/s3.js'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { error } from '@sveltejs/kit'; +import mime from 'mime-types'; +import { S3_BUCKET } from '$env/static/private'; + +export async function GET({ params, request }) { + const referer = request.headers.get('referer'); + if (!referer) { + throw error(400, 'Referer header is required'); + } + + const refererUrl = new URL(referer); + const match = refererUrl.pathname.match(/\/public\/run\/([^/]+)/); + + if (!match || !match[1]) { + throw error(400, 'Could not determine experiment ID from referer'); + } + + const experimentId = match[1]; + const imagePath = `images/${params.path}`; + + const key = `experiments/${experimentId}/${imagePath}`; + + 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(imagePath) || s3Response.ContentType || 'application/octet-stream'; + const fileBuffer = await stream.transformToByteArray(); + + return new Response(fileBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': fileBuffer.length.toString() + } + }); + } catch (e: any) { + if (e.name === 'NoSuchKey') { + throw error(404, 'Not found'); + } + console.error(e); + throw error(500, 'Internal server error'); + } +} \ No newline at end of file diff --git a/src/routes/public/run/[experimentId]/[...path]/+server.ts b/src/routes/public/run/[experimentId]/[...path]/+server.ts new file mode 100644 index 0000000..0bb6539 --- /dev/null +++ b/src/routes/public/run/[experimentId]/[...path]/+server.ts @@ -0,0 +1,256 @@ +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 PARTICIPANT_COOKIE_PREFIX = 'participant-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; + + // Map of requested CSS files to their location in the static directory. + const staticCssMap: Record = { + '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 = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`; + let participantSessionId = cookies.get(cookieName); + + if (!participantSessionId) { + // First request for this experiment. Create a new participant session. + participantSessionId = randomUUID(); + + await db.insert(schema.participantSession).values({ + id: participantSessionId, + experimentId, + createdAt: new Date(), + ipAddress: getClientAddress(), + userAgent: request.headers.get('user-agent') ?? undefined + }); + + cookies.set(cookieName, participantSessionId, { + path: `/public/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.participantSession) + .where(eq(schema.participantSession.id, participantSessionId)); + if (!session) { + // invalid cookie, create new session + const newParticipantSessionId = randomUUID(); + await db.insert(schema.participantSession).values({ + id: newParticipantSessionId, + experimentId, + createdAt: new Date(), + ipAddress: getClientAddress(), + userAgent: request.headers.get('user-agent') ?? undefined + }); + cookies.set(cookieName, newParticipantSessionId, { + path: `/public/run/${experimentId}`, + httpOnly: true, + secure: !dev, + maxAge: 60 * 60 * 24 * 365 // 1 year + }); + } + } + + const s3Prefix = `experiments/${experimentId}/`; + 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/run/${experimentId}/`; + + // Get all files in the experiment directory to create a resource manifest + const listCommand = new ListObjectsV2Command({ + Bucket: S3_BUCKET, + Prefix: `experiments/${experimentId}/` + }); + const listResponse = await s3.send(listCommand); + + // Create resource manifest for PsychoJS + const resources = (listResponse.Contents || []) + .filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`) + .map(obj => { + const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, ''); + return { + name: relativePath, + path: relativePath + }; + }); + + // Create the resource injection script that runs after PsychoJS loads + const resourceInjectionScript = ` + + `; + + const injectedBody = body + .replace(/]*>/, `$&`) + .replace(/<\/body>/, `${resourceInjectionScript}`); + + 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, + 'Content-Length': fileBuffer.length.toString() + } + }); + } catch (e: any) { + if (e.name === 'NoSuchKey') { + throw error(404, 'Not found'); + } + throw error(500, 'Internal server error'); + } +} \ No newline at end of file diff --git a/static/lib/psychoJS/surveyJS/survey.grey_style.css b/static/lib/psychoJS/surveyJS/survey.grey_style.css new file mode 100644 index 0000000..c330172 --- /dev/null +++ b/static/lib/psychoJS/surveyJS/survey.grey_style.css @@ -0,0 +1,116 @@ +/** + * Light-grey Survey style. + */ + + .survey { + position: absolute; + left: 1em; + top: 1em; + right: 1em; + bottom: 1em; + + overflow-x: hidden; + + /* title, buttons background, border around text boxes */ + --primary: #1AB7FA; + /* text, e.g. Designer, Preview, Logic */ + --foreground: #000000; + /* text of selected element, e.g. Complete button, selected rating*/ + --primary-foreground: #FFFFFF; + /* highlight border of survey elements */ + --secondary: #FFAA00; + /* top menu and side bar background, also background for survey elements */ + --background: #FAFAFA; + /* background behind survey questions */ + --background-dim: #F0F0F0; + /* background of survey element, e.g. text boxes */ + --background-dim-light: #FFFFFF; + } + + .sd-root-modern { + --sd-base-padding: 2em; + --sd-base-vertical-padding: 1em; + } + + .sd-element--with-frame:not(.sd-element--collapsed) { + border-radius: 5px; + background: #FAFAFA; /*#f0f0f0;*/ + box-shadow: 2px 2px 4px #d8d8d8 /*, -2px -2px 4px #ffffff*/; + } + + /* Survey title */ + .sd-root-modern:not(.svc-tab-designer) .sd-container-modern__title { + + background: #FFFFFF; + /*background: linear-gradient(315deg, #F0F0F0, #ffffff);*/ + /*border-left: 1em solid #F0F0F0; + border-right: 1em solid #F0F0F0;*/ + margin: 0 24px 0 24px; + border-radius: 5px; + /*border-top-left-radius: 5px; + border-top-right-radius: 5px;*/ + box-shadow: 2px 2px 4px #d8d8d8; + + /* + border-radius: 0; + background: linear-gradient(315deg, #e5e5e5, #ffffff); + box-shadow: -1px -1px 2px #e6e6e6, + 1px 1px 2px #ffffff; + border-bottom: 1px solid #1ab7fa; + */ + } + + .sd-btn { + border-radius: 5px; + background: #1ab7fa; + } + + .sd-navigation__next-btn, + .sd-navigation__prev-btn { + color: #000000; + background: #FFFFFF !important; + } + + .sd-navigation__start-btn, + .sd-navigation__preview-btn { + color: #FFFFFF; + } + + .sd-question__header { + border-bottom: 2px solid #EEEEEE; + margin-bottom: 0.5em; + } + + .sv-ranking-item__content { + background-color: transparent !important; + } + + .sd-radio__decorator { + border: 1px solid #CCCCCC; + } + + + .sd-question--table>.sd-question__content:before { + /*background-color: transparent;*/ + /*border-left: 2px dashed #DDDDDD !important; + border-top: 2px dashed #DDDDDD !important; + border-bottom: 2px dashed #DDDDDD !important;*/ + } + + /*.sd-table, .sd-matrix__table, .sd-table__cell, .sd-matrix__cell */ + .sd-table, .sd-matrix__table { + /*background-color: transparent !important;*/ + /*border-top: 2px dashed #DDDDDD !important; + border-bottom: 2px dashed #DDDDDD !important;*/ + } + + .sd-question--table>.sd-question__content:after { + /*background-color: transparent;*/ + /*border-top: 2px dashed #DDDDDD !important; + border-bottom: 2px dashed #DDDDDD !important;*/ + } + + .sd-element--complex:not(.sd-element--collapsed)>.sd-element__header--location-top:after { + background: transparent !important; + } + \ No newline at end of file diff --git a/static/lib/psychoJS/surveyJS/survey.widgets.css b/static/lib/psychoJS/surveyJS/survey.widgets.css new file mode 100644 index 0000000..c57eddc --- /dev/null +++ b/static/lib/psychoJS/surveyJS/survey.widgets.css @@ -0,0 +1,207 @@ +/** ============ Slider ============ */ + +/** + * Native input range styling inspired by: + * https://css-tricks.com/value-bubbles-for-range-inputs/ + * https://codepen.io/ShadowShahriar/pen/zYPPYrQ + */ + + .srv-slider-container { + --thumb-size: 20px; + /*--primary: #19b394;*/ + --slider-background: #f5f5f5; + --slider-background-disabled: #e5e5e5; + --slider-thumb-disabled: #a5a5a5; + --slider-display-background: black; + --slider-border: #d4d4d4; + --survey-question-margin: 0.55em; + box-sizing: border-box; + position: relative; + margin: 50px 10px 10px 10px; +} + +.srv-slider-container * { + box-sizing: border-box; +} + +.srv-slider-container .srv-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: calc(var(--thumb-size) - 10px); + background: var(--slider-background); + border: 0.07em solid var(--slider-border); + border-radius: 10px; + margin: 0; + padding: 0; +} + +.srv-slider-container .srv-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 50%; + background: var(--primary, #19b394); +} + +.srv-slider-container .srv-slider::-moz-range-thumb { + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 50%; + background: var(--primary, #19b394); +} + +.srv-slider-container .srv-slider-display { + position: absolute; + background: var(--slider-display-background); + color: white; + border-radius: 3px; + padding: 5px; + top: -40px; + transform: translate(-50%, 0); +} + +.srv-slider-container .srv-slider-title { + margin: 0 0 0 0; +} + +.srv-slider-container .srv-slider-display::after { + content: ""; + position: absolute; + width: 0; + height: 0; + border-top: 10px solid var(--slider-display-background); + border-left: 5px solid transparent; + border-right: 5px solid transparent; + top: 100%; + left: 50%; + margin-left: -5px; + margin-top: -2px; +} + +.srv-slider-container.srv-slider-bar .srv-slider { + height: var(--thumb-size); + border-radius: 0; + overflow: hidden; +} + +.srv-slider-container.srv-slider-bar .srv-slider::-webkit-slider-thumb { + border: none; + border-radius: 0; + box-shadow: calc(-100vmax - var(--thumb-size)) 0 0 100vmax var(--primary); +} + +.srv-slider-container.srv-slider-bar .srv-slider::-moz-range-thumb { + border: none; + border-radius: 0; + box-shadow: calc(-100vmax - var(--thumb-size)) 0 0 100vmax var(--primary); +} + +.srv-slider-container .srv-slider[disabled] { + background: var(--slider-background-disabled); +} + +.srv-slider-container .srv-slider[disabled]::-webkit-slider-thumb { + background: var(--slider-thumb-disabled); +} + +.srv-slider-container .srv-slider[disabled]::-moz-range-thumb { + background: var(--slider-thumb-disabled); +} + +.srv-slider-container.srv-slider-bar .srv-slider[disabled]::-webkit-slider-thumb { + box-shadow: calc(-100vmax - var(--thumb-size)) 0 0 100vmax var(--slider-thumb-disabled); +} + +.srv-slider-container.srv-slider-bar .srv-slider[disabled]::-moz-range-thumb { + box-shadow: calc(-100vmax - var(--thumb-size)) 0 0 100vmax var(--slider-thumb-disabled); +} + + +/** ============ MaxDiffMatrix ============ */ +/*Overriding SurveyJS's theme styles*/ +.matrix-maxdiff .sv-matrix__cell:first-child, +.matrix-maxdiff .sd-matrix__cell:first-child { + text-align: center; +} + +/** ============ MatrixBipolar ============ */ +.matrix-bipolar tbody td:first-child { + text-align: right; +} + +.matrix-bipolar tbody td:last-child { + text-align: left; +} + + +/** ============ SliderStar ============ */ +.star-slider-container { + --default-background: #d4d4d4; + --selected-background: rgb(249, 195, 0); + margin: 0 0 8px 0; +} + +.star-slider-container .star-slider-inputs { + display: flex; + align-items: flex-end; +} + +.star-slider-container .stars-container { + display: flex; +} + +.star-slider-container .star-slider-inputs .star-slider-star-input { + color: var(--default-background); + font-size: 40px; + line-height: 40px; + cursor: pointer; +} + +.star-slider-container .star-slider-inputs .slider-star-text-input { + margin: 0 0 0 18px; + font-size: 18px; +} + +.star-slider-container .star-slider-inputs .star-slider-star-input.active { + color: var(--selected-background); +} + +.star-slider-container .stars-container:hover .star-slider-star-input { + color: var(--selected-background); +} + +.star-slider-container .stars-container .star-slider-star-input:hover ~ .star-slider-star-input { + color: var(--default-background); +} + + +/** ============ Multiple Select ============ */ + +.srv-select-multiple { + border: 1px solid #9e9e9e; + border-radius: 4px; + box-sizing: border-box; + background: var(--background); + box-shadow: none; + font-size: calc(2 * var(--base-unit, 8px)); +} + +.srv-select-multiple option { + padding: calc(1.5 * var(--base-unit, 8px)) calc(2 * var(--base-unit, 8px)); +} + +.srv-select-multiple:focus { + outline: 1px solid var(--primary); + border-color: var(--primary); +} + +/** Change of selection color while focused still refuses to change. + * Leaving it here for future. */ +.srv-select-multiple:focus option:checked, +.srv-select-multiple option:checked { + background: var(--primary); + background-color: var(--primary); + color: var(--primary-foreground); +} \ No newline at end of file