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
This commit is contained in:
130
drizzle/0000_even_thor_girl.sql
Normal file
130
drizzle/0000_even_thor_girl.sql
Normal file
@@ -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");
|
||||
3
drizzle/0001_new_silver_samurai.sql
Normal file
3
drizzle/0001_new_silver_samurai.sql
Normal file
@@ -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;
|
||||
889
drizzle/meta/0000_snapshot.json
Normal file
889
drizzle/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
908
drizzle/meta/0001_snapshot.json
Normal file
908
drizzle/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
17
src/lib/auth/client.ts
Normal file
17
src/lib/auth/client.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
27
src/lib/components/LanguagePicker.svelte
Normal file
27
src/lib/components/LanguagePicker.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { language, setLanguage, availableLanguages } from '$lib/i18n';
|
||||
|
||||
const flags: Record<string, { flag: string; name: string }> = {
|
||||
en: { flag: '🇬🇧', name: 'English' },
|
||||
fr: { flag: '🇫🇷', name: 'Français' }
|
||||
};
|
||||
|
||||
function handleLanguageChange(lang: string) {
|
||||
setLanguage(lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-6 justify-center">
|
||||
{#each availableLanguages as lang (lang)}
|
||||
<button
|
||||
onclick={() => handleLanguageChange(lang)}
|
||||
class="w-20 flex flex-col items-center gap-2 transition-transform hover:scale-110 {$language ===
|
||||
lang
|
||||
? 'opacity-100'
|
||||
: 'opacity-60 hover:opacity-80'}"
|
||||
>
|
||||
<div class="text-3xl">{flags[lang].flag}</div>
|
||||
<span class="text-xs font-medium text-gray-700 text-center">{flags[lang].name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
279
src/lib/components/StepForm.svelte
Normal file
279
src/lib/components/StepForm.svelte
Normal file
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
type StepFormValues = {
|
||||
title: string;
|
||||
type: string;
|
||||
order: number | string;
|
||||
description: string;
|
||||
content: string;
|
||||
answer: string;
|
||||
hint: string;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
proximityRadius?: number | string;
|
||||
};
|
||||
|
||||
let {
|
||||
gameId,
|
||||
gameTitle,
|
||||
heading,
|
||||
subheading,
|
||||
submitLabel,
|
||||
errorMessage,
|
||||
initialValues,
|
||||
maxOrder
|
||||
}: {
|
||||
gameId: number;
|
||||
gameTitle: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
submitLabel: string;
|
||||
errorMessage?: string;
|
||||
initialValues: StepFormValues;
|
||||
maxOrder: number;
|
||||
} = $props();
|
||||
|
||||
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
||||
let selectedType = $derived(initialValues.type || 'question');
|
||||
|
||||
let fieldConfig = $derived.by(() => {
|
||||
switch (selectedType) {
|
||||
case 'text':
|
||||
return {
|
||||
contentLabel: 'Text to display',
|
||||
contentPlaceholder: 'Text shown to players',
|
||||
showLocation: false,
|
||||
showAnswer: false,
|
||||
showHint: false
|
||||
};
|
||||
case 'location':
|
||||
return {
|
||||
contentLabel: 'Location instruction',
|
||||
contentPlaceholder: 'Describe where players need to go',
|
||||
showLocation: true,
|
||||
showAnswer: true,
|
||||
showHint: true
|
||||
};
|
||||
case 'puzzle':
|
||||
return {
|
||||
contentLabel: 'Puzzle statement',
|
||||
contentPlaceholder: 'Describe the puzzle to solve',
|
||||
showLocation: false,
|
||||
showAnswer: true,
|
||||
showHint: true
|
||||
};
|
||||
default:
|
||||
return {
|
||||
contentLabel: 'Question',
|
||||
contentPlaceholder: 'Enter the question for players',
|
||||
showLocation: false,
|
||||
showAnswer: true,
|
||||
showHint: true
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href={resolve(`/admin/games/${gameId}`)}
|
||||
class="text-indigo-600 hover:text-indigo-700 text-sm font-medium mb-4 inline-block"
|
||||
>
|
||||
← Back to {gameTitle}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{heading}</h1>
|
||||
<p class="text-gray-600">{subheading}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<form method="POST" use:enhance class="p-8 space-y-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Step Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={initialValues.title}
|
||||
placeholder="Enter step title"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
bind:value={selectedType}
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
required
|
||||
>
|
||||
{#each stepTypes as stepType (stepType)}
|
||||
<option value={stepType}>
|
||||
{stepType.charAt(0).toUpperCase() + stepType.slice(1)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Step Order
|
||||
</label>
|
||||
<input
|
||||
id="order"
|
||||
type="number"
|
||||
name="order"
|
||||
value={initialValues.order}
|
||||
min="1"
|
||||
max={maxOrder}
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe this step"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
|
||||
>{initialValues.description}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{fieldConfig.contentLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
placeholder={fieldConfig.contentPlaceholder}
|
||||
rows="4"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
|
||||
>{initialValues.content}</textarea>
|
||||
</div>
|
||||
|
||||
{#if fieldConfig.showAnswer}
|
||||
<div class="col-span-2">
|
||||
<label for="answer" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Expected answer
|
||||
</label>
|
||||
<input
|
||||
id="answer"
|
||||
type="text"
|
||||
name="answer"
|
||||
value={initialValues.answer}
|
||||
placeholder="Expected answer for this step"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fieldConfig.showHint}
|
||||
<div class="col-span-2">
|
||||
<label for="hint" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hint
|
||||
</label>
|
||||
<textarea
|
||||
id="hint"
|
||||
name="hint"
|
||||
placeholder="Optional hint for players"
|
||||
rows="2"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
|
||||
>{initialValues.hint}</textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fieldConfig.showLocation}
|
||||
<div class="col-span-2 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 class="text-sm font-semibold text-blue-900 mb-4">Location Coordinates</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="latitude" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Latitude <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
name="latitude"
|
||||
value={initialValues.latitude ?? ''}
|
||||
placeholder="e.g., 48.8566"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="longitude" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Longitude <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
name="longitude"
|
||||
value={initialValues.longitude ?? ''}
|
||||
placeholder="e.g., 2.3522"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="proximityRadius" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Proximity Radius (meters)
|
||||
</label>
|
||||
<input
|
||||
id="proximityRadius"
|
||||
type="number"
|
||||
name="proximityRadius"
|
||||
value={initialValues.proximityRadius ?? 50}
|
||||
min="5"
|
||||
max="500"
|
||||
placeholder="50"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Distance in meters within which the step will be validated (default: 50m)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 bg-indigo-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
<a
|
||||
href={resolve(`/admin/games/${gameId}`)}
|
||||
class="flex-1 bg-gray-200 text-gray-800 py-3 px-6 rounded-lg font-semibold hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors text-center"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
138
src/lib/i18n/en.json
Normal file
138
src/lib/i18n/en.json
Normal file
@@ -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..."
|
||||
}
|
||||
}
|
||||
138
src/lib/i18n/fr.json
Normal file
138
src/lib/i18n/fr.json
Normal file
@@ -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..."
|
||||
}
|
||||
}
|
||||
51
src/lib/i18n/index.ts
Normal file
51
src/lib/i18n/index.ts
Normal file
@@ -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<string, Messages> = { 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<string> = writable(getInitialLanguage());
|
||||
|
||||
// Create derived store for the current messages
|
||||
export const t: Readable<Messages> = 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);
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
15
src/routes/(admin)/admin/+layout.server.ts
Normal file
15
src/routes/(admin)/admin/+layout.server.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
65
src/routes/(admin)/admin/+layout.svelte
Normal file
65
src/routes/(admin)/admin/+layout.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-gray-50">
|
||||
<aside class="hidden w-72 shrink-0 border-r border-gray-200 bg-white lg:flex lg:flex-col">
|
||||
<div class="border-b border-gray-200 px-6 py-5">
|
||||
<h2 class="text-lg font-bold text-gray-900">{$t.admin?.adminDashboard || 'Admin'}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">{$t.admin?.adminDashboard || 'Dashboard menu'}</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-2 px-4 py-4">
|
||||
<a
|
||||
href={resolve('/admin')}
|
||||
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
$page.url.pathname === '/admin' ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{$t.admin?.adminDashboard || 'Dashboard'}
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/admin/games')}
|
||||
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
$page.url.pathname.startsWith('/admin/games')
|
||||
? 'bg-indigo-50 text-indigo-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{$t.admin?.game || 'Games'}
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/admin/sessions')}
|
||||
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
$page.url.pathname.startsWith('/admin/sessions')
|
||||
? 'bg-indigo-50 text-indigo-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{$t.admin?.sessions || 'Sessions'}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<form method="POST" action="/admin?/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50"
|
||||
>
|
||||
{$t.admin?.logout || 'Logout'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-auto">
|
||||
<div class="border-b border-gray-200 bg-white px-4 py-3 lg:hidden">
|
||||
<p class="text-sm font-semibold text-gray-700">{$t.admin?.adminDashboard || 'Admin'}</p>
|
||||
</div>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
161
src/routes/(admin)/admin/+page.server.ts
Normal file
161
src/routes/(admin)/admin/+page.server.ts
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
176
src/routes/(admin)/admin/+page.svelte
Normal file
176
src/routes/(admin)/admin/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${Math.round(minutes)} min`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = Math.round(minutes % 60);
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.adminDashboard}</h1>
|
||||
<a
|
||||
href={resolve('/admin/sessions/new')}
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
{$t.admin.createSession}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">{$t.admin.totalGames}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.totalGames || 0}</p>
|
||||
</div>
|
||||
<div class="bg-indigo-100 rounded-full p-3">
|
||||
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">{$t.admin.activeSessions}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.activeSessions || 0}</p>
|
||||
</div>
|
||||
<div class="bg-green-100 rounded-full p-3">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">{$t.admin.totalPlayers}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.totalPlayers || 0}</p>
|
||||
</div>
|
||||
<div class="bg-blue-100 rounded-full p-3">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current & Incoming Sessions -->
|
||||
<div class="mb-8 bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">{$t.admin.currentAndIncomingSessions}</h2>
|
||||
</div>
|
||||
|
||||
{#if data.currentAndIncomingSessions && data.currentAndIncomingSessions.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.code}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.game}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.status}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.players}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.expires}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.currentAndIncomingSessions as session (session.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{session.code}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{session.gameName}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
session.startedAt && !session.completedAt
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{session.startedAt && !session.completedAt ? $t.admin.current : $t.admin.incoming}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{session.playerCount || 0}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(session.expiresAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
{$t.admin.noCurrentOrIncomingSessions}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mean Resolution Time Graph -->
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">{$t.admin.meanResolutionTime}</h2>
|
||||
</div>
|
||||
|
||||
{#if data.resolutionMetrics && data.resolutionMetrics.length > 0}
|
||||
<div class="p-6 space-y-4">
|
||||
{#each data.resolutionMetrics as metric (metric.gameId)}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{metric.gameTitle}</div>
|
||||
<div class="text-sm text-gray-600 whitespace-nowrap">
|
||||
{formatDuration(metric.meanResolutionMinutes)}
|
||||
<span class="text-gray-400">({metric.completedSessions})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-3 w-full rounded-full bg-gray-100">
|
||||
<div
|
||||
class="h-3 rounded-full bg-indigo-500"
|
||||
style={`width: ${Math.max(
|
||||
6,
|
||||
(metric.meanResolutionMinutes / (data.maxMeanResolutionMinutes || 1)) * 100
|
||||
)}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="text-gray-500">{$t.admin.noResolutionData}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
62
src/routes/(admin)/admin/games/+page.server.ts
Normal file
62
src/routes/(admin)/admin/games/+page.server.ts
Normal file
@@ -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 };
|
||||
}
|
||||
};
|
||||
150
src/routes/(admin)/admin/games/+page.svelte
Normal file
150
src/routes/(admin)/admin/games/+page.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let gameToDelete = $state<{ id: number; title: string } | null>(null);
|
||||
|
||||
function openDeleteModal(game: { id: number; title: string }) {
|
||||
gameToDelete = game;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
gameToDelete = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success === true) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.escapeGames}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{$t.admin.manage}</p>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/admin/games/new')}
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
{$t.admin.createNewGame}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-md">
|
||||
{#if form?.error}
|
||||
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{$t.admin.gameTitle}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{$t.admin.steps}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{$t.admin.sessions}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{$t.admin.created}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{$t.admin.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if data.games && data.games.length > 0}
|
||||
{#each data.games as game (game.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{game.title}</div>
|
||||
{#if game.description}
|
||||
<div class="text-sm text-gray-500">{game.description}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{game.stepCount}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{game.sessionCount}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(game.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<a
|
||||
href={resolve(`/admin/games/${game.id}`)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{$t.admin.edit}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDeleteModal({ id: game.id, title: game.title })}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
|
||||
</svg>
|
||||
{$t.admin.delete}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="px-6 py-10 text-center text-sm text-gray-500" colspan="5">
|
||||
{$t.admin.noGamesYet}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if gameToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 class="text-lg font-semibold text-gray-900">{$t.admin.confirmDeleteTitle}</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{$t.admin.confirmDeleteGame} <span class="font-semibold text-gray-900">{gameToDelete.title}</span>?
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteModal}
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
{$t.admin.cancel}
|
||||
</button>
|
||||
<form method="POST" action="?/deleteGame" use:enhance>
|
||||
<input type="hidden" name="gameId" value={gameToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
{$t.admin.confirmDelete}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
127
src/routes/(admin)/admin/games/[id]/+page.server.ts
Normal file
127
src/routes/(admin)/admin/games/[id]/+page.server.ts
Normal file
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
238
src/routes/(admin)/admin/games/[id]/+page.svelte
Normal file
238
src/routes/(admin)/admin/games/[id]/+page.svelte
Normal file
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let game = $derived.by(() => data.game);
|
||||
let steps = $derived([...(game.steps ?? [])]);
|
||||
let draggedStepId = $state<number | null>(null);
|
||||
let reorderPayload = $state('');
|
||||
let reorderForm = $state<HTMLFormElement | undefined>(undefined);
|
||||
let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null);
|
||||
|
||||
function openDeleteModal(step: { id: number; title: string; order: number }) {
|
||||
stepToDelete = step;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
stepToDelete = null;
|
||||
}
|
||||
|
||||
function moveStep(list: typeof steps, fromIndex: number, toIndex: number) {
|
||||
const clone = [...list];
|
||||
const [moved] = clone.splice(fromIndex, 1);
|
||||
if (!moved) {
|
||||
return list;
|
||||
}
|
||||
clone.splice(toIndex, 0, moved);
|
||||
return clone.map((item, index) => ({ ...item, order: index + 1 }));
|
||||
}
|
||||
|
||||
function onDragStart(stepId: number) {
|
||||
draggedStepId = stepId;
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop(targetStepId: number) {
|
||||
if (draggedStepId === null || draggedStepId === targetStepId) {
|
||||
draggedStepId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = steps.findIndex((item) => item.id === draggedStepId);
|
||||
const toIndex = steps.findIndex((item) => item.id === targetStepId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
draggedStepId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
steps = moveStep(steps, fromIndex, toIndex);
|
||||
reorderPayload = JSON.stringify(steps.map((item) => item.id));
|
||||
reorderForm?.requestSubmit();
|
||||
draggedStepId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Game Header -->
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href={resolve('/admin')}
|
||||
class="text-indigo-600 hover:text-indigo-700 text-sm font-medium mb-4 inline-block"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
<div class="bg-white rounded-xl shadow-md p-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{game.title}</h1>
|
||||
{#if game.description}
|
||||
<p class="text-gray-600 mb-4">{game.description}</p>
|
||||
{/if}
|
||||
<div class="flex gap-4 text-sm text-gray-500">
|
||||
<span>Created: {new Date(game.createdAt).toLocaleDateString()}</span>
|
||||
<span>Steps: {game.steps?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps Section -->
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div class="px-8 py-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
|
||||
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
|
||||
<a
|
||||
href={resolve(`/admin/games/${game.id}/steps/new`)}
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
+ Add Step
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="?/reorderSteps" use:enhance bind:this={reorderForm}>
|
||||
<input type="hidden" name="order" bind:value={reorderPayload} />
|
||||
</form>
|
||||
|
||||
{#if steps.length > 0}
|
||||
<div class="divide-y divide-gray-200">
|
||||
{#each steps as step (step.id)}
|
||||
<div
|
||||
class="px-8 py-6 hover:bg-gray-50 transition-colors cursor-move"
|
||||
draggable="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`Deplacer l'etape ${step.order}`}
|
||||
ondragstart={() => onDragStart(step.id)}
|
||||
ondragover={onDragOver}
|
||||
ondrop={() => onDrop(step.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="text-gray-400 text-sm">::</span>
|
||||
<span class="inline-block bg-indigo-100 text-indigo-800 px-3 py-1 rounded-full text-sm font-semibold">
|
||||
Step {step.order}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">{step.type}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{step.title}</h3>
|
||||
{#if step.description}
|
||||
<p class="text-gray-600 text-sm">{step.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<a
|
||||
href={resolve(`/admin/games/${game.id}/steps/${step.id}`)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDeleteModal({ id: step.id, title: step.title, order: step.order })}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-8 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m0 0h6"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-4">No steps yet. Add your first step to get started.</p>
|
||||
<a
|
||||
href={resolve(`/admin/games/${game.id}/steps/new`)}
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Add First Step
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stepToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Confirm Deletion</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Are you sure you want to delete <span class="font-semibold text-gray-900">Step {stepToDelete.order}: {stepToDelete.title}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteModal}
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form method="POST" action="?/deleteStep" use:enhance>
|
||||
<input type="hidden" name="stepId" value={stepToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Step
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stepToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Confirm Deletion</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Are you sure you want to delete <span class="font-semibold text-gray-900">Step {stepToDelete.order}: {stepToDelete.title}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteModal}
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form method="POST" action="?/deleteStep">
|
||||
<input type="hidden" name="stepId" value={stepToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Step
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import StepForm from '$lib/components/StepForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let formMap = $derived.by<Record<string, unknown>>(() => (form ?? {}) as Record<string, unknown>);
|
||||
|
||||
let initialValues = $derived.by(() => ({
|
||||
title: typeof formMap.title === 'string' ? formMap.title : data.step.title,
|
||||
type: typeof formMap.type === 'string' ? formMap.type : data.step.type,
|
||||
order:
|
||||
typeof formMap.order === 'number' || typeof formMap.order === 'string'
|
||||
? formMap.order
|
||||
: data.step.order,
|
||||
description:
|
||||
typeof formMap.description === 'string'
|
||||
? formMap.description
|
||||
: (data.step.description ?? ''),
|
||||
content: typeof formMap.content === 'string' ? formMap.content : (data.step.content ?? ''),
|
||||
answer: typeof formMap.answer === 'string' ? formMap.answer : (data.step.answer ?? ''),
|
||||
hint: typeof formMap.hint === 'string' ? formMap.hint : (data.step.hint ?? ''),
|
||||
latitude: typeof formMap.latitude === 'string' || typeof formMap.latitude === 'number'
|
||||
? formMap.latitude
|
||||
: data.step.latitude,
|
||||
longitude: typeof formMap.longitude === 'string' || typeof formMap.longitude === 'number'
|
||||
? formMap.longitude
|
||||
: data.step.longitude,
|
||||
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
||||
? formMap.proximityRadius
|
||||
: (data.step.proximityRadius ?? 50)
|
||||
}));
|
||||
</script>
|
||||
|
||||
<StepForm
|
||||
gameId={data.game.id}
|
||||
gameTitle={data.game.title}
|
||||
heading="Edit Step"
|
||||
subheading="Modify this step for your escape game."
|
||||
submitLabel="Save Changes"
|
||||
errorMessage={form?.error}
|
||||
{initialValues}
|
||||
maxOrder={data.totalSteps}
|
||||
/>
|
||||
197
src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts
Normal file
197
src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
};
|
||||
40
src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte
Normal file
40
src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import StepForm from '$lib/components/StepForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let formMap = $derived.by<Record<string, unknown>>(() => (form ?? {}) as Record<string, unknown>);
|
||||
|
||||
let initialValues = $derived.by(() => ({
|
||||
title: typeof formMap.title === 'string' ? formMap.title : '',
|
||||
type: typeof formMap.type === 'string' ? formMap.type : 'question',
|
||||
order:
|
||||
typeof formMap.order === 'number' || typeof formMap.order === 'string'
|
||||
? formMap.order
|
||||
: data.nextStepOrder,
|
||||
description: typeof formMap.description === 'string' ? formMap.description : '',
|
||||
content: typeof formMap.content === 'string' ? formMap.content : '',
|
||||
answer: typeof formMap.answer === 'string' ? formMap.answer : '',
|
||||
hint: typeof formMap.hint === 'string' ? formMap.hint : '',
|
||||
latitude: typeof formMap.latitude === 'string' || typeof formMap.latitude === 'number'
|
||||
? formMap.latitude
|
||||
: '',
|
||||
longitude: typeof formMap.longitude === 'string' || typeof formMap.longitude === 'number'
|
||||
? formMap.longitude
|
||||
: '',
|
||||
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
||||
? formMap.proximityRadius
|
||||
: 50
|
||||
}));
|
||||
</script>
|
||||
|
||||
<StepForm
|
||||
gameId={data.game.id}
|
||||
gameTitle={data.game.title}
|
||||
heading="Add New Step"
|
||||
subheading="Create a new step for your escape game."
|
||||
submitLabel="Create Step"
|
||||
errorMessage={form?.error}
|
||||
{initialValues}
|
||||
maxOrder={data.nextStepOrder}
|
||||
/>
|
||||
66
src/routes/(admin)/admin/games/new/+page.server.ts
Normal file
66
src/routes/(admin)/admin/games/new/+page.server.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
};
|
||||
74
src/routes/(admin)/admin/games/new/+page.svelte
Normal file
74
src/routes/(admin)/admin/games/new/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Create New Game</h1>
|
||||
<p class="text-gray-600">Create a new escape game and add steps to it.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<form method="POST" use:enhance class="p-8 space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Game Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
placeholder="Enter game title"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Minimum 3 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
bind:value={description}
|
||||
placeholder="Describe your escape game"
|
||||
rows="6"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
|
||||
></textarea>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 bg-indigo-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
Create Game
|
||||
</button>
|
||||
<a
|
||||
href="/admin"
|
||||
class="flex-1 bg-gray-200 text-gray-800 py-3 px-6 rounded-lg font-semibold hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors text-center"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
81
src/routes/(admin)/admin/sessions/+page.server.ts
Normal file
81
src/routes/(admin)/admin/sessions/+page.server.ts
Normal file
@@ -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}`
|
||||
};
|
||||
}
|
||||
};
|
||||
160
src/routes/(admin)/admin/sessions/+page.svelte
Normal file
160
src/routes/(admin)/admin/sessions/+page.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let sessionToDelete = $state<{ id: number; code: string } | null>(null);
|
||||
|
||||
function openDeleteModal(session: { id: number; code: string }) {
|
||||
sessionToDelete = session;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
sessionToDelete = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success === true) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.sessions}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{$t.admin.recentSessions}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={resolve('/admin/sessions/new')}
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
{$t.admin.createSession}
|
||||
</a>
|
||||
<form method="GET" action={resolve('/admin/sessions')} class="flex items-center gap-2">
|
||||
<label for="gameId" class="text-sm font-medium text-gray-700">{$t.admin.game}</label>
|
||||
<select
|
||||
id="gameId"
|
||||
name="gameId"
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All games</option>
|
||||
{#each data.games as game (game.id)}
|
||||
<option value={game.id} selected={data.selectedGameId === game.id}>{game.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-md">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.code}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.game}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.status}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.players}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.created}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.expires}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if data.sessions.length > 0}
|
||||
{#each data.sessions as session (session.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">{session.code}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{session.gameTitle}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${session.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{session.isActive ? $t.admin.active : $t.admin.inactive}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{session.players}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{new Date(session.createdAt).toLocaleString()}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{new Date(session.expiresAt).toLocaleString()}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<a
|
||||
href={resolve('/(admin)/admin/sessions/[id]', { id: session.id.toString() })}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{$t.admin.edit}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDeleteModal({ id: session.id, code: session.code })}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
|
||||
</svg>
|
||||
{$t.admin.delete}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="px-6 py-10 text-center text-sm text-gray-500" colspan="7">{$t.admin.noSessions}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sessionToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 class="text-lg font-semibold text-gray-900">{$t.admin.confirmDeleteSessionTitle}</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{$t.admin.confirmDeleteSession}
|
||||
<span class="font-semibold text-gray-900">{sessionToDelete.code}</span>?
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteModal}
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
{$t.admin.cancel}
|
||||
</button>
|
||||
<form method="POST" action="?/deleteSession" use:enhance>
|
||||
<input type="hidden" name="sessionId" value={sessionToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
{$t.admin.confirmDelete}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
131
src/routes/(admin)/admin/sessions/[id]/+page.server.ts
Normal file
131
src/routes/(admin)/admin/sessions/[id]/+page.server.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
};
|
||||
102
src/routes/(admin)/admin/sessions/[id]/+page.svelte
Normal file
102
src/routes/(admin)/admin/sessions/[id]/+page.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">{$t.admin.editSession}</h1>
|
||||
<p class="text-gray-600">{$t.admin.editSessionDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-md">
|
||||
<form method="POST" use:enhance class="space-y-6 p-8">
|
||||
<div>
|
||||
<label for="sessionCode" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{$t.admin.code}
|
||||
</label>
|
||||
<input
|
||||
id="sessionCode"
|
||||
type="text"
|
||||
value={data.session.code}
|
||||
readonly
|
||||
class="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-100 px-4 py-3 text-sm text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="gameId" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{$t.admin.game} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="gameId"
|
||||
name="gameId"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="">{$t.admin.selectGame}</option>
|
||||
{#each data.games as game (game.id)}
|
||||
<option value={game.id} selected={Number(form?.gameId ?? data.session.gameId) === game.id}>
|
||||
{game.title}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="expiresAt" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{$t.admin.expiresAtDateTime} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="expiresAt"
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
value={form?.expiresAt ?? data.session.expiresAt}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">{$t.admin.expiresAtDateTimeHelp}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<label for="isActive" class="flex items-center gap-3 text-sm font-medium text-gray-700">
|
||||
<input
|
||||
id="isActive"
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
checked={Boolean(form?.isActive ?? data.session.isActive)}
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>{$t.admin.active}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-indigo-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
{$t.admin.saveChanges}
|
||||
</button>
|
||||
<a
|
||||
href={resolve('/admin/sessions')}
|
||||
class="flex-1 rounded-lg bg-gray-200 px-6 py-3 text-center font-semibold text-gray-800 transition-colors hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
{$t.admin.cancel}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
116
src/routes/(admin)/admin/sessions/new/+page.server.ts
Normal file
116
src/routes/(admin)/admin/sessions/new/+page.server.ts
Normal file
@@ -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<string | null> {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
87
src/routes/(admin)/admin/sessions/new/+page.svelte
Normal file
87
src/routes/(admin)/admin/sessions/new/+page.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">{$t.admin.createSession}</h1>
|
||||
<p class="text-gray-600">{$t.admin.createSessionDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-md">
|
||||
<form method="POST" use:enhance class="space-y-6 p-8">
|
||||
<div>
|
||||
<label for="gameId" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{$t.admin.game} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="gameId"
|
||||
name="gameId"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="">{$t.admin.selectGame}</option>
|
||||
{#each data.games as game (game.id)}
|
||||
<option
|
||||
value={game.id}
|
||||
selected={Number(form?.gameId ?? data.selectedGameId ?? 0) === game.id}
|
||||
>
|
||||
{game.title}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="expiresAt" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{$t.admin.expiresAtDateTime} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="expiresAt"
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
value={form?.expiresAt ?? ''}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">{$t.admin.expiresAtDateTimeHelp}</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-indigo-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
disabled={data.games.length === 0}
|
||||
>
|
||||
{$t.admin.createSession}
|
||||
</button>
|
||||
<a
|
||||
href={resolve('/admin/sessions')}
|
||||
class="flex-1 rounded-lg bg-gray-200 px-6 py-3 text-center font-semibold text-gray-800 transition-colors hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
{$t.admin.cancel}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if data.games.length === 0}
|
||||
<p class="mt-4 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{$t.admin.createGameBeforeSession}
|
||||
<a href={resolve('/admin/games/new')} class="font-semibold underline">{$t.admin.createNewGame}</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
9
src/routes/(game)/+layout.svelte
Normal file
9
src/routes/(game)/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<main class="flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
86
src/routes/(game)/game/+page.server.ts
Normal file
86
src/routes/(game)/game/+page.server.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
};
|
||||
62
src/routes/(game)/game/+page.svelte
Normal file
62
src/routes/(game)/game/+page.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let sessionCode = $state('');
|
||||
let cguAccepted = $state(false);
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-linear-to-br from-blue-50 to-indigo-100 p-3 sm:p-4">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-5 shadow-xl sm:rounded-2xl sm:p-8">
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 sm:text-3xl">{$t.home.title}</h1>
|
||||
<p class="mb-6 text-center text-sm text-gray-600 sm:mb-8 sm:text-base">{$t.home.subtitle}</p>
|
||||
|
||||
<form method="POST" action="?/joinSession" use:enhance class="space-y-5 sm:space-y-6">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{$t.game.sessionCode}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
name="code"
|
||||
bind:value={sessionCode}
|
||||
placeholder={$t.game.enterSessionCode}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3.5 text-base uppercase focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<input
|
||||
id="cgu"
|
||||
type="checkbox"
|
||||
name="cguAccepted"
|
||||
bind:checked={cguAccepted}
|
||||
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
required
|
||||
/>
|
||||
<label for="cgu" class="ml-2 block text-sm leading-6 text-gray-700">
|
||||
{$t.game.acceptTerms} <a href={resolve('/terms')} class="text-indigo-600 hover:text-indigo-700 underline" data-sveltekit-preload-data="hover">{$t.game.termsAndConditions}</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{$t.game.joinGame}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
120
src/routes/(game)/game/play/[sessionCode]/+layout.server.ts
Normal file
120
src/routes/(game)/game/play/[sessionCode]/+layout.server.ts
Normal file
@@ -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
|
||||
}))
|
||||
};
|
||||
};
|
||||
189
src/routes/(game)/game/play/[sessionCode]/+layout.svelte
Normal file
189
src/routes/(game)/game/play/[sessionCode]/+layout.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { LayoutData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
||||
|
||||
let inventoryOpen = $state(false);
|
||||
let dragStartY = $state(0);
|
||||
let dragCurrentY = $state(0);
|
||||
|
||||
const isCompletePage = $derived($page.url.pathname.endsWith('/complete'));
|
||||
const showMenu = $derived(!isCompletePage);
|
||||
|
||||
const openInventory = () => {
|
||||
inventoryOpen = true;
|
||||
};
|
||||
|
||||
const closeInventory = () => {
|
||||
inventoryOpen = false;
|
||||
dragStartY = 0;
|
||||
dragCurrentY = 0;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
dragStartY = e.touches[0].clientY;
|
||||
dragCurrentY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
dragCurrentY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const dragDistance = dragCurrentY - dragStartY;
|
||||
if (dragDistance > 100) {
|
||||
closeInventory();
|
||||
}
|
||||
dragStartY = 0;
|
||||
dragCurrentY = 0;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!showMenu || !inventoryOpen) {
|
||||
dragStartY = 0;
|
||||
dragCurrentY = 0;
|
||||
}
|
||||
|
||||
if (!showMenu && inventoryOpen) {
|
||||
inventoryOpen = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:pb-28={showMenu}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if showMenu}
|
||||
<nav class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white/95 px-3 py-2 backdrop-blur sm:px-4">
|
||||
<div class="mx-auto grid max-w-4xl grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={openInventory}
|
||||
class="rounded-lg bg-indigo-600 px-3 py-2 text-center text-indigo-700 transition-colors hover:bg-indigo-700 flex items-center justify-center"
|
||||
aria-label="{$t.gameplay.inventory}"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4l1-12z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={resolve('/(game)/game/play/[sessionCode]/tutorial', { sessionCode: data.sessionCode })}
|
||||
class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-center transition-colors hover:bg-indigo-100 flex items-center justify-center"
|
||||
aria-label="{$t.gameplay.tutorial}"
|
||||
>
|
||||
<svg class="h-5 w-5 text-indigo-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{#if data.previousStepId}
|
||||
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
|
||||
<input type="hidden" name="step" value={data.previousStepId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
{$t.gameplay.previous}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded-lg border border-gray-200 px-3 py-2 text-center text-sm font-medium text-gray-400">
|
||||
{$t.gameplay.previous}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if data.nextStepId}
|
||||
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
|
||||
<input type="hidden" name="step" value={data.nextStepId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
{$t.gameplay.next}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded-lg border border-gray-200 px-3 py-2 text-center text-sm font-medium text-gray-400">
|
||||
{$t.gameplay.next}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{#if inventoryOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300"
|
||||
onclick={closeInventory}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') closeInventory();
|
||||
}}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
></div>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-50 flex max-h-[80vh] flex-col overflow-y-auto rounded-t-2xl bg-white shadow-lg transition-transform duration-300"
|
||||
style="transform: translateY({Math.max(0, dragCurrentY - dragStartY)}px)"
|
||||
role="dialog"
|
||||
aria-label="Inventory"
|
||||
aria-modal="true"
|
||||
tabindex={-1}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 select-none border-b border-gray-200 bg-white px-4 py-4"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
closeInventory();
|
||||
}
|
||||
}}
|
||||
aria-label="Drag to close inventory"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-center">
|
||||
<div class="h-1 w-12 rounded-full bg-gray-300"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900">{$t.gameplay.collectedItems}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeInventory}
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
aria-label="Close inventory"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4">
|
||||
{#if data.collectedItems && data.collectedItems.length > 0}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
{#each data.collectedItems as item (item.id)}
|
||||
<div class="rounded-lg border border-gray-200 p-3 text-center">
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt={item.name} class="mb-2 h-24 w-full rounded object-cover" />
|
||||
{/if}
|
||||
<p class="text-sm font-medium text-gray-900">{item.name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-8 text-center text-sm text-gray-500">{$t.gameplay.emptyInventory}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
214
src/routes/(game)/game/play/[sessionCode]/+page.server.ts
Normal file
214
src/routes/(game)/game/play/[sessionCode]/+page.server.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
};
|
||||
513
src/routes/(game)/game/play/[sessionCode]/+page.svelte
Normal file
513
src/routes/(game)/game/play/[sessionCode]/+page.svelte
Normal file
@@ -0,0 +1,513 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
type Step = {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
type: string;
|
||||
hint?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
proximityRadius?: number | null;
|
||||
};
|
||||
|
||||
let currentStep = $derived((data.displayedStep as Step | null) ?? null);
|
||||
let isCurrentActiveStep = $derived(
|
||||
currentStep !== null && data.activeStepId !== null && currentStep.id === data.activeStepId
|
||||
);
|
||||
let answer = $derived(
|
||||
typeof (form as { answer?: string } | null)?.answer === 'string'
|
||||
? ((form as { answer?: string }).answer ?? '')
|
||||
: ''
|
||||
);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Location tracking state
|
||||
let userLat = $state<number | null>(null);
|
||||
let userLon = $state<number | null>(null);
|
||||
let heading = $state<number | null>(null);
|
||||
let watchId: number | null = null;
|
||||
let locationError = $state<string | null>(null);
|
||||
let locationPermission = $state<'prompt' | 'granted' | 'denied' | 'checking'>('prompt');
|
||||
let distance = $state<number | null>(null);
|
||||
let arrowRotation = $state<number>(0);
|
||||
|
||||
// Calculate distance between two coordinates in meters (Haversine formula)
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371e3; // Earth radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * 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));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// Calculate bearing (direction) from user to target in degrees
|
||||
function calculateBearing(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||
const θ = Math.atan2(y, x);
|
||||
|
||||
return ((θ * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
// Update arrow rotation based on user heading and target bearing
|
||||
$effect(() => {
|
||||
if (
|
||||
currentStep?.type === 'location' &&
|
||||
isCurrentActiveStep &&
|
||||
userLat !== null &&
|
||||
userLon !== null &&
|
||||
currentStep.latitude != null &&
|
||||
currentStep.longitude != null
|
||||
) {
|
||||
const dist = calculateDistance(
|
||||
userLat,
|
||||
userLon,
|
||||
currentStep.latitude,
|
||||
currentStep.longitude
|
||||
);
|
||||
distance = Math.round(dist);
|
||||
|
||||
const bearing = calculateBearing(
|
||||
userLat,
|
||||
userLon,
|
||||
currentStep.latitude,
|
||||
currentStep.longitude
|
||||
);
|
||||
|
||||
// If we have device heading, rotate relative to it; otherwise just use absolute bearing
|
||||
arrowRotation = heading !== null ? bearing - heading : bearing;
|
||||
}
|
||||
});
|
||||
|
||||
// Check and request location permission
|
||||
async function checkLocationPermission() {
|
||||
if (!('geolocation' in navigator)) {
|
||||
locationError = 'Geolocation is not supported by your device';
|
||||
locationPermission = 'denied';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check permission API if available
|
||||
if ('permissions' in navigator) {
|
||||
try {
|
||||
const result = await navigator.permissions.query({ name: 'geolocation' });
|
||||
locationPermission = result.state as 'granted' | 'denied' | 'prompt';
|
||||
|
||||
// Listen for permission changes
|
||||
result.addEventListener('change', () => {
|
||||
locationPermission = result.state as 'granted' | 'denied' | 'prompt';
|
||||
});
|
||||
|
||||
return result.state === 'granted';
|
||||
} catch (e) {
|
||||
// Some browsers don't support permissions API for geolocation
|
||||
console.log('Permissions API not fully supported', e);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function startLocationTracking() {
|
||||
if (!('geolocation' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
locationPermission = 'checking';
|
||||
locationError = null;
|
||||
|
||||
watchId = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
userLat = position.coords.latitude;
|
||||
userLon = position.coords.longitude;
|
||||
locationError = null;
|
||||
locationPermission = 'granted';
|
||||
|
||||
// Try to get device heading if available
|
||||
if (position.coords.heading !== null) {
|
||||
heading = position.coords.heading;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
|
||||
if (error.code === error.PERMISSION_DENIED) {
|
||||
locationError = 'Location access was denied. Please enable location in your browser settings.';
|
||||
locationPermission = 'denied';
|
||||
} else if (error.code === error.POSITION_UNAVAILABLE) {
|
||||
locationError = 'Location information is unavailable.';
|
||||
locationPermission = 'prompt';
|
||||
} else if (error.code === error.TIMEOUT) {
|
||||
locationError = 'Location request timed out. Please try again.';
|
||||
locationPermission = 'prompt';
|
||||
} else {
|
||||
locationError = error.message;
|
||||
locationPermission = 'prompt';
|
||||
}
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 5000,
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// Try to use device orientation for compass heading
|
||||
if (typeof DeviceOrientationEvent !== 'undefined') {
|
||||
const hasAbsoluteOrientation = 'ondeviceorientationabsolute' in window;
|
||||
const hasOrientation = 'ondeviceorientation' in window;
|
||||
|
||||
if (hasAbsoluteOrientation) {
|
||||
window.addEventListener('deviceorientationabsolute', handleOrientation as EventListener);
|
||||
} else if (hasOrientation) {
|
||||
window.addEventListener('deviceorientation', handleOrientation as EventListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopLocationTracking() {
|
||||
if (watchId !== null) {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
watchId = null;
|
||||
}
|
||||
window.removeEventListener('deviceorientationabsolute', handleOrientation as EventListener);
|
||||
window.removeEventListener('deviceorientation', handleOrientation as EventListener);
|
||||
}
|
||||
|
||||
// Initialize location tracking
|
||||
onMount(() => {
|
||||
if (currentStep?.type === 'location' && isCurrentActiveStep) {
|
||||
checkLocationPermission().then(hasPermission => {
|
||||
if (hasPermission) {
|
||||
startLocationTracking();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopLocationTracking();
|
||||
};
|
||||
});
|
||||
|
||||
function handleOrientation(event: Event) {
|
||||
const e = event as DeviceOrientationEvent;
|
||||
if (e.absolute && e.alpha !== null) {
|
||||
heading = 360 - e.alpha; // Convert to compass heading
|
||||
} else if (e.alpha !== null) {
|
||||
// Fallback for iOS with webkitCompassHeading
|
||||
const webkit = e as DeviceOrientationEvent & { webkitCompassHeading?: number };
|
||||
if (webkit.webkitCompassHeading !== undefined) {
|
||||
heading = webkit.webkitCompassHeading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-submit when within proximity
|
||||
let proximityForm = $state<HTMLFormElement | null>(null);
|
||||
$effect(() => {
|
||||
if (
|
||||
currentStep?.type === 'location' &&
|
||||
isCurrentActiveStep &&
|
||||
distance !== null &&
|
||||
currentStep.proximityRadius != null &&
|
||||
distance <= currentStep.proximityRadius &&
|
||||
!isLoading &&
|
||||
proximityForm
|
||||
) {
|
||||
// Auto-submit the form
|
||||
isLoading = true;
|
||||
proximityForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh bg-gray-50">
|
||||
<header class="sticky top-0 z-10 bg-white/95 shadow-sm backdrop-blur">
|
||||
<div class="mx-auto max-w-4xl px-3 py-3 sm:px-4 sm:py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900 sm:text-2xl">{$t.home.title}</h1>
|
||||
<p class="text-sm text-gray-600">{$t.gameplay.progress}: {data.sessionCode || 'Loading...'}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-4xl px-3 py-5 sm:px-4 sm:py-8">
|
||||
<div class="mb-5 sm:mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">{$t.gameplay.progress}</span>
|
||||
<span class="text-xs text-gray-500 sm:text-sm">{$t.gameplay.step} {data.currentStepOrder || 0} {$t.gameplay.of} {data.totalSteps || 0}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
class="bg-indigo-600 h-2.5 rounded-full transition-all duration-300"
|
||||
style="width: {((data.currentStepOrder || 0) / (data.totalSteps || 1)) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-xl bg-white p-4 shadow-md sm:mb-6 sm:p-6">
|
||||
{#if currentStep}
|
||||
<h2 class="mb-3 text-xl font-bold text-gray-900 sm:mb-4 sm:text-2xl">{currentStep.title}</h2>
|
||||
|
||||
{#if currentStep.description}
|
||||
<p class="text-gray-700 mb-4">{currentStep.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentStep.content}
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<p class="text-gray-800">{currentStep.content}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/submitAnswer"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isLoading = false;
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="stepId" value={currentStep.id} />
|
||||
<div>
|
||||
<label for="answer" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{$t.gameplay.yourAnswer}
|
||||
</label>
|
||||
<input
|
||||
id="answer"
|
||||
type="text"
|
||||
name="answer"
|
||||
bind:value={answer}
|
||||
placeholder={$t.gameplay.enterYourAnswer}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3.5 text-base focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? $t.gameplay.checking : $t.gameplay.submitAnswer}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if currentStep.hint}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
{$t.gameplay.needAHint}
|
||||
</summary>
|
||||
<p class="mt-2 text-gray-700 bg-yellow-50 p-4 rounded-lg">{currentStep.hint}</p>
|
||||
</details>
|
||||
{/if}
|
||||
{:else if currentStep.type === 'text' && isCurrentActiveStep}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/continueStep"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isLoading = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="stepId" value={currentStep.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? $t.gameplay.loadingStep : $t.gameplay.continue}
|
||||
</button>
|
||||
</form>
|
||||
{:else if currentStep.type === 'location' && isCurrentActiveStep}
|
||||
<form
|
||||
bind:this={proximityForm}
|
||||
method="POST"
|
||||
action="?/validateLocation"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isLoading = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="stepId" value={currentStep.id} />
|
||||
<input type="hidden" name="userLat" value={userLat ?? ''} />
|
||||
<input type="hidden" name="userLon" value={userLon ?? ''} />
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if locationPermission === 'denied'}
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-4 rounded-lg text-sm space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold mb-1">{$t.gameplay.locationDenied}</p>
|
||||
<p>{locationError || $t.gameplay.locationDeniedMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startLocationTracking()}
|
||||
class="w-full rounded-lg bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{$t.gameplay.tryAgain}
|
||||
</button>
|
||||
</div>
|
||||
{:else if locationPermission === 'prompt'}
|
||||
<div class="bg-blue-50 border border-blue-200 text-blue-900 px-4 py-4 rounded-lg text-sm space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold mb-1">{$t.gameplay.locationRequired}</p>
|
||||
<p>{$t.gameplay.locationRequiredMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startLocationTracking()}
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-3 text-base font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
{$t.gameplay.enableLocation}
|
||||
</button>
|
||||
</div>
|
||||
{:else if locationError && locationPermission === 'checking'}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{$t.gameplay.locationError}: {locationError}
|
||||
</div>
|
||||
{:else if userLat === null || userLon === null}
|
||||
<div class="bg-blue-50 text-blue-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{$t.gameplay.locatingYou}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Arrow indicator -->
|
||||
<div class="relative bg-gradient-to-br from-indigo-50 to-purple-50 rounded-2xl p-8 flex flex-col items-center justify-center" style="min-height: 300px;">
|
||||
<div class="absolute top-4 left-4 right-4 flex justify-between items-start">
|
||||
<div class="bg-white/90 backdrop-blur rounded-lg px-3 py-2 shadow-md">
|
||||
<p class="text-xs text-gray-600 font-medium uppercase tracking-wide">{$t.gameplay.distance}</p>
|
||||
<p class="text-2xl font-bold text-indigo-600">
|
||||
{#if distance !== null}
|
||||
{distance < 1000 ? `${distance}m` : `${(distance / 1000).toFixed(1)}km`}
|
||||
{:else}
|
||||
--
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compass arrow -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-32 h-32 flex items-center justify-center transition-transform duration-500 ease-out"
|
||||
style="transform: rotate({arrowRotation}deg);"
|
||||
>
|
||||
<svg class="w-full h-full drop-shadow-lg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="arrowGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4F46E5;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7C3AED;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Arrow shape -->
|
||||
<path d="M 50 10 L 70 80 L 50 70 L 30 80 Z" fill="url(#arrowGradient)" stroke="white" stroke-width="2"/>
|
||||
<!-- Center dot -->
|
||||
<circle cx="50" cy="50" r="6" fill="white" stroke="#4F46E5" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Outer ring -->
|
||||
<div class="absolute inset-0 rounded-full border-4 border-indigo-200 opacity-30" style="width: 180px; height: 180px; left: -24px; top: -24px;"></div>
|
||||
</div>
|
||||
|
||||
{#if distance !== null && currentStep.proximityRadius != null}
|
||||
{#if distance <= currentStep.proximityRadius}
|
||||
<p class="mt-6 text-lg font-semibold text-green-600 animate-pulse">
|
||||
🎯 {$t.gameplay.arrived}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-6 text-sm text-gray-600">
|
||||
{$t.gameplay.getWithin} {currentStep.proximityRadius}m {$t.gameplay.toValidate}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Manual validation button (as fallback) -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || distance === null}
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? $t.gameplay.checking : $t.gameplay.validateLocation}
|
||||
</button>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if currentStep.hint}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
{$t.gameplay.needAHint}
|
||||
</summary>
|
||||
<p class="mt-2 text-gray-700 bg-yellow-50 p-4 rounded-lg">{currentStep.hint}</p>
|
||||
</details>
|
||||
{/if}
|
||||
{:else if !isCurrentActiveStep}
|
||||
<p class="rounded-lg bg-indigo-50 p-3 text-sm text-indigo-800">
|
||||
{$t.gameplay.viewingUnlockedStep}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-gray-600">{$t.gameplay.loadingStep}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="py-10 text-center sm:py-12">
|
||||
<p class="text-gray-500">{$t.gameplay.loadingStep}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
@@ -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`
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-linear-to-br from-emerald-50 to-cyan-100 p-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl sm:p-8">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-emerald-700">{$t.gameplay.completedLabel}</p>
|
||||
<h1 class="mb-2 text-2xl font-bold text-gray-900 sm:text-3xl">{$t.gameplay.completedTitle}</h1>
|
||||
<p class="mb-6 text-sm text-gray-600 sm:text-base">{data.gameTitle} - {$t.gameplay.sessionCode}: {data.sessionCode}</p>
|
||||
|
||||
<div class="mb-6 rounded-xl bg-emerald-50 p-4 text-center">
|
||||
<p class="mb-1 text-sm font-medium text-emerald-800">{$t.gameplay.completedIn}</p>
|
||||
<p class="text-3xl font-bold text-emerald-900 sm:text-4xl">{data.formattedDuration}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={resolve('/(game)/game')}
|
||||
class="block w-full rounded-lg bg-emerald-600 px-4 py-3 text-center text-base font-semibold text-white transition-colors hover:bg-emerald-700"
|
||||
>
|
||||
{$t.gameplay.playAgain}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-50 px-3 py-5 sm:px-4 sm:py-8">
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-7">
|
||||
<h1 class="mb-2 text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
|
||||
<p class="mb-5 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
|
||||
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
25
src/routes/(game)/terms/+page.svelte
Normal file
25
src/routes/(game)/terms/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { t } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh bg-gray-50 px-3 py-6 sm:px-4 sm:py-10">
|
||||
<div class="mx-auto max-w-3xl rounded-xl bg-white p-5 shadow-md sm:p-8">
|
||||
<a href={resolve('/(game)/game')} class="mb-5 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-700 sm:mb-6">
|
||||
← Back
|
||||
</a>
|
||||
|
||||
<h1 class="mb-4 text-2xl font-bold text-gray-900 sm:text-3xl">{$t.game.termsAndConditions}</h1>
|
||||
<p class="mb-4 text-sm leading-6 text-gray-700 sm:text-base">
|
||||
By joining a session, you agree to follow the game instructions, respect other players,
|
||||
and use the platform appropriately.
|
||||
</p>
|
||||
<p class="mb-4 text-sm leading-6 text-gray-700 sm:text-base">
|
||||
The organizer may collect minimal session data needed to operate the game
|
||||
(code, progress, and participation timestamps).
|
||||
</p>
|
||||
<p class="text-sm leading-6 text-gray-700 sm:text-base">
|
||||
If you do not agree with these terms, please do not join the game session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,4 +6,5 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
|
||||
{@render children()}
|
||||
@@ -1,2 +1,34 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import LanguagePicker from '$lib/components/LanguagePicker.svelte';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-indigo-100 via-purple-50 to-pink-100">
|
||||
<div class="flex flex-col items-center justify-center px-4">
|
||||
<h1 class="text-4xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6 text-center">
|
||||
{$t.home.title}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-700 mb-12 max-w-2xl text-center">
|
||||
{$t.home.subtitle}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onclick={() => goto('/game')}
|
||||
class="bg-indigo-600 text-white px-12 py-8 rounded-2xl font-semibold text-xl hover:bg-indigo-700 shadow-lg hover:shadow-xl transition-all transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-16 h-16 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{$t.home.playGame}</span>
|
||||
<span class="text-sm text-indigo-200 mt-2">{$t.home.joinWithCode}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-16">
|
||||
<p class="text-gray-600 text-sm mb-6 font-medium">{$t.common?.selectLanguage || 'Select Language'}</p>
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
input:where([type='datetime-local']) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
106
src/routes/login/+page.server.ts
Normal file
106
src/routes/login/+page.server.ts
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
143
src/routes/login/+page.svelte
Normal file
143
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { t } from '$lib/i18n';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
|
||||
let { form, data }: { form: ActionData; data: PageData } = $props();
|
||||
const noUsersExist = $derived(data?.noUsersExist ?? false);
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-linear-to-br from-indigo-100 via-purple-50 to-pink-100 p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||||
{#if noUsersExist}
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2 text-center">
|
||||
Créer le premier utilisateur
|
||||
</h1>
|
||||
<p class="text-gray-600 mb-8 text-center">
|
||||
Aucun utilisateur n'existe. Créez le premier compte administrateur.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="?/createFirstUser" use:enhance class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
placeholder="John Doe"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
bind:value={email}
|
||||
placeholder="admin@example.com"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
placeholder="••••••••"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Créer le compte
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2 text-center">
|
||||
{$t.login?.login || 'Login'}
|
||||
</h1>
|
||||
<p class="text-gray-600 mb-8 text-center">
|
||||
{$t.login?.accessAdmin || 'Access the admin dashboard'}
|
||||
</p>
|
||||
|
||||
<form method="POST" action="?/signIn" use:enhance class="space-y-6">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
bind:value={email}
|
||||
placeholder="admin@example.com"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
placeholder="••••••••"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{$t.login?.login || 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
|
||||
<a href={resolve('/')} class="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
← {$t.home?.title || 'Back Home'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user