From efeea1ae19dc3ee11649235f2f4337f651b25372 Mon Sep 17 00:00:00 2001 From: whidix Date: Sun, 8 Mar 2026 15:34:24 +0100 Subject: [PATCH] feat: complete outdoor escape game platform with location-based steps - Initialize SvelteKit project with authentication and database - Implement multilingual support (English/French) - Add authentication system with login, signup, and logout - Create admin panel with games and sessions management - Implement game and step management (CRUD operations) - Add soft delete for escape games - Create player game flow with step progression - Implement inventory and collected items system - Add location-based steps with GPS tracking and proximity validation - Create compass arrow indicator pointing to destinations - Add session management with code-based access - Implement edit session and delete session functionality - Add terms and conditions page - Create completion screens with time tracking - Add tutorial navigation guide --- drizzle/0000_even_thor_girl.sql | 130 +++ drizzle/0001_new_silver_samurai.sql | 3 + drizzle/meta/0000_snapshot.json | 889 +++++++++++++++++ drizzle/meta/0001_snapshot.json | 908 ++++++++++++++++++ drizzle/meta/_journal.json | 17 +- package.json | 1 - src/lib/auth/client.ts | 17 + src/lib/components/LanguagePicker.svelte | 27 + src/lib/components/StepForm.svelte | 279 ++++++ src/lib/i18n/en.json | 138 +++ src/lib/i18n/fr.json | 138 +++ src/lib/i18n/index.ts | 51 + src/lib/server/db/schema.ts | 14 +- src/routes/(admin)/admin/+layout.server.ts | 15 + src/routes/(admin)/admin/+layout.svelte | 65 ++ src/routes/(admin)/admin/+page.server.ts | 161 ++++ src/routes/(admin)/admin/+page.svelte | 176 ++++ .../(admin)/admin/games/+page.server.ts | 62 ++ src/routes/(admin)/admin/games/+page.svelte | 150 +++ .../(admin)/admin/games/[id]/+page.server.ts | 127 +++ .../(admin)/admin/games/[id]/+page.svelte | 238 +++++ .../games/[id]/steps/[stepId]/+page.server.ts | 226 +++++ .../games/[id]/steps/[stepId]/+page.svelte | 43 + .../games/[id]/steps/new/+page.server.ts | 197 ++++ .../admin/games/[id]/steps/new/+page.svelte | 40 + .../(admin)/admin/games/new/+page.server.ts | 66 ++ .../(admin)/admin/games/new/+page.svelte | 74 ++ .../(admin)/admin/sessions/+page.server.ts | 81 ++ .../(admin)/admin/sessions/+page.svelte | 160 +++ .../admin/sessions/[id]/+page.server.ts | 131 +++ .../(admin)/admin/sessions/[id]/+page.svelte | 102 ++ .../admin/sessions/new/+page.server.ts | 116 +++ .../(admin)/admin/sessions/new/+page.svelte | 87 ++ src/routes/(game)/+layout.svelte | 9 + src/routes/(game)/game/+page.server.ts | 86 ++ src/routes/(game)/game/+page.svelte | 62 ++ .../game/play/[sessionCode]/+layout.server.ts | 120 +++ .../game/play/[sessionCode]/+layout.svelte | 189 ++++ .../game/play/[sessionCode]/+page.server.ts | 214 +++++ .../game/play/[sessionCode]/+page.svelte | 513 ++++++++++ .../[sessionCode]/complete/+page.server.ts | 46 + .../play/[sessionCode]/complete/+page.svelte | 27 + .../play/[sessionCode]/tutorial/+page.svelte | 29 + src/routes/(game)/terms/+page.svelte | 25 + src/routes/+layout.svelte | 3 +- src/routes/+page.svelte | 36 +- src/routes/layout.css | 4 + src/routes/login/+page.server.ts | 106 ++ src/routes/login/+page.svelte | 143 +++ 49 files changed, 6531 insertions(+), 10 deletions(-) create mode 100644 drizzle/0000_even_thor_girl.sql create mode 100644 drizzle/0001_new_silver_samurai.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/lib/auth/client.ts create mode 100644 src/lib/components/LanguagePicker.svelte create mode 100644 src/lib/components/StepForm.svelte create mode 100644 src/lib/i18n/en.json create mode 100644 src/lib/i18n/fr.json create mode 100644 src/lib/i18n/index.ts create mode 100644 src/routes/(admin)/admin/+layout.server.ts create mode 100644 src/routes/(admin)/admin/+layout.svelte create mode 100644 src/routes/(admin)/admin/+page.server.ts create mode 100644 src/routes/(admin)/admin/+page.svelte create mode 100644 src/routes/(admin)/admin/games/+page.server.ts create mode 100644 src/routes/(admin)/admin/games/+page.svelte create mode 100644 src/routes/(admin)/admin/games/[id]/+page.server.ts create mode 100644 src/routes/(admin)/admin/games/[id]/+page.svelte create mode 100644 src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts create mode 100644 src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte create mode 100644 src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts create mode 100644 src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte create mode 100644 src/routes/(admin)/admin/games/new/+page.server.ts create mode 100644 src/routes/(admin)/admin/games/new/+page.svelte create mode 100644 src/routes/(admin)/admin/sessions/+page.server.ts create mode 100644 src/routes/(admin)/admin/sessions/+page.svelte create mode 100644 src/routes/(admin)/admin/sessions/[id]/+page.server.ts create mode 100644 src/routes/(admin)/admin/sessions/[id]/+page.svelte create mode 100644 src/routes/(admin)/admin/sessions/new/+page.server.ts create mode 100644 src/routes/(admin)/admin/sessions/new/+page.svelte create mode 100644 src/routes/(game)/+layout.svelte create mode 100644 src/routes/(game)/game/+page.server.ts create mode 100644 src/routes/(game)/game/+page.svelte create mode 100644 src/routes/(game)/game/play/[sessionCode]/+layout.server.ts create mode 100644 src/routes/(game)/game/play/[sessionCode]/+layout.svelte create mode 100644 src/routes/(game)/game/play/[sessionCode]/+page.server.ts create mode 100644 src/routes/(game)/game/play/[sessionCode]/+page.svelte create mode 100644 src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts create mode 100644 src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte create mode 100644 src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte create mode 100644 src/routes/(game)/terms/+page.svelte create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte diff --git a/drizzle/0000_even_thor_girl.sql b/drizzle/0000_even_thor_girl.sql new file mode 100644 index 0000000..6c5cd2b --- /dev/null +++ b/drizzle/0000_even_thor_girl.sql @@ -0,0 +1,130 @@ +CREATE TYPE "public"."step_type" AS ENUM('question', 'text', 'puzzle', 'location');--> statement-breakpoint +CREATE TABLE "escape_game" ( + "id" serial PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "description" text, + "is_deleted" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "game_session" ( + "id" serial PRIMARY KEY NOT NULL, + "escape_game_id" integer NOT NULL, + "code" varchar(10) NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "started_at" timestamp, + "completed_at" timestamp, + "is_active" integer DEFAULT 1 NOT NULL, + CONSTRAINT "game_session_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "item" ( + "id" serial PRIMARY KEY NOT NULL, + "escape_game_id" integer NOT NULL, + "step_id" integer, + "name" text NOT NULL, + "description" text, + "image_url" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player" ( + "id" serial PRIMARY KEY NOT NULL, + "game_session_id" integer NOT NULL, + "cgu_accepted" boolean DEFAULT false NOT NULL, + "joined_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session_item" ( + "id" serial PRIMARY KEY NOT NULL, + "game_session_id" integer NOT NULL, + "item_id" integer NOT NULL, + "collected_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session_progress" ( + "id" serial PRIMARY KEY NOT NULL, + "game_session_id" integer NOT NULL, + "step_id" integer NOT NULL, + "completed_at" timestamp, + "attempts" integer DEFAULT 0 NOT NULL, + "last_attempt_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "step" ( + "id" serial PRIMARY KEY NOT NULL, + "escape_game_id" integer NOT NULL, + "title" text NOT NULL, + "description" text, + "type" "step_type" NOT NULL, + "order" integer NOT NULL, + "content" text, + "answer" text, + "hint" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "game_session" ADD CONSTRAINT "game_session_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "item" ADD CONSTRAINT "item_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "item" ADD CONSTRAINT "item_step_id_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "player" ADD CONSTRAINT "player_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_item" ADD CONSTRAINT "session_item_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_item" ADD CONSTRAINT "session_item_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_progress" ADD CONSTRAINT "session_progress_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_progress" ADD CONSTRAINT "session_progress_step_id_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "step" ADD CONSTRAINT "step_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/0001_new_silver_samurai.sql b/drizzle/0001_new_silver_samurai.sql new file mode 100644 index 0000000..d4f7386 --- /dev/null +++ b/drizzle/0001_new_silver_samurai.sql @@ -0,0 +1,3 @@ +ALTER TABLE "step" ADD COLUMN "latitude" double precision;--> statement-breakpoint +ALTER TABLE "step" ADD COLUMN "longitude" double precision;--> statement-breakpoint +ALTER TABLE "step" ADD COLUMN "proximity_radius" integer DEFAULT 50; \ 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..ac0a030 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,889 @@ +{ + "id": "6bb611c5-8d24-4601-a6ea-fe00ce6d0f5c", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.escape_game": { + "name": "escape_game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_session": { + "name": "game_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "game_session_escape_game_id_escape_game_id_fk": { + "name": "game_session_escape_game_id_escape_game_id_fk", + "tableFrom": "game_session", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_session_code_unique": { + "name": "game_session_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "item_escape_game_id_escape_game_id_fk": { + "name": "item_escape_game_id_escape_game_id_fk", + "tableFrom": "item", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_step_id_step_id_fk": { + "name": "item_step_id_step_id_fk", + "tableFrom": "item", + "tableTo": "step", + "columnsFrom": [ + "step_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player": { + "name": "player", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cgu_accepted": { + "name": "cgu_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_game_session_id_game_session_id_fk": { + "name": "player_game_session_id_game_session_id_fk", + "tableFrom": "player", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_item": { + "name": "session_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_item_game_session_id_game_session_id_fk": { + "name": "session_item_game_session_id_game_session_id_fk", + "tableFrom": "session_item", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_item_item_id_item_id_fk": { + "name": "session_item_item_id_item_id_fk", + "tableFrom": "session_item", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_progress": { + "name": "session_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_progress_game_session_id_game_session_id_fk": { + "name": "session_progress_game_session_id_game_session_id_fk", + "tableFrom": "session_progress", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_progress_step_id_step_id_fk": { + "name": "session_progress_step_id_step_id_fk", + "tableFrom": "session_progress", + "tableTo": "step", + "columnsFrom": [ + "step_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.step": { + "name": "step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "step_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "step_escape_game_id_escape_game_id_fk": { + "name": "step_escape_game_id_escape_game_id_fk", + "tableFrom": "step", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.step_type": { + "name": "step_type", + "schema": "public", + "values": [ + "question", + "text", + "puzzle", + "location" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..0db8873 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,908 @@ +{ + "id": "fe46bb64-72d0-4fc1-849b-4867f5f2209c", + "prevId": "6bb611c5-8d24-4601-a6ea-fe00ce6d0f5c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.escape_game": { + "name": "escape_game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_session": { + "name": "game_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "game_session_escape_game_id_escape_game_id_fk": { + "name": "game_session_escape_game_id_escape_game_id_fk", + "tableFrom": "game_session", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_session_code_unique": { + "name": "game_session_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "item_escape_game_id_escape_game_id_fk": { + "name": "item_escape_game_id_escape_game_id_fk", + "tableFrom": "item", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_step_id_step_id_fk": { + "name": "item_step_id_step_id_fk", + "tableFrom": "item", + "tableTo": "step", + "columnsFrom": [ + "step_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player": { + "name": "player", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cgu_accepted": { + "name": "cgu_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_game_session_id_game_session_id_fk": { + "name": "player_game_session_id_game_session_id_fk", + "tableFrom": "player", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_item": { + "name": "session_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_item_game_session_id_game_session_id_fk": { + "name": "session_item_game_session_id_game_session_id_fk", + "tableFrom": "session_item", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_item_item_id_item_id_fk": { + "name": "session_item_item_id_item_id_fk", + "tableFrom": "session_item", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_progress": { + "name": "session_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_session_id": { + "name": "game_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_progress_game_session_id_game_session_id_fk": { + "name": "session_progress_game_session_id_game_session_id_fk", + "tableFrom": "session_progress", + "tableTo": "game_session", + "columnsFrom": [ + "game_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_progress_step_id_step_id_fk": { + "name": "session_progress_step_id_step_id_fk", + "tableFrom": "session_progress", + "tableTo": "step", + "columnsFrom": [ + "step_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.step": { + "name": "step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "escape_game_id": { + "name": "escape_game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "step_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "proximity_radius": { + "name": "proximity_radius", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 50 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "step_escape_game_id_escape_game_id_fk": { + "name": "step_escape_game_id_escape_game_id_fk", + "tableFrom": "step", + "tableTo": "escape_game", + "columnsFrom": [ + "escape_game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.step_type": { + "name": "step_type", + "schema": "public", + "values": [ + "question", + "text", + "puzzle", + "location" + ] + } + }, + "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 index eaa8fcf..f6999ad 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,5 +1,20 @@ { "version": "7", "dialect": "postgresql", - "entries": [] + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1772972870708, + "tag": "0000_even_thor_girl", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772977993829, + "tag": "0001_new_silver_samurai", + "breakpoints": true + } + ] } \ No newline at end of file diff --git a/package.json b/package.json index 173c6c0..0b57988 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:reset": "drizzle-kit drop && drizzle-kit push", "db:studio": "drizzle-kit studio", "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" }, diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts new file mode 100644 index 0000000..c687ca6 --- /dev/null +++ b/src/lib/auth/client.ts @@ -0,0 +1,17 @@ +import { createAuthClient } from 'better-auth/svelte'; +import { browser } from '$app/environment'; + +export const authClient = browser ? createAuthClient({ + baseURL: typeof window !== 'undefined' ? window.location.origin : '' +}) : null; + +export function useAuth() { + if (!authClient) return null; + + return { + signUp: authClient.signUp, + signIn: authClient.signIn, + signOut: authClient.signOut, + getSession: authClient.getSession + }; +} diff --git a/src/lib/components/LanguagePicker.svelte b/src/lib/components/LanguagePicker.svelte new file mode 100644 index 0000000..7a57b5c --- /dev/null +++ b/src/lib/components/LanguagePicker.svelte @@ -0,0 +1,27 @@ + + +
+ {#each availableLanguages as lang (lang)} + + {/each} +
diff --git a/src/lib/components/StepForm.svelte b/src/lib/components/StepForm.svelte new file mode 100644 index 0000000..ce5d64b --- /dev/null +++ b/src/lib/components/StepForm.svelte @@ -0,0 +1,279 @@ + + +
+
+
+ + ← Back to {gameTitle} + +

{heading}

+

{subheading}

+
+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if fieldConfig.showAnswer} +
+ + +
+ {/if} + + {#if fieldConfig.showHint} +
+ + +
+ {/if} + + {#if fieldConfig.showLocation} +
+

Location Coordinates

+
+
+ + +
+
+ + +
+
+ + +

Distance in meters within which the step will be validated (default: 50m)

+
+
+
+ {/if} +
+ + {#if errorMessage} +
+ {errorMessage} +
+ {/if} + +
+ + + Cancel + +
+
+
+
+
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json new file mode 100644 index 0000000..8741a46 --- /dev/null +++ b/src/lib/i18n/en.json @@ -0,0 +1,138 @@ +{ + "common": { + "language": "Language", + "selectLanguage": "Select Language", + "english": "English", + "french": "Français", + "german": "Deutsch", + "spanish": "Español" + }, + "home": { + "title": "Outdoor Escape Game", + "subtitle": "Adventure awaits! Enter your session code to start playing.", + "playGame": "Play Game", + "joinWithCode": "Join with a session code" + }, + "game": { + "sessionCode": "Session Code", + "enterSessionCode": "Enter session code", + "yourName": "Your Name", + "enterYourName": "Enter your name", + "acceptTerms": "I accept the", + "termsAndConditions": "terms and conditions", + "joinGame": "Join Game", + "joining": "Joining...", + "pleaseEnterCode": "Please enter a session code", + "pleaseEnterName": "Please enter your name", + "mustAcceptTerms": "You must accept the terms and conditions", + "failedToJoin": "Failed to join session", + "errorOccurred": "An error occurred. Please try again." + }, + "gameplay": { + "progress": "Progress", + "step": "Step", + "of": "of", + "currentStep": "Current Step", + "yourAnswer": "Your Answer", + "enterYourAnswer": "Enter your answer", + "submitAnswer": "Submit Answer", + "checking": "Checking...", + "incorrectAnswer": "Incorrect answer", + "needAHint": "Need a hint?", + "continue": "Continue", + "loadingStep": "Loading step...", + "collectedItems": "Collected Items", + "inventory": "Inventory", + "previous": "Previous", + "next": "Next", + "emptyInventory": "No collected items yet.", + "viewingUnlockedStep": "You are viewing an already unlocked step. Go back to the active step to continue progression.", + "completedLabel": "Escape completed", + "completedTitle": "Great job, you completed the escape game!", + "completedIn": "Total time", + "playAgain": "Play again", + "sessionCode": "Session code", + "tutorial": "Tutorial", + "tutorialTitle": "Navigation bar guide", + "tutorialIntro": "This bar helps you move quickly during the game:", + "tutorialPrevious": "Previous: go back to the previous unlocked step.", + "tutorialInventory": "Inventory: open the collected items section.", + "tutorialNext": "Next: move to the next step only if it is already unlocked.", + "backToGame": "Back to game", + "locationError": "Location error", + "locatingYou": "Locating your position...", + "distance": "Distance", + "arrived": "You've arrived!", + "getWithin": "Get within", + "toValidate": "to validate", + "validateLocation": "Validate Location", + "locationDenied": "Location Access Denied", + "locationDeniedMessage": "Please enable location access in your browser settings to continue with this step.", + "locationRequired": "Location Access Required", + "locationRequiredMessage": "This step requires your location to show you the way to the destination.", + "enableLocation": "Enable Location", + "tryAgain": "Try Again" + }, + "admin": { + "adminDashboard": "Admin Dashboard", + "createNewGame": "Create New Game", + "createSession": "Create Session", + "createSessionDescription": "Create a game session and generate an access code for players.", + "selectGame": "Select a game", + "expiresDate": "Expiration date", + "expiresTime": "Expiration time", + "expiresAtDateTime": "Expiration date and time", + "expiresAtDateTimeHelp": "Choose when this session should expire.", + "cancel": "Cancel", + "createGameBeforeSession": "You need at least one game before creating a session.", + "totalGames": "Total Games", + "activeSessions": "Active Sessions", + "totalPlayers": "Total Players", + "escapeGames": "Escape Games", + "gameTitle": "Game Title", + "steps": "Steps", + "sessions": "Sessions", + "created": "Created", + "actions": "Actions", + "edit": "Edit", + "delete": "Delete", + "confirmDeleteSessionTitle": "Delete session", + "confirmDeleteSession": "Are you sure you want to delete session", + "confirmDeleteTitle": "Delete game", + "confirmDeleteGame": "Are you sure you want to delete", + "confirmDelete": "Delete permanently", + "manage": "Manage", + "editSession": "Edit Session", + "editSessionDescription": "Update game, expiration date, and active status.", + "saveChanges": "Save changes", + "noGamesYet": "No escape games yet", + "createFirstGame": "Create Your First Game", + "recentSessions": "Recent Sessions", + "currentAndIncomingSessions": "Current and Incoming Sessions", + "meanResolutionTime": "Mean Resolution Time by Game", + "noResolutionData": "No completed sessions yet.", + "current": "Current", + "incoming": "Incoming", + "noCurrentOrIncomingSessions": "No current or incoming sessions", + "code": "Code", + "game": "Game", + "status": "Status", + "players": "Players", + "expires": "Expires", + "active": "Active", + "inactive": "Inactive", + "noSessions": "No sessions yet", + "logout": "Logout" + }, + "login": { + "login": "Login", + "signup": "Sign Up", + "accessAdmin": "Access the admin dashboard", + "createAccount": "Create an admin account", + "emptyFields": "Please fill all fields", + "authFailed": "Authentication failed", + "hasAccount": "Already have an account?", + "noAccount": "Don't have an account?", + "loading": "Loading..." + } +} diff --git a/src/lib/i18n/fr.json b/src/lib/i18n/fr.json new file mode 100644 index 0000000..d0a77fc --- /dev/null +++ b/src/lib/i18n/fr.json @@ -0,0 +1,138 @@ +{ + "common": { + "language": "Langue", + "selectLanguage": "Sélectionnez la Langue", + "english": "English", + "french": "Français", + "german": "Deutsch", + "spanish": "Español" + }, + "home": { + "title": "Jeu d'Évasion en Plein Air", + "subtitle": "L'aventure vous attend ! Entrez votre code de session pour commencer à jouer.", + "playGame": "Jouez", + "joinWithCode": "Rejoignez avec un code de session" + }, + "game": { + "sessionCode": "Code de Session", + "enterSessionCode": "Entrez le code de session", + "yourName": "Votre Nom", + "enterYourName": "Entrez votre nom", + "acceptTerms": "J'accepte les", + "termsAndConditions": "conditions d'utilisation", + "joinGame": "Rejoindre le Jeu", + "joining": "Connexion...", + "pleaseEnterCode": "Veuillez entrer un code de session", + "pleaseEnterName": "Veuillez entrer votre nom", + "mustAcceptTerms": "Vous devez accepter les conditions d'utilisation", + "failedToJoin": "Échec de la connexion à la session", + "errorOccurred": "Une erreur s'est produite. Veuillez réessayer." + }, + "gameplay": { + "progress": "Progression", + "step": "Étape", + "of": "sur", + "currentStep": "Étape Actuelle", + "yourAnswer": "Votre Réponse", + "enterYourAnswer": "Entrez votre réponse", + "submitAnswer": "Soumettre la Réponse", + "checking": "Vérification...", + "incorrectAnswer": "Réponse incorrecte", + "needAHint": "Besoin d'un indice ?", + "continue": "Continuer", + "loadingStep": "Chargement de l'étape...", + "collectedItems": "Articles Collectés", + "inventory": "Inventaire", + "previous": "Précédent", + "next": "Suivant", + "emptyInventory": "Aucun objet collecté pour le moment.", + "viewingUnlockedStep": "Vous consultez une étape déjà débloquée. Revenez à l'étape active pour continuer la progression.", + "completedLabel": "Escape termine", + "completedTitle": "Bravo, vous avez termine l'escape game !", + "completedIn": "Temps total", + "playAgain": "Rejouer", + "sessionCode": "Code session", + "tutorial": "Tutoriel", + "tutorialTitle": "Guide de la barre de navigation", + "tutorialIntro": "Cette barre vous aide a naviguer rapidement pendant le jeu :", + "tutorialPrevious": "Precedent : revenir a l'etape debloquee precedente.", + "tutorialInventory": "Inventaire : ouvrir la zone des objets recoltes.", + "tutorialNext": "Suivant : aller a l'etape suivante uniquement si elle est deja debloquee.", + "backToGame": "Retour au jeu", + "locationError": "Erreur de localisation", + "locatingYou": "Localisation en cours...", + "distance": "Distance", + "arrived": "Vous êtes arrivé !", + "getWithin": "Approchez-vous à", + "toValidate": "pour valider", + "validateLocation": "Valider la position", + "locationDenied": "Accès à la localisation refusé", + "locationDeniedMessage": "Veuillez activer l'accès à la localisation dans les paramètres de votre navigateur pour continuer cette étape.", + "locationRequired": "Accès à la localisation requis", + "locationRequiredMessage": "Cette étape nécessite votre position pour vous montrer le chemin vers la destination.", + "enableLocation": "Activer la localisation", + "tryAgain": "Réessayer" + }, + "admin": { + "adminDashboard": "Tableau de Bord Admin", + "createNewGame": "Créer un Nouveau Jeu", + "createSession": "Créer une Session", + "createSessionDescription": "Créez une session de jeu et générez un code d'accès pour les joueurs.", + "selectGame": "Sélectionnez un jeu", + "expiresDate": "Date d'expiration", + "expiresTime": "Heure d'expiration", + "expiresAtDateTime": "Date et heure d'expiration", + "expiresAtDateTimeHelp": "Choisissez quand cette session doit expirer.", + "cancel": "Annuler", + "createGameBeforeSession": "Vous avez besoin d'au moins un jeu avant de créer une session.", + "totalGames": "Jeux Totaux", + "activeSessions": "Sessions Actives", + "totalPlayers": "Joueurs Totaux", + "escapeGames": "Jeux d'Évasion", + "gameTitle": "Titre du Jeu", + "steps": "Étapes", + "sessions": "Sessions", + "created": "Créé", + "actions": "Actions", + "edit": "Modifier", + "delete": "Supprimer", + "confirmDeleteSessionTitle": "Supprimer la session", + "confirmDeleteSession": "Voulez-vous vraiment supprimer la session", + "confirmDeleteTitle": "Supprimer le jeu", + "confirmDeleteGame": "Voulez-vous vraiment supprimer", + "confirmDelete": "Supprimer definitivement", + "manage": "Gérer", + "editSession": "Modifier la session", + "editSessionDescription": "Mettez a jour le jeu, la date d'expiration et le statut actif.", + "saveChanges": "Enregistrer les modifications", + "noGamesYet": "Aucun jeu d'évasion pour le moment", + "createFirstGame": "Créez Votre Premier Jeu", + "recentSessions": "Sessions Récentes", + "currentAndIncomingSessions": "Sessions en cours et a venir", + "meanResolutionTime": "Temps moyen de resolution par jeu", + "noResolutionData": "Aucune session terminee pour le moment.", + "current": "En cours", + "incoming": "A venir", + "noCurrentOrIncomingSessions": "Aucune session en cours ou a venir", + "code": "Code", + "game": "Jeu", + "status": "Statut", + "players": "Joueurs", + "expires": "Expire", + "active": "Actif", + "inactive": "Inactif", + "noSessions": "Aucune session", + "logout": "Déconnexion" + }, + "login": { + "login": "Connexion", + "signup": "Inscription", + "accessAdmin": "Accédez au tableau de bord d'administration", + "createAccount": "Créer un compte administrateur", + "emptyFields": "Veuillez remplir tous les champs", + "authFailed": "L'authentification a échoué", + "hasAccount": "Vous avez déjà un compte ?", + "noAccount": "Vous n'avez pas de compte ?", + "loading": "Chargement..." + } +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..2e8b4db --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,51 @@ +import { writable, derived } from 'svelte/store'; +import type { Writable, Readable } from 'svelte/store'; + +import en from './en.json'; +import fr from './fr.json'; + +type Messages = typeof en; + +const translations: Record = { en, fr }; + +// Get initial language +function getInitialLanguage(): string { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('language'); + if (stored && stored in translations) { + return stored; + } + const browserLang = navigator.language.split('-')[0]; + if (browserLang in translations) { + return browserLang; + } + } + return 'en'; +} + +// Create writable store for the current language +export const language: Writable = writable(getInitialLanguage()); + +// Create derived store for the current messages +export const t: Readable = derived(language, ($language) => { + return translations[$language] || translations['en']; +}); + +export function setLanguage(lang: string) { + if (lang in translations) { + if (typeof window !== 'undefined') { + localStorage.setItem('language', lang); + } + language.set(lang); + } +} + +export function getLanguage(): string { + let currentLang = 'en'; + language.subscribe((lang) => { + currentLang = lang; + })(); + return currentLang; +} + +export const availableLanguages = Object.keys(translations); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 2f2fb5b..8f7f518 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,14 +1,15 @@ -import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean } from 'drizzle-orm/pg-core'; +import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean, doublePrecision } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; // Enum for step types -export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'challenge', 'photo', 'location']); +export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'location']); -// Escape Game table +// Escape Game table (soft delete) export const escapeGame = pgTable('escape_game', { id: serial('id').primaryKey(), title: text('title').notNull(), description: text('description'), + isDeleted: boolean('is_deleted').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); @@ -23,9 +24,12 @@ export const step = pgTable('step', { description: text('description'), type: stepTypeEnum('type').notNull(), order: integer('order').notNull(), // Order of the step in the game - content: text('content'), // Question text, puzzle instructions, etc. - answer: text('answer'), // Expected answer for question/puzzle types + content: text('content'), // Question text, puzzle, etc. + answer: text('answer'), // Expected answer for question types hint: text('hint'), // Optional hint for the step + latitude: doublePrecision('latitude'), // Target latitude for location steps + longitude: doublePrecision('longitude'), // Target longitude for location steps + proximityRadius: integer('proximity_radius').default(50), // Proximity radius in meters (default: 50m) createdAt: timestamp('created_at').defaultNow().notNull(), }); diff --git a/src/routes/(admin)/admin/+layout.server.ts b/src/routes/(admin)/admin/+layout.server.ts new file mode 100644 index 0000000..72f9139 --- /dev/null +++ b/src/routes/(admin)/admin/+layout.server.ts @@ -0,0 +1,15 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { auth } from '$lib/server/auth'; + +export const load: LayoutServerLoad = async (event) => { + const session = await auth.api.getSession({ headers: event.request.headers }); + + if (!session?.user) { + redirect(303, '/login'); + } + + return { + user: session.user + }; +}; diff --git a/src/routes/(admin)/admin/+layout.svelte b/src/routes/(admin)/admin/+layout.svelte new file mode 100644 index 0000000..90576a2 --- /dev/null +++ b/src/routes/(admin)/admin/+layout.svelte @@ -0,0 +1,65 @@ + + +
+ + +
+
+

{$t.admin?.adminDashboard || 'Admin'}

+
+ {@render children()} +
+
diff --git a/src/routes/(admin)/admin/+page.server.ts b/src/routes/(admin)/admin/+page.server.ts new file mode 100644 index 0000000..b75ff84 --- /dev/null +++ b/src/routes/(admin)/admin/+page.server.ts @@ -0,0 +1,161 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { auth } from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import { gameSession, player } from '$lib/server/db/schema'; +import { and, eq, gt } from 'drizzle-orm'; + +type Game = { + id: number; + title: string; + description?: string; + stepCount?: number; + sessionCount?: number; + createdAt: string; +}; + +type Session = { + id: number; + code: string; + gameName: string; + isActive: boolean; + startedAt?: string; + completedAt?: string; + playerCount?: number; + expiresAt: string; +}; + +type ResolutionMetric = { + gameId: number; + gameTitle: string; + meanResolutionMinutes: number; + completedSessions: number; +}; + +export const load: PageServerLoad = async () => { + const gamesRaw = await db.query.escapeGame.findMany({ + orderBy: (escapeGame, { desc }) => [desc(escapeGame.createdAt)], + with: { + steps: true, + sessions: true + } + }); + + const games: Game[] = gamesRaw.map((game) => ({ + id: game.id, + title: game.title, + description: game.description ?? undefined, + stepCount: game.steps.length, + sessionCount: game.sessions.length, + createdAt: game.createdAt.toISOString() + })); + + const resolutionMetrics: ResolutionMetric[] = gamesRaw + .map((game) => { + const completedDurations = game.sessions + .filter((session) => session.startedAt && session.completedAt) + .map((session) => { + const startedAt = new Date(session.startedAt as Date).getTime(); + const completedAt = new Date(session.completedAt as Date).getTime(); + return Math.max(0, completedAt - startedAt); + }); + + if (completedDurations.length === 0) { + return null; + } + + const totalMs = completedDurations.reduce((sum, duration) => sum + duration, 0); + const meanResolutionMinutes = totalMs / completedDurations.length / 60000; + + return { + gameId: game.id, + gameTitle: game.title, + meanResolutionMinutes, + completedSessions: completedDurations.length + }; + }) + .filter((metric): metric is ResolutionMetric => metric !== null) + .sort((a, b) => b.meanResolutionMinutes - a.meanResolutionMinutes); + + const maxMeanResolutionMinutes = + resolutionMetrics.length > 0 + ? Math.max(...resolutionMetrics.map((metric) => metric.meanResolutionMinutes)) + : 0; + + const totalGames = games.length; + const activeSessions = await db.$count(gameSession, eq(gameSession.isActive, 1)); + const totalPlayers = await db.$count(player); + + const recentSessionsRaw = await db.query.gameSession.findMany({ + orderBy: (session, { desc }) => [desc(session.createdAt)], + limit: 10, + with: { + escapeGame: true, + players: true + } + }); + + const currentAndIncomingRaw = await db.query.gameSession.findMany({ + where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())), + with: { + escapeGame: true, + players: true + } + }); + + const currentAndIncomingSessions: Session[] = currentAndIncomingRaw + .map((session) => ({ + id: session.id, + code: session.code, + gameName: session.escapeGame.title, + isActive: session.isActive === 1, + startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined, + completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined, + playerCount: session.players.length, + expiresAt: session.expiresAt.toISOString() + })) + .sort((a, b) => { + const aIsCurrent = Boolean(a.startedAt) && !a.completedAt; + const bIsCurrent = Boolean(b.startedAt) && !b.completedAt; + + if (aIsCurrent !== bIsCurrent) { + return aIsCurrent ? -1 : 1; + } + + return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime(); + }) + .slice(0, 10); + + const recentSessions: Session[] = recentSessionsRaw.map((session) => ({ + id: session.id, + code: session.code, + gameName: session.escapeGame.title, + isActive: session.isActive === 1, + startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined, + completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined, + playerCount: session.players.length, + expiresAt: session.expiresAt.toISOString() + })); + + return { + stats: { + totalGames, + activeSessions, + totalPlayers + }, + resolutionMetrics, + maxMeanResolutionMinutes, + games, + currentAndIncomingSessions, + recentSessions + }; +}; + +export const actions: Actions = { + logout: async (event) => { + await auth.api.signOut({ + headers: event.request.headers + }); + redirect(302, '/admin/login'); + } +}; diff --git a/src/routes/(admin)/admin/+page.svelte b/src/routes/(admin)/admin/+page.svelte new file mode 100644 index 0000000..40cf939 --- /dev/null +++ b/src/routes/(admin)/admin/+page.svelte @@ -0,0 +1,176 @@ + + +
+
+
+

{$t.admin.adminDashboard}

+ + {$t.admin.createSession} + +
+ +
+
+
+
+

{$t.admin.totalGames}

+

{data.stats?.totalGames || 0}

+
+
+ + + +
+
+
+ +
+
+
+

{$t.admin.activeSessions}

+

{data.stats?.activeSessions || 0}

+
+
+ + + +
+
+
+ +
+
+
+

{$t.admin.totalPlayers}

+

{data.stats?.totalPlayers || 0}

+
+
+ + + +
+
+
+
+ + +
+
+

{$t.admin.currentAndIncomingSessions}

+
+ + {#if data.currentAndIncomingSessions && data.currentAndIncomingSessions.length > 0} +
+ + + + + + + + + + + + {#each data.currentAndIncomingSessions as session (session.id)} + + + + + + + + {/each} + +
+ {$t.admin.code} + + {$t.admin.game} + + {$t.admin.status} + + {$t.admin.players} + + {$t.admin.expires} +
+ {session.code} + + {session.gameName} + + + {session.startedAt && !session.completedAt ? $t.admin.current : $t.admin.incoming} + + + {session.playerCount || 0} + + {new Date(session.expiresAt).toLocaleString()} +
+
+ {:else} +
+ {$t.admin.noCurrentOrIncomingSessions} +
+ {/if} +
+ + +
+
+

{$t.admin.meanResolutionTime}

+
+ + {#if data.resolutionMetrics && data.resolutionMetrics.length > 0} +
+ {#each data.resolutionMetrics as metric (metric.gameId)} +
+
+
{metric.gameTitle}
+
+ {formatDuration(metric.meanResolutionMinutes)} + ({metric.completedSessions}) +
+
+
+
+
+
+ {/each} +
+ {:else} +
+

{$t.admin.noResolutionData}

+
+ {/if} +
+
+
diff --git a/src/routes/(admin)/admin/games/+page.server.ts b/src/routes/(admin)/admin/games/+page.server.ts new file mode 100644 index 0000000..deca9e6 --- /dev/null +++ b/src/routes/(admin)/admin/games/+page.server.ts @@ -0,0 +1,62 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export const load: PageServerLoad = async () => { + const gamesRaw = await db.query.escapeGame.findMany({ + orderBy: (game, { desc }) => [desc(game.createdAt)], + where: (game, { eq }) => eq(game.isDeleted, false), + with: { + steps: true, + sessions: true + } + }); + + const games = gamesRaw.map((game) => ({ + id: game.id, + title: game.title, + description: game.description ?? undefined, + stepCount: game.steps.length, + sessionCount: game.sessions.length, + createdAt: game.createdAt.toISOString() + })); + + if (!Array.isArray(games)) { + error(500, 'Failed to load games'); + } + + return { + games + }; +}; + +export const actions: Actions = { + deleteGame: async ({ request }) => { + const formData = await request.formData(); + const rawGameId = formData.get('gameId')?.toString() ?? ''; + const gameId = Number.parseInt(rawGameId, 10); + + if (!Number.isInteger(gameId) || gameId <= 0) { + return fail(400, { + error: 'Invalid game ID' + }); + } + + const existingGame = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId), + columns: { id: true } + }); + + if (!existingGame) { + return fail(404, { + error: 'Game not found' + }); + } + + await db.update(escapeGame).set({ isDeleted: true }).where(eq(escapeGame.id, gameId)); + + return { success: true }; + } +}; diff --git a/src/routes/(admin)/admin/games/+page.svelte b/src/routes/(admin)/admin/games/+page.svelte new file mode 100644 index 0000000..c5a39a2 --- /dev/null +++ b/src/routes/(admin)/admin/games/+page.svelte @@ -0,0 +1,150 @@ + + +
+
+
+
+

{$t.admin.escapeGames}

+

{$t.admin.manage}

+
+ + {$t.admin.createNewGame} + +
+ +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + + + + + + + + + + {#if data.games && data.games.length > 0} + {#each data.games as game (game.id)} + + + + + + + + {/each} + {:else} + + + + {/if} + +
+ {$t.admin.gameTitle} + + {$t.admin.steps} + + {$t.admin.sessions} + + {$t.admin.created} + + {$t.admin.actions} +
+
{game.title}
+ {#if game.description} +
{game.description}
+ {/if} +
{game.stepCount}{game.sessionCount} + {new Date(game.createdAt).toLocaleDateString()} + +
+ + + {$t.admin.edit} + + +
+
+ {$t.admin.noGamesYet} +
+
+
+
+
+ +{#if gameToDelete} + +{/if} diff --git a/src/routes/(admin)/admin/games/[id]/+page.server.ts b/src/routes/(admin)/admin/games/[id]/+page.server.ts new file mode 100644 index 0000000..99be553 --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/+page.server.ts @@ -0,0 +1,127 @@ +import { error, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame, step } from '$lib/server/db/schema'; +import { and, eq } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ params }) => { + const gameId = parseInt(params.id, 10); + + if (isNaN(gameId)) { + error(400, 'Invalid game ID'); + } + + const game = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId), + with: { + steps: { + orderBy: (steps) => steps.order + } + } + }); + + if (!game) { + error(404, 'Game not found'); + } + + return { + game + }; +}; + +export const actions: Actions = { + reorderSteps: async ({ params, request }) => { + const gameId = Number.parseInt(params.id, 10); + if (Number.isNaN(gameId)) { + return fail(400, { error: 'Invalid game ID' }); + } + + const formData = await request.formData(); + const orderRaw = formData.get('order')?.toString() ?? ''; + + if (!orderRaw) { + return fail(400, { error: 'Missing order payload' }); + } + + let orderedIds: number[] = []; + try { + const parsed = JSON.parse(orderRaw); + if (!Array.isArray(parsed)) { + return fail(400, { error: 'Invalid order payload' }); + } + orderedIds = parsed + .map((value) => Number.parseInt(String(value), 10)) + .filter((value) => Number.isInteger(value) && value > 0); + } catch { + return fail(400, { error: 'Invalid JSON payload' }); + } + + if (orderedIds.length === 0) { + return fail(400, { error: 'No steps to reorder' }); + } + + const uniqueIds = new Set(orderedIds); + if (uniqueIds.size !== orderedIds.length) { + return fail(400, { error: 'Duplicate step IDs in payload' }); + } + + const gameSteps = await db + .select({ id: step.id }) + .from(step) + .where(eq(step.escapeGameId, gameId)); + + if (gameSteps.length !== orderedIds.length) { + return fail(400, { error: 'Order payload does not match game steps' }); + } + + const gameStepIds = new Set(gameSteps.map((gameStep) => gameStep.id)); + for (const id of orderedIds) { + if (!gameStepIds.has(id)) { + return fail(400, { error: 'Order payload contains invalid step IDs' }); + } + } + + await db.transaction(async (tx) => { + for (let index = 0; index < orderedIds.length; index += 1) { + const stepId = orderedIds[index]; + await tx + .update(step) + .set({ order: index + 1 }) + .where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId))); + } + }); + + return { success: true }; + }, + + deleteStep: async ({ params, request }) => { + const gameId = Number.parseInt(params.id, 10); + if (Number.isNaN(gameId)) { + return fail(400, { error: 'Invalid game ID' }); + } + + const formData = await request.formData(); + const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10); + + if (Number.isNaN(stepId)) { + return fail(400, { error: 'Invalid step ID' }); + } + + // Verify the step exists and belongs to this game + const existingStep = await db.query.step.findFirst({ + where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId)) + }); + + if (!existingStep) { + return fail(404, { error: 'Step not found' }); + } + + try { + await db.delete(step).where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId))); + return { success: true }; + } catch (err) { + console.error('Delete step error:', err); + return fail(500, { error: 'Failed to delete step' }); + } + } +}; diff --git a/src/routes/(admin)/admin/games/[id]/+page.svelte b/src/routes/(admin)/admin/games/[id]/+page.svelte new file mode 100644 index 0000000..ee4856f --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/+page.svelte @@ -0,0 +1,238 @@ + + +
+
+ +
+ + ← Back to Dashboard + +
+

{game.title}

+ {#if game.description} +

{game.description}

+ {/if} +
+ Created: {new Date(game.createdAt).toLocaleDateString()} + Steps: {game.steps?.length || 0} +
+
+
+ + +
+
+
+

Game Steps

+

Glissez-deposez pour reordonner

+ + + Add Step + +
+
+ +
+ +
+ + {#if steps.length > 0} +
+ {#each steps as step (step.id)} +
onDragStart(step.id)} + ondragover={onDragOver} + ondrop={() => onDrop(step.id)} + > +
+
+
+ :: + + Step {step.order} + + {step.type} +
+

{step.title}

+ {#if step.description} +

{step.description}

+ {/if} +
+
+ + + Edit + + +
+
+
+ {/each} +
+ {:else} +
+ + + +

No steps yet. Add your first step to get started.

+ + Add First Step + +
+ {/if} +
+
+
+ +{#if stepToDelete} + +{/if} + +{#if stepToDelete} + +{/if} diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts new file mode 100644 index 0000000..2ee9768 --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts @@ -0,0 +1,226 @@ +import { fail, redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame, step } from '$lib/server/db/schema'; +import { and, eq, max } from 'drizzle-orm'; + +const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; +type StepType = (typeof stepTypes)[number]; + +const isStepType = (value: string): value is StepType => + stepTypes.includes(value as StepType); + +export const load: PageServerLoad = async ({ params }) => { + const gameId = Number.parseInt(params.id, 10); + const stepId = Number.parseInt(params.stepId, 10); + + if (Number.isNaN(gameId) || Number.isNaN(stepId)) { + error(400, 'Invalid ID'); + } + + const game = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId) + }); + + if (!game) { + error(404, 'Game not found'); + } + + const gameStep = await db.query.step.findFirst({ + where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId)) + }); + + if (!gameStep) { + error(404, 'Step not found'); + } + + // Get the total number of steps + const lastStep = await db + .select({ order: max(step.order) }) + .from(step) + .where(eq(step.escapeGameId, gameId)) + .then((result) => result[0]?.order ?? 0); + + return { + game, + step: gameStep, + totalSteps: lastStep + }; +}; + +export const actions: Actions = { + default: async ({ params, request }) => { + const gameId = Number.parseInt(params.id, 10); + const stepId = Number.parseInt(params.stepId, 10); + + if (Number.isNaN(gameId) || Number.isNaN(stepId)) { + return fail(400, { error: 'Invalid ID' }); + } + + const formData = await request.formData(); + const title = formData.get('title')?.toString().trim() ?? ''; + const description = formData.get('description')?.toString().trim() ?? ''; + const type = formData.get('type')?.toString() ?? ''; + const content = formData.get('content')?.toString().trim() ?? ''; + const answer = formData.get('answer')?.toString().trim() ?? ''; + const hint = formData.get('hint')?.toString().trim() ?? ''; + const order = Number.parseInt(formData.get('order')?.toString() ?? '1', 10); + const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null; + const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null; + const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50; + + if (!title) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + error: 'Le titre est requis' + }); + } + + if (!type) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + error: 'Le type est requis' + }); + } + + if (!isStepType(type)) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + error: 'Type invalide' + }); + } + + // Validate location-specific fields + if (type === 'location') { + if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + latitude: latitude?.toString() ?? '', + longitude: longitude?.toString() ?? '', + proximityRadius, + error: 'Latitude and longitude are required for location steps' + }); + } + if (latitude < -90 || latitude > 90) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + latitude: latitude.toString(), + longitude: longitude?.toString() ?? '', + proximityRadius, + error: 'Latitude must be between -90 and 90' + }); + } + if (longitude < -180 || longitude > 180) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + latitude: latitude?.toString() ?? '', + longitude: longitude.toString(), + proximityRadius, + error: 'Longitude must be between -180 and 180' + }); + } + } + + // Get the current total steps to validate order + const lastStep = await db + .select({ order: max(step.order) }) + .from(step) + .where(eq(step.escapeGameId, gameId)) + .then((result) => result[0]?.order ?? 0); + + if (!Number.isInteger(order) || order < 1 || order > lastStep) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + error: `L'ordre doit etre entre 1 et ${lastStep}` + }); + } + + try { + const updated = await db + .update(step) + .set({ + title, + description: description || null, + type, + order, + content: content || null, + answer: answer || null, + hint: hint || null, + latitude, + longitude, + proximityRadius + }) + .where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId))) + .returning({ id: step.id }); + + if (!updated[0]) { + return fail(404, { + title, + description, + type, + content, + answer, + hint, + order, + error: 'Etape introuvable' + }); + } + } catch (err) { + console.error('Update step error:', err); + return fail(500, { + title, + description, + type, + content, + answer, + hint, + order, + error: 'Une erreur est survenue' + }); + } + + redirect(302, `/admin/games/${gameId}`); + } +}; diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte new file mode 100644 index 0000000..7a45eb6 --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte @@ -0,0 +1,43 @@ + + + diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts new file mode 100644 index 0000000..230c848 --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts @@ -0,0 +1,197 @@ +import { fail, redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame, step } from '$lib/server/db/schema'; +import { eq, max } from 'drizzle-orm'; + +const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; +type StepType = (typeof stepTypes)[number]; + +const isStepType = (value: string): value is StepType => + stepTypes.includes(value as StepType); + +export const load: PageServerLoad = async ({ params }) => { + const gameId = parseInt(params.id, 10); + + if (isNaN(gameId)) { + error(400, 'Invalid game ID'); + } + + const game = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId) + }); + + if (!game) { + error(404, 'Game not found'); + } + + // Get the highest step order + const lastStep = await db + .select({ order: max(step.order) }) + .from(step) + .where(eq(step.escapeGameId, gameId)) + .then((result) => result[0]?.order ?? 0); + + const nextStepOrder = lastStep + 1; + + return { + game, + nextStepOrder, + totalSteps: lastStep + }; +}; + +export const actions: Actions = { + default: async ({ params, request }) => { + const gameId = parseInt(params.id, 10); + + if (isNaN(gameId)) { + return fail(400, { error: 'Invalid game ID' }); + } + + const formData = await request.formData(); + const title = formData.get('title')?.toString().trim() ?? ''; + const description = formData.get('description')?.toString().trim() ?? ''; + const type = formData.get('type')?.toString() ?? ''; + const content = formData.get('content')?.toString().trim() ?? ''; + const answer = formData.get('answer')?.toString().trim() ?? ''; + const hint = formData.get('hint')?.toString().trim() ?? ''; + const order = parseInt(formData.get('order')?.toString() ?? '1', 10); + const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null; + const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null; + const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50; + + if (!title) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + error: 'Le titre est requis' + }); + } + + if (!type) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + error: 'Le type est requis' + }); + } + + if (!isStepType(type)) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + error: 'Type invalide' + }); + } + + // Validate location-specific fields + if (type === 'location') { + if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + latitude: latitude?.toString() ?? '', + longitude: longitude?.toString() ?? '', + proximityRadius, + error: 'Latitude and longitude are required for location steps' + }); + } + if (latitude < -90 || latitude > 90) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + latitude: latitude.toString(), + longitude: longitude?.toString() ?? '', + proximityRadius, + error: 'Latitude must be between -90 and 90' + }); + } + if (longitude < -180 || longitude > 180) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + latitude: latitude?.toString() ?? '', + longitude: longitude.toString(), + proximityRadius, + error: 'Longitude must be between -180 and 180' + }); + } + } + + // Get the current total steps to validate order + const lastStep = await db + .select({ order: max(step.order) }) + .from(step) + .where(eq(step.escapeGameId, gameId)) + .then((result) => result[0]?.order ?? 0); + + const maxOrder = lastStep + 1; + + if (!Number.isInteger(order) || order < 1 || order > maxOrder) { + return fail(400, { + title, + description, + type, + content, + answer, + hint, + order, + error: `L'ordre doit etre entre 1 et ${maxOrder}` + }); + } + + try { + await db.insert(step).values({ + escapeGameId: gameId, + title, + description: description || null, + type, + order, + content: content || null, + answer: answer || null, + hint: hint || null, + latitude, + longitude, + proximityRadius + }); + } catch (error) { + console.error('Create step error:', error); + return fail(500, { + title, + description, + type, + content, + answer, + hint, + error: 'Une erreur est survenue' + }); + } + + redirect(302, `/admin/games/${gameId}`); + } +}; diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte new file mode 100644 index 0000000..e7ca16e --- /dev/null +++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte @@ -0,0 +1,40 @@ + + + diff --git a/src/routes/(admin)/admin/games/new/+page.server.ts b/src/routes/(admin)/admin/games/new/+page.server.ts new file mode 100644 index 0000000..d3e546c --- /dev/null +++ b/src/routes/(admin)/admin/games/new/+page.server.ts @@ -0,0 +1,66 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame } from '$lib/server/db/schema'; + +export const actions: Actions = { + default: async (event) => { + const formData = await event.request.formData(); + const title = formData.get('title')?.toString().trim() ?? ''; + const description = formData.get('description')?.toString().trim() ?? ''; + let createdGameId: number | null = null; + + if (!title) { + return fail(400, { + title, + description, + error: 'Le titre est requis' + }); + } + + if (title.length < 3) { + return fail(400, { + title, + description, + error: 'Le titre doit contenir au moins 3 caractères' + }); + } + + try { + const result = await db + .insert(escapeGame) + .values({ + title, + description: description || null + }) + .returning({ id: escapeGame.id }); + + if (!result[0]) { + return fail(500, { + title, + description, + error: 'Erreur lors de la création du jeu' + }); + } + + createdGameId = result[0].id; + } catch (error) { + console.error('Create game error:', error); + return fail(500, { + title, + description, + error: 'Une erreur est survenue' + }); + } + + if (createdGameId === null) { + return fail(500, { + title, + description, + error: 'Erreur lors de la création du jeu' + }); + } + + redirect(302, `/admin/games/${createdGameId}`); + } +}; diff --git a/src/routes/(admin)/admin/games/new/+page.svelte b/src/routes/(admin)/admin/games/new/+page.svelte new file mode 100644 index 0000000..d345667 --- /dev/null +++ b/src/routes/(admin)/admin/games/new/+page.svelte @@ -0,0 +1,74 @@ + + +
+
+
+

Create New Game

+

Create a new escape game and add steps to it.

+
+ +
+
+
+ + +

Minimum 3 characters

+
+ +
+ + +

Optional

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + Cancel + +
+
+
+
+
diff --git a/src/routes/(admin)/admin/sessions/+page.server.ts b/src/routes/(admin)/admin/sessions/+page.server.ts new file mode 100644 index 0000000..af8c528 --- /dev/null +++ b/src/routes/(admin)/admin/sessions/+page.server.ts @@ -0,0 +1,81 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { gameSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ url }) => { + const requestedGameId = Number.parseInt(url.searchParams.get('gameId') ?? '', 10); + const selectedGameId = Number.isInteger(requestedGameId) && requestedGameId > 0 ? requestedGameId : null; + + const gamesRaw = await db.query.escapeGame.findMany({ + orderBy: (game, { asc }) => [asc(game.title)] + }); + + const sessionsRaw = await db.query.gameSession.findMany({ + where: selectedGameId ? eq(gameSession.escapeGameId, selectedGameId) : undefined, + orderBy: (session, { desc }) => [desc(session.createdAt)], + with: { + escapeGame: true, + players: true + } + }); + + return { + selectedGameId, + games: gamesRaw.map((game) => ({ + id: game.id, + title: game.title + })), + sessions: sessionsRaw.map((session) => ({ + id: session.id, + code: session.code, + gameId: session.escapeGameId, + gameTitle: session.escapeGame.title, + isActive: session.isActive === 1, + players: session.players.length, + createdAt: session.createdAt.toISOString(), + expiresAt: session.expiresAt.toISOString(), + startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : null, + completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : null + })) + }; +}; + +export const actions: Actions = { + toggleStatus: async ({ request, url }) => { + const formData = await request.formData(); + const sessionId = Number.parseInt(formData.get('sessionId')?.toString() ?? '', 10); + const currentStatus = Number.parseInt(formData.get('currentStatus')?.toString() ?? '', 10); + + if (!Number.isInteger(sessionId) || sessionId <= 0) { + return fail(400, { error: 'Invalid session ID' }); + } + + if (![0, 1].includes(currentStatus)) { + return fail(400, { error: 'Invalid status value' }); + } + + await db + .update(gameSession) + .set({ isActive: currentStatus === 1 ? 0 : 1 }) + .where(eq(gameSession.id, sessionId)); + + redirect(303, `${url.pathname}${url.search}`); + }, + deleteSession: async ({ request, url }) => { + const formData = await request.formData(); + const sessionId = Number.parseInt(formData.get('sessionId')?.toString() ?? '', 10); + + if (!Number.isInteger(sessionId) || sessionId <= 0) { + return fail(400, { error: 'Invalid session ID' }); + } + + await db.delete(gameSession).where(eq(gameSession.id, sessionId)); + + return { + success: true, + redirectTo: `${url.pathname}${url.search}` + }; + } +}; diff --git a/src/routes/(admin)/admin/sessions/+page.svelte b/src/routes/(admin)/admin/sessions/+page.svelte new file mode 100644 index 0000000..b81ae93 --- /dev/null +++ b/src/routes/(admin)/admin/sessions/+page.svelte @@ -0,0 +1,160 @@ + + +
+
+
+
+

{$t.admin.sessions}

+

{$t.admin.recentSessions}

+
+ +
+ + {$t.admin.createSession} + +
+ + + +
+
+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
+ + + + + + + + + + + + + + {#if data.sessions.length > 0} + {#each data.sessions as session (session.id)} + + + + + + + + + + {/each} + {:else} + + + + {/if} + +
{$t.admin.code}{$t.admin.game}{$t.admin.status}{$t.admin.players}{$t.admin.created}{$t.admin.expires}{$t.admin.actions}
{session.code}{session.gameTitle} + + {session.isActive ? $t.admin.active : $t.admin.inactive} + + {session.players}{new Date(session.createdAt).toLocaleString()}{new Date(session.expiresAt).toLocaleString()} +
+ + + {$t.admin.edit} + + +
+
{$t.admin.noSessions}
+
+
+
+
+ +{#if sessionToDelete} + +{/if} diff --git a/src/routes/(admin)/admin/sessions/[id]/+page.server.ts b/src/routes/(admin)/admin/sessions/[id]/+page.server.ts new file mode 100644 index 0000000..aa37592 --- /dev/null +++ b/src/routes/(admin)/admin/sessions/[id]/+page.server.ts @@ -0,0 +1,131 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame, gameSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +function toDateTimeLocalInputValue(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + const hours = `${date.getHours()}`.padStart(2, '0'); + const minutes = `${date.getMinutes()}`.padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +export const load: PageServerLoad = async ({ params }) => { + const sessionId = Number.parseInt(params.id, 10); + if (!Number.isInteger(sessionId) || sessionId <= 0) { + error(400, 'Invalid session ID'); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.id, sessionId), + with: { + escapeGame: true + } + }); + + if (!session) { + error(404, 'Session not found'); + } + + const gamesRaw = await db.query.escapeGame.findMany({ + orderBy: (game, { asc }) => [asc(game.title)], + columns: { + id: true, + title: true + } + }); + + return { + session: { + id: session.id, + code: session.code, + gameId: session.escapeGameId, + expiresAt: toDateTimeLocalInputValue(session.expiresAt), + isActive: session.isActive === 1 + }, + games: gamesRaw + }; +}; + +export const actions: Actions = { + default: async ({ params, request }) => { + const formData = await request.formData(); + const rawGameId = formData.get('gameId')?.toString() ?? ''; + const rawExpiresAt = formData.get('expiresAt')?.toString() ?? ''; + const isActive = formData.get('isActive') === 'on'; + + const sessionId = Number.parseInt(params.id, 10); + if (!Number.isInteger(sessionId) || sessionId <= 0) { + return fail(400, { + error: 'Invalid session ID', + gameId: rawGameId, + expiresAt: rawExpiresAt, + isActive + }); + } + + const gameId = Number.parseInt(rawGameId, 10); + const expiresAt = new Date(rawExpiresAt); + + if (!Number.isInteger(gameId) || gameId <= 0) { + return fail(400, { + error: 'Please select a valid game', + gameId: rawGameId, + expiresAt: rawExpiresAt, + isActive + }); + } + + if (!rawExpiresAt || Number.isNaN(expiresAt.getTime())) { + return fail(400, { + error: 'Please provide a valid expiration date and time', + gameId: rawGameId, + expiresAt: rawExpiresAt, + isActive + }); + } + + const existingGame = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId), + columns: { id: true } + }); + + if (!existingGame) { + return fail(404, { + error: 'Selected game does not exist', + gameId: rawGameId, + expiresAt: rawExpiresAt, + isActive + }); + } + + const existingSession = await db.query.gameSession.findFirst({ + where: eq(gameSession.id, sessionId), + columns: { id: true } + }); + + if (!existingSession) { + return fail(404, { + error: 'Session not found', + gameId: rawGameId, + expiresAt: rawExpiresAt, + isActive + }); + } + + await db + .update(gameSession) + .set({ + escapeGameId: gameId, + expiresAt, + isActive: isActive ? 1 : 0 + }) + .where(eq(gameSession.id, sessionId)); + + redirect(303, `/admin/sessions?gameId=${gameId}`); + } +}; diff --git a/src/routes/(admin)/admin/sessions/[id]/+page.svelte b/src/routes/(admin)/admin/sessions/[id]/+page.svelte new file mode 100644 index 0000000..689071f --- /dev/null +++ b/src/routes/(admin)/admin/sessions/[id]/+page.svelte @@ -0,0 +1,102 @@ + + +
+
+
+

{$t.admin.editSession}

+

{$t.admin.editSessionDescription}

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +

{$t.admin.expiresAtDateTimeHelp}

+
+ +
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + {$t.admin.cancel} + +
+
+
+
+
diff --git a/src/routes/(admin)/admin/sessions/new/+page.server.ts b/src/routes/(admin)/admin/sessions/new/+page.server.ts new file mode 100644 index 0000000..0201ed7 --- /dev/null +++ b/src/routes/(admin)/admin/sessions/new/+page.server.ts @@ -0,0 +1,116 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { escapeGame, gameSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +const SESSION_CODE_LENGTH = 6; +const SESSION_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const MAX_SESSION_CODE_ATTEMPTS = 10; + +function generateSessionCode(): string { + return Array.from({ length: SESSION_CODE_LENGTH }, () => { + const randomIndex = Math.floor(Math.random() * SESSION_CODE_ALPHABET.length); + return SESSION_CODE_ALPHABET[randomIndex]; + }).join(''); +} + +async function createUniqueSessionCode(): Promise { + for (let attempt = 0; attempt < MAX_SESSION_CODE_ATTEMPTS; attempt += 1) { + const code = generateSessionCode(); + const existing = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, code), + columns: { id: true } + }); + + if (!existing) { + return code; + } + } + + return null; +} + +export const load: PageServerLoad = async ({ url }) => { + const requestedGameId = Number.parseInt(url.searchParams.get('gameId') ?? '', 10); + const selectedGameId = Number.isInteger(requestedGameId) && requestedGameId > 0 ? requestedGameId : null; + + const gamesRaw = await db.query.escapeGame.findMany({ + orderBy: (game, { asc }) => [asc(game.title)], + columns: { + id: true, + title: true + } + }); + + return { + selectedGameId, + games: gamesRaw + }; +}; + +export const actions: Actions = { + default: async ({ request }) => { + const formData = await request.formData(); + const rawGameId = formData.get('gameId')?.toString() ?? ''; + const rawExpiresAt = formData.get('expiresAt')?.toString() ?? ''; + + const gameId = Number.parseInt(rawGameId, 10); + const expiresAt = new Date(rawExpiresAt); + + if (!Number.isInteger(gameId) || gameId <= 0) { + return fail(400, { + error: 'Please select a valid game', + gameId: rawGameId, + expiresAt: rawExpiresAt + }); + } + + if (!rawExpiresAt || Number.isNaN(expiresAt.getTime())) { + return fail(400, { + error: 'Please provide a valid expiration date and time', + gameId: rawGameId, + expiresAt: rawExpiresAt + }); + } + + if (expiresAt.getTime() <= Date.now()) { + return fail(400, { + error: 'Expiration date must be in the future', + gameId: rawGameId, + expiresAt: rawExpiresAt + }); + } + + const existingGame = await db.query.escapeGame.findFirst({ + where: eq(escapeGame.id, gameId), + columns: { id: true } + }); + + if (!existingGame) { + return fail(404, { + error: 'Selected game does not exist', + gameId: rawGameId, + expiresAt: rawExpiresAt + }); + } + + const code = await createUniqueSessionCode(); + if (!code) { + return fail(500, { + error: 'Unable to generate session code. Please retry.', + gameId: rawGameId, + expiresAt: rawExpiresAt + }); + } + + await db.insert(gameSession).values({ + escapeGameId: gameId, + code, + expiresAt, + isActive: 1 + }); + + redirect(303, `/admin/sessions?gameId=${gameId}`); + } +}; diff --git a/src/routes/(admin)/admin/sessions/new/+page.svelte b/src/routes/(admin)/admin/sessions/new/+page.svelte new file mode 100644 index 0000000..6afb753 --- /dev/null +++ b/src/routes/(admin)/admin/sessions/new/+page.svelte @@ -0,0 +1,87 @@ + + +
+
+
+

{$t.admin.createSession}

+

{$t.admin.createSessionDescription}

+
+ +
+
+
+ + +
+ +
+ + +

{$t.admin.expiresAtDateTimeHelp}

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + {$t.admin.cancel} + +
+
+
+ + {#if data.games.length === 0} +

+ {$t.admin.createGameBeforeSession} + {$t.admin.createNewGame} +

+ {/if} +
+
diff --git a/src/routes/(game)/+layout.svelte b/src/routes/(game)/+layout.svelte new file mode 100644 index 0000000..f901bcd --- /dev/null +++ b/src/routes/(game)/+layout.svelte @@ -0,0 +1,9 @@ + + +
+
+ {@render children()} +
+
diff --git a/src/routes/(game)/game/+page.server.ts b/src/routes/(game)/game/+page.server.ts new file mode 100644 index 0000000..444077e --- /dev/null +++ b/src/routes/(game)/game/+page.server.ts @@ -0,0 +1,86 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { db } from '$lib/server/db'; +import { gameSession, player } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export const actions: Actions = { + joinSession: async (event) => { + const formData = await event.request.formData(); + const code = (formData.get('code') as string)?.toUpperCase(); + const cguAccepted = formData.get('cguAccepted') === 'on'; + let sessionCode: string | null = null; + + if (!code?.trim()) { + return fail(400, { + error: 'Please enter a session code' + }); + } + + if (!cguAccepted) { + return fail(400, { + error: 'You must accept the terms and conditions' + }); + } + + try { + // Find the session by code + const sessions = await db + .select() + .from(gameSession) + .where(eq(gameSession.code, code)); + + if (!sessions || sessions.length === 0) { + return fail(404, { + error: 'Session not found. Please check the code.' + }); + } + + const session = sessions[0]; + + // Check if session is active + if (!session.isActive) { + return fail(400, { + error: 'This session is no longer active.' + }); + } + + // Check if session has expired + if (session.expiresAt && new Date(session.expiresAt) < new Date()) { + return fail(400, { + error: 'This session has expired.' + }); + } + + // Create player + await db.insert(player).values({ + gameSessionId: session.id, + cguAccepted + }); + + // Start timer when the first player joins the session + if (!session.startedAt) { + await db + .update(gameSession) + .set({ startedAt: new Date() }) + .where(eq(gameSession.id, session.id)); + } + + sessionCode = session.code; + + } catch (err) { + console.error('Join session error:', err); + return fail(500, { + error: 'An error occurred. Please try again.' + }); + } + + if (sessionCode === null) { + return fail(500, { + error: 'An error occurred. Please try again.' + }); + } + + redirect(302, `/game/play/${sessionCode}`); + } +}; diff --git a/src/routes/(game)/game/+page.svelte b/src/routes/(game)/game/+page.svelte new file mode 100644 index 0000000..fee7107 --- /dev/null +++ b/src/routes/(game)/game/+page.svelte @@ -0,0 +1,62 @@ + + +
+
+

{$t.home.title}

+

{$t.home.subtitle}

+ +
+
+ + +
+ +
+ + +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+
+
diff --git a/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts new file mode 100644 index 0000000..96d5f47 --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts @@ -0,0 +1,120 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { gameSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export const load: LayoutServerLoad = async ({ params, url }) => { + const sessionCode = params.sessionCode.toUpperCase().trim(); + + if (!sessionCode) { + error(400, 'Invalid session code'); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, sessionCode), + with: { + escapeGame: { + with: { + steps: { + orderBy: (steps, { asc }) => [asc(steps.order)] + } + } + }, + progress: { + with: { + step: true + } + }, + collectedItems: { + with: { + item: true + } + } + } + }); + + if (!session) { + error(404, 'Session not found'); + } + + if (session.isActive !== 1) { + error(400, 'Session is inactive'); + } + + if (session.expiresAt && new Date(session.expiresAt) < new Date()) { + error(400, 'Session has expired'); + } + + const steps = session.escapeGame.steps; + const totalSteps = steps.length; + + const completedStepIds = new Set( + session.progress.filter((entry) => entry.completedAt !== null).map((entry) => entry.stepId) + ); + + const currentStep = steps.find((entry) => !completedStepIds.has(entry.id)) ?? null; + const currentStepOrder = currentStep?.order ?? totalSteps; + + if (!currentStep && !url.pathname.endsWith('/complete')) { + if (!session.completedAt) { + await db + .update(gameSession) + .set({ completedAt: new Date() }) + .where(eq(gameSession.id, session.id)); + } + + redirect(303, `/game/play/${session.code}/complete`); + } + + const unlockedSteps = currentStep + ? steps.filter((entry) => entry.order <= currentStep.order) + : steps; + + const requestedStepId = Number.parseInt(url.searchParams.get('step') ?? '', 10); + const isRequestedStepUnlocked = unlockedSteps.some((entry) => entry.id === requestedStepId); + + const displayedStepRecord = isRequestedStepUnlocked + ? (unlockedSteps.find((entry) => entry.id === requestedStepId) ?? null) + : (currentStep ?? unlockedSteps.at(-1) ?? null); + + const displayedStepIndex = displayedStepRecord + ? unlockedSteps.findIndex((entry) => entry.id === displayedStepRecord.id) + : -1; + + const previousStepId = displayedStepIndex > 0 ? unlockedSteps[displayedStepIndex - 1].id : null; + const nextStepId = + displayedStepIndex >= 0 && displayedStepIndex < unlockedSteps.length - 1 + ? unlockedSteps[displayedStepIndex + 1].id + : null; + + return { + sessionCode: session.code, + currentStepOrder, + totalSteps, + activeStepId: currentStep?.id ?? null, + displayedStep: displayedStepRecord + ? { + id: displayedStepRecord.id, + title: displayedStepRecord.title, + description: displayedStepRecord.description ?? undefined, + content: displayedStepRecord.content ?? undefined, + type: displayedStepRecord.type, + hint: displayedStepRecord.hint ?? undefined + } + : null, + unlockedSteps: unlockedSteps.map((entry) => ({ + id: entry.id, + order: entry.order, + title: entry.title, + isCompleted: completedStepIds.has(entry.id) + })), + previousStepId, + nextStepId, + collectedItems: session.collectedItems.map((entry) => ({ + id: entry.item.id, + name: entry.item.name, + imageUrl: entry.item.imageUrl ?? undefined + })) + }; +}; diff --git a/src/routes/(game)/game/play/[sessionCode]/+layout.svelte b/src/routes/(game)/game/play/[sessionCode]/+layout.svelte new file mode 100644 index 0000000..aa3e79b --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/+layout.svelte @@ -0,0 +1,189 @@ + + +
+ {@render children()} +
+ +{#if showMenu} + + + {#if inventoryOpen} +
{ + if (e.key === 'Escape') closeInventory(); + }} + role="button" + tabindex={0} + >
+ + {/if} +{/if} diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.server.ts b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts new file mode 100644 index 0000000..75ffddf --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts @@ -0,0 +1,214 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { db } from '$lib/server/db'; +import { gameSession, sessionProgress, step } from '$lib/server/db/schema'; +import { and, eq } from 'drizzle-orm'; + +export const actions: Actions = { + continueStep: async ({ params, request }) => { + const sessionCode = params.sessionCode.toUpperCase().trim(); + if (!sessionCode) { + return fail(400, { error: 'Invalid session code' }); + } + + const formData = await request.formData(); + const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10); + + if (!Number.isInteger(stepId) || stepId <= 0) { + return fail(400, { error: 'Invalid step ID' }); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, sessionCode) + }); + if (!session) { + return fail(404, { error: 'Session not found' }); + } + + const stepRecord = await db.query.step.findFirst({ + where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId)) + }); + + if (!stepRecord) { + return fail(404, { error: 'Step not found' }); + } + + if (stepRecord.type !== 'text') { + return fail(400, { error: 'Only text steps can be continued without an answer' }); + } + + const existingProgress = await db.query.sessionProgress.findFirst({ + where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId)) + }); + + if (existingProgress) { + await db + .update(sessionProgress) + .set({ + completedAt: existingProgress.completedAt ?? new Date() + }) + .where(eq(sessionProgress.id, existingProgress.id)); + } else { + await db.insert(sessionProgress).values({ + gameSessionId: session.id, + stepId, + attempts: 0, + completedAt: new Date() + }); + } + + redirect(303, `/game/play/${session.code}`); + }, + + submitAnswer: async ({ params, request }) => { + const sessionCode = params.sessionCode.toUpperCase().trim(); + if (!sessionCode) { + return fail(400, { error: 'Invalid session code' }); + } + + const formData = await request.formData(); + const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10); + const answer = formData.get('answer')?.toString().trim() ?? ''; + + if (!Number.isInteger(stepId) || stepId <= 0) { + return fail(400, { error: 'Invalid step ID' }); + } + + if (!answer) { + return fail(400, { error: 'Please enter your answer', answer }); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, sessionCode) + }); + if (!session) { + return fail(404, { error: 'Session not found', answer }); + } + + const stepRecord = await db.query.step.findFirst({ + where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId)) + }); + if (!stepRecord) { + return fail(404, { error: 'Step not found', answer }); + } + + const expectedAnswer = (stepRecord.answer ?? '').trim().toLowerCase(); + const submittedAnswer = answer.trim().toLowerCase(); + const isCorrect = expectedAnswer.length > 0 && expectedAnswer === submittedAnswer; + + const existingProgress = await db.query.sessionProgress.findFirst({ + where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId)) + }); + + if (existingProgress) { + await db + .update(sessionProgress) + .set({ + attempts: existingProgress.attempts + 1, + lastAttemptAt: new Date(), + completedAt: isCorrect ? new Date() : existingProgress.completedAt + }) + .where(eq(sessionProgress.id, existingProgress.id)); + } else { + await db.insert(sessionProgress).values({ + gameSessionId: session.id, + stepId, + attempts: 1, + lastAttemptAt: new Date(), + completedAt: isCorrect ? new Date() : null + }); + } + + if (!isCorrect) { + return fail(400, { error: 'Incorrect answer', answer }); + } + + redirect(303, `/game/play/${session.code}`); + }, + + validateLocation: async ({ params, request }) => { + const sessionCode = params.sessionCode.toUpperCase().trim(); + if (!sessionCode) { + return fail(400, { error: 'Invalid session code' }); + } + + const formData = await request.formData(); + const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10); + const userLat = parseFloat(formData.get('userLat')?.toString() ?? ''); + const userLon = parseFloat(formData.get('userLon')?.toString() ?? ''); + + if (!Number.isInteger(stepId) || stepId <= 0) { + return fail(400, { error: 'Invalid step ID' }); + } + + if (isNaN(userLat) || isNaN(userLon)) { + return fail(400, { error: 'Unable to get your location' }); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, sessionCode) + }); + if (!session) { + return fail(404, { error: 'Session not found' }); + } + + const stepRecord = await db.query.step.findFirst({ + where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId)) + }); + if (!stepRecord) { + return fail(404, { error: 'Step not found' }); + } + + if (stepRecord.type !== 'location') { + return fail(400, { error: 'This step is not a location step' }); + } + + if (stepRecord.latitude === null || stepRecord.longitude === null) { + return fail(400, { error: 'Location coordinates not set for this step' }); + } + + // Calculate distance using Haversine formula + const R = 6371e3; // Earth radius in meters + const φ1 = (userLat * Math.PI) / 180; + const φ2 = (stepRecord.latitude * Math.PI) / 180; + const Δφ = ((stepRecord.latitude - userLat) * Math.PI) / 180; + const Δλ = ((stepRecord.longitude - userLon) * Math.PI) / 180; + + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + const proximityRadius = stepRecord.proximityRadius ?? 50; + + if (distance > proximityRadius) { + return fail(400, { + error: `You are ${Math.round(distance)}m away. Get within ${proximityRadius}m to validate.` + }); + } + + // User is within proximity - mark step as completed + const existingProgress = await db.query.sessionProgress.findFirst({ + where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId)) + }); + + if (existingProgress) { + await db + .update(sessionProgress) + .set({ + completedAt: existingProgress.completedAt ?? new Date() + }) + .where(eq(sessionProgress.id, existingProgress.id)); + } else { + await db.insert(sessionProgress).values({ + gameSessionId: session.id, + stepId, + attempts: 0, + completedAt: new Date() + }); + } + + redirect(303, `/game/play/${session.code}`); + } +}; diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/+page.svelte new file mode 100644 index 0000000..d0a8c46 --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/+page.svelte @@ -0,0 +1,513 @@ + + +
+
+
+

{$t.home.title}

+

{$t.gameplay.progress}: {data.sessionCode || 'Loading...'}

+
+
+ +
+
+
+ {$t.gameplay.progress} + {$t.gameplay.step} {data.currentStepOrder || 0} {$t.gameplay.of} {data.totalSteps || 0} +
+
+
+
+
+ +
+ {#if currentStep} +

{currentStep.title}

+ + {#if currentStep.description} +

{currentStep.description}

+ {/if} + + {#if currentStep.content} +
+

{currentStep.content}

+
+ {/if} + + {#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep} +
{ + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + class="space-y-4" + > + +
+ + +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+ + {#if currentStep.hint} +
+ + {$t.gameplay.needAHint} + +

{currentStep.hint}

+
+ {/if} + {:else if currentStep.type === 'text' && isCurrentActiveStep} +
{ + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + > + + +
+ {:else if currentStep.type === 'location' && isCurrentActiveStep} +
{ + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + > + + + + +
+ {#if locationPermission === 'denied'} +
+
+ + + +
+

{$t.gameplay.locationDenied}

+

{locationError || $t.gameplay.locationDeniedMessage}

+
+
+ +
+ {:else if locationPermission === 'prompt'} +
+
+ + + + +
+

{$t.gameplay.locationRequired}

+

{$t.gameplay.locationRequiredMessage}

+
+
+ +
+ {:else if locationError && locationPermission === 'checking'} +
+ {$t.gameplay.locationError}: {locationError} +
+ {:else if userLat === null || userLon === null} +
+ + + + + {$t.gameplay.locatingYou} +
+ {:else} + +
+
+
+

{$t.gameplay.distance}

+

+ {#if distance !== null} + {distance < 1000 ? `${distance}m` : `${(distance / 1000).toFixed(1)}km`} + {:else} + -- + {/if} +

+
+
+ + +
+
+ + + + + + + + + + + + +
+ +
+
+ + {#if distance !== null && currentStep.proximityRadius != null} + {#if distance <= currentStep.proximityRadius} +

+ 🎯 {$t.gameplay.arrived} +

+ {:else} +

+ {$t.gameplay.getWithin} {currentStep.proximityRadius}m {$t.gameplay.toValidate} +

+ {/if} + {/if} +
+ + + + + {#if form?.error} +
+ {form.error} +
+ {/if} + {/if} +
+
+ + {#if currentStep.hint} +
+ + {$t.gameplay.needAHint} + +

{currentStep.hint}

+
+ {/if} + {:else if !isCurrentActiveStep} +

+ {$t.gameplay.viewingUnlockedStep} +

+ {:else} +

{$t.gameplay.loadingStep}

+ {/if} + {:else} +
+

{$t.gameplay.loadingStep}

+
+ {/if} +
+
+ +
diff --git a/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts b/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts new file mode 100644 index 0000000..776b72a --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts @@ -0,0 +1,46 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { gameSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +const toSafeDate = (value: Date | string | null | undefined): Date | null => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const load: PageServerLoad = async ({ params }) => { + const sessionCode = params.sessionCode.toUpperCase().trim(); + + if (!sessionCode) { + error(400, 'Invalid session code'); + } + + const session = await db.query.gameSession.findFirst({ + where: eq(gameSession.code, sessionCode), + with: { + escapeGame: true + } + }); + + if (!session) { + error(404, 'Session not found'); + } + + const startedAt = toSafeDate(session.startedAt) ?? toSafeDate(session.createdAt) ?? new Date(); + const completedAt = toSafeDate(session.completedAt) ?? new Date(); + const elapsedMs = Math.max(0, completedAt.getTime() - startedAt.getTime()); + const totalSeconds = Math.floor(elapsedMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return { + sessionCode: session.code, + gameTitle: session.escapeGame.title, + startedAt, + completedAt, + totalSeconds, + formattedDuration: `${minutes}m ${seconds.toString().padStart(2, '0')}s` + }; +}; diff --git a/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte new file mode 100644 index 0000000..aa9b94e --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte @@ -0,0 +1,27 @@ + + +
+
+

{$t.gameplay.completedLabel}

+

{$t.gameplay.completedTitle}

+

{data.gameTitle} - {$t.gameplay.sessionCode}: {data.sessionCode}

+ +
+

{$t.gameplay.completedIn}

+

{data.formattedDuration}

+
+ + + {$t.gameplay.playAgain} + +
+
diff --git a/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte new file mode 100644 index 0000000..7d2b0ee --- /dev/null +++ b/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte @@ -0,0 +1,29 @@ + + +
+
+

{$t.gameplay.tutorialTitle}

+

{$t.gameplay.tutorialIntro}

+ +
+
+ 1 +

{$t.gameplay.tutorialPrevious}

+
+
+ 2 +

{$t.gameplay.tutorialInventory}

+
+
+ 3 +

{$t.gameplay.tutorialNext}

+
+
+ +

+ {$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next} +

+
+
diff --git a/src/routes/(game)/terms/+page.svelte b/src/routes/(game)/terms/+page.svelte new file mode 100644 index 0000000..ded84ff --- /dev/null +++ b/src/routes/(game)/terms/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+ + ← Back + + +

{$t.game.termsAndConditions}

+

+ By joining a session, you agree to follow the game instructions, respect other players, + and use the platform appropriately. +

+

+ The organizer may collect minimal session data needed to operate the game + (code, progress, and participation timestamps). +

+

+ If you do not agree with these terms, please do not join the game session. +

+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0d8eb03..ae2313c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,4 +6,5 @@ -{@render children()} + +{@render children()} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..01b044e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,34 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +
+
+

+ {$t.home.title} +

+

+ {$t.home.subtitle} +

+ + +
+ +
+

{$t.common?.selectLanguage || 'Select Language'}

+ +
+
diff --git a/src/routes/layout.css b/src/routes/layout.css index d4b5078..f95fbc1 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1 +1,5 @@ @import 'tailwindcss'; + +input:where([type='datetime-local']) { + min-height: 2.75rem; +} diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..385c9f6 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,106 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { auth } from '$lib/server/auth'; +import { user } from '$lib/server/db/schema'; +import { db } from '$lib/server/db'; +import { sql } from 'drizzle-orm'; +import { APIError } from 'better-auth/api'; + +export const load: PageServerLoad = async (event) => { + const session = await auth.api.getSession({ headers: event.request.headers }); + + if (session?.user) { + redirect(303, '/admin'); + } + + // Check if any users exist + const users = await db.select().from(user).limit(1); + const noUsersExist = users.length === 0; + + return { + noUsersExist + }; +}; + +export const actions: Actions = { + signIn: async (event) => { + const formData = await event.request.formData(); + const identifier = formData.get('identifier')?.toString().trim() ?? formData.get('email')?.toString().trim() ?? ''; + const password = formData.get('password')?.toString() ?? ''; + + if (!identifier) { + return fail(400, { message: 'Email ou nom d\'utilisateur requis' }); + } + + let email = identifier; + if (!identifier.includes('@')) { + const [foundUser] = await db + .select({ email: user.email }) + .from(user) + .where(sql`lower(${user.email}) = ${identifier.toLowerCase()}`) + .limit(1); + + if (!foundUser) { + return fail(400, { message: 'Identifiants invalides' }); + } + + email = foundUser.email; + } + + try { + await auth.api.signInEmail({ + body: { + email, + password, + callbackURL: '/auth/verification-success' + } + }); + } catch (error) { + if (error instanceof APIError) { + return fail(400, { message: error.message || 'Signin failed' }); + } + return fail(500, { message: 'Unexpected error' }); + } + + return redirect(302, '/'); + }, + + createFirstUser: async (event) => { + // Check if any users already exist + const users = await db.select().from(user).limit(1); + if (users.length > 0) { + return fail(400, { message: 'Un utilisateur existe déjà' }); + } + + const formData = await event.request.formData(); + const email = formData.get('email')?.toString().trim() ?? ''; + const password = formData.get('password')?.toString() ?? ''; + const name = formData.get('name')?.toString().trim() ?? email; + + if (!email || !password) { + return fail(400, { message: 'Email et mot de passe requis' }); + } + + if (password.length < 6) { + return fail(400, { message: 'Le mot de passe doit contenir au moins 6 caractères' }); + } + + try { + await auth.api.signUpEmail({ + body: { + email, + password, + name, + callbackURL: '/admin' + } + }); + } catch (error) { + if (error instanceof APIError) { + return fail(400, { message: error.message || 'Creation failed' }); + } + return fail(500, { message: 'Unexpected error' }); + } + + return redirect(302, '/admin'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..372d9b3 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,143 @@ + + +
+
+ {#if noUsersExist} +

+ Créer le premier utilisateur +

+

+ Aucun utilisateur n'existe. Créez le premier compte administrateur. +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if form?.message} +
+ {form.message} +
+ {/if} + + +
+ {:else} +

+ {$t.login?.login || 'Login'} +

+

+ {$t.login?.accessAdmin || 'Access the admin dashboard'} +

+ +
+
+ + +
+ +
+ + +
+ + {#if form?.message} +
+ {form.message} +
+ {/if} + + +
+ {/if} + + +
+