feat: add puzzle component and image upload functionality
All checks were successful
Migrate supabase / migrate (push) Successful in 16s
All checks were successful
Migrate supabase / migrate (push) Successful in 16s
- Introduced a new Puzzle component for interactive puzzle gameplay. - Updated StepForm to include fields for puzzle image and number of pieces. - Implemented image upload handling in the server-side logic for steps. - Enhanced database schema to store image URLs and puzzle piece counts. - Added file upload utilities for managing uploaded images. - Created routes for serving uploaded images securely. - Updated game play routes to handle puzzle completion logic. - Improved error handling for image uploads and puzzle configurations.
This commit is contained in:
@@ -7,3 +7,10 @@ ORIGIN=""
|
|||||||
# For production use 32 characters and generated with high entropy
|
# For production use 32 characters and generated with high entropy
|
||||||
# https://www.better-auth.com/docs/installation
|
# https://www.better-auth.com/docs/installation
|
||||||
BETTER_AUTH_SECRET=""
|
BETTER_AUTH_SECRET=""
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
UPLOAD_DIR="./uploads"
|
||||||
|
|
||||||
|
# Upload Directory
|
||||||
|
# Path to store uploaded files (defaults to ./uploads if not set)
|
||||||
|
UPLOAD_DIR=""
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,3 +21,6 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/uploads
|
||||||
|
|||||||
2
drizzle/0002_naive_loa.sql
Normal file
2
drizzle/0002_naive_loa.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "step" ADD COLUMN "image_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "step" ADD COLUMN "puzzle_pieces" integer;
|
||||||
920
drizzle/meta/0002_snapshot.json
Normal file
920
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
{
|
||||||
|
"id": "3b1a2adf-8bb4-48be-84b5-8d357780ea2a",
|
||||||
|
"prevId": "fe46bb64-72d0-4fc1-849b-4867f5f2209c",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"puzzle_pieces": {
|
||||||
|
"name": "puzzle_pieces",
|
||||||
|
"type": "integer",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1772977993829,
|
"when": 1772977993829,
|
||||||
"tag": "0001_new_silver_samurai",
|
"tag": "0001_new_silver_samurai",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772994053752,
|
||||||
|
"tag": "0002_naive_loa",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
301
src/lib/components/Puzzle.svelte
Normal file
301
src/lib/components/Puzzle.svelte
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
imageUrl: string;
|
||||||
|
puzzlePieces: number;
|
||||||
|
onComplete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { imageUrl, puzzlePieces, onComplete }: Props = $props();
|
||||||
|
|
||||||
|
type PuzzlePiece = {
|
||||||
|
id: number;
|
||||||
|
currentIndex: number;
|
||||||
|
correctIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pieces = $state<PuzzlePiece[]>([]);
|
||||||
|
let gridSize = $state(0);
|
||||||
|
let draggedIndex = $state<number | null>(null);
|
||||||
|
let imageLoaded = $state(false);
|
||||||
|
let isComplete = $state(false);
|
||||||
|
|
||||||
|
// Calculate grid size (e.g., 9 pieces = 3x3)
|
||||||
|
$effect(() => {
|
||||||
|
gridSize = Math.sqrt(puzzlePieces);
|
||||||
|
if (gridSize * gridSize !== puzzlePieces) {
|
||||||
|
console.error('Puzzle pieces must be a perfect square (4, 9, 16, 25, etc.)');
|
||||||
|
gridSize = Math.ceil(gridSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize and shuffle pieces
|
||||||
|
onMount(() => {
|
||||||
|
initializePuzzle();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializePuzzle() {
|
||||||
|
// Create pieces in correct order
|
||||||
|
const initialPieces: PuzzlePiece[] = Array.from({ length: puzzlePieces }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
currentIndex: i,
|
||||||
|
correctIndex: i
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Shuffle using Fisher-Yates algorithm
|
||||||
|
const shuffled = [...initialPieces];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update currentIndex based on shuffled position
|
||||||
|
shuffled.forEach((piece, index) => {
|
||||||
|
piece.currentIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
pieces = shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(index: number) {
|
||||||
|
draggedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(targetIndex: number) {
|
||||||
|
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||||
|
draggedIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap pieces
|
||||||
|
const newPieces = [...pieces];
|
||||||
|
const draggedPiece = newPieces[draggedIndex];
|
||||||
|
const targetPiece = newPieces[targetIndex];
|
||||||
|
|
||||||
|
// Swap positions
|
||||||
|
[newPieces[draggedIndex], newPieces[targetIndex]] = [targetPiece, draggedPiece];
|
||||||
|
|
||||||
|
// Update currentIndex
|
||||||
|
newPieces[draggedIndex].currentIndex = draggedIndex;
|
||||||
|
newPieces[targetIndex].currentIndex = targetIndex;
|
||||||
|
|
||||||
|
pieces = newPieces;
|
||||||
|
draggedIndex = null;
|
||||||
|
|
||||||
|
// Check if puzzle is complete
|
||||||
|
checkCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(index: number, event: TouchEvent) {
|
||||||
|
draggedIndex = index;
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
target.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
|
||||||
|
// Add visual feedback for potential drop target
|
||||||
|
document.querySelectorAll('.puzzle-piece').forEach(el => {
|
||||||
|
el.classList.remove('drop-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (element?.classList.contains('puzzle-piece')) {
|
||||||
|
element.classList.add('drop-target');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
|
||||||
|
// Reset opacity
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
target.style.opacity = '1';
|
||||||
|
|
||||||
|
// Remove drop target highlighting
|
||||||
|
document.querySelectorAll('.puzzle-piece').forEach(el => {
|
||||||
|
el.classList.remove('drop-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (element?.classList.contains('puzzle-piece') && draggedIndex !== null) {
|
||||||
|
const targetIndex = parseInt(element.getAttribute('data-index') || '-1');
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
handleDrop(targetIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCompletion() {
|
||||||
|
const solved = pieces.every((piece) => piece.currentIndex === piece.correctIndex);
|
||||||
|
if (solved && !isComplete) {
|
||||||
|
isComplete = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="puzzle-container">
|
||||||
|
{#if !imageLoaded}
|
||||||
|
<div class="loading">
|
||||||
|
<p>Loading puzzle...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="puzzle-grid"
|
||||||
|
class:complete={isComplete}
|
||||||
|
style="grid-template-columns: repeat({gridSize}, 1fr); grid-template-rows: repeat({gridSize}, 1fr);"
|
||||||
|
>
|
||||||
|
{#each pieces as piece, index (piece.id)}
|
||||||
|
<div
|
||||||
|
class="puzzle-piece"
|
||||||
|
class:correct={piece.currentIndex === piece.correctIndex}
|
||||||
|
data-index={index}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={() => handleDragStart(index)}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondrop={() => handleDrop(index)}
|
||||||
|
ontouchstart={(e) => handleTouchStart(index, e)}
|
||||||
|
ontouchmove={handleTouchMove}
|
||||||
|
ontouchend={handleTouchEnd}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
style="
|
||||||
|
background-image: url('{imageUrl}');
|
||||||
|
background-size: {gridSize * 100}% {gridSize * 100}%;
|
||||||
|
background-position: {(piece.correctIndex % gridSize) * (100 / (gridSize - 1))}% {Math.floor(piece.correctIndex / gridSize) * (100 / (gridSize - 1))}%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Puzzle"
|
||||||
|
style="display: none;"
|
||||||
|
onload={() => imageLoaded = true}
|
||||||
|
onerror={() => console.error('Failed to load puzzle image')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isComplete}
|
||||||
|
<div class="completion-message">
|
||||||
|
<div class="completion-content">
|
||||||
|
<p>🎉 Puzzle complete!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.puzzle-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-grid.complete {
|
||||||
|
animation: celebrate 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes celebrate {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
cursor: grab;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece:global(.drop-target) {
|
||||||
|
outline: 2px solid #4f46e5;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece.correct {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-message {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-content p {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.puzzle-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-content p {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
latitude?: number | string | null;
|
latitude?: number | string | null;
|
||||||
longitude?: number | string | null;
|
longitude?: number | string | null;
|
||||||
proximityRadius?: number | string;
|
proximityRadius?: number | string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
puzzlePieces?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,6 +39,44 @@
|
|||||||
|
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
||||||
let selectedType = $derived(initialValues.type || 'question');
|
let selectedType = $derived(initialValues.type || 'question');
|
||||||
|
let currentImageUrl = $state<string | null>(null);
|
||||||
|
let selectedFileName = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize image URL from initialValues
|
||||||
|
$effect(() => {
|
||||||
|
if (initialValues.imageUrl && !currentImageUrl) {
|
||||||
|
currentImageUrl = initialValues.imageUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
selectedFileName = file.name;
|
||||||
|
// Create a preview URL for the selected image
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
currentImageUrl = e.target?.result as string;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
selectedFileName = null;
|
||||||
|
if (!initialValues.imageUrl) {
|
||||||
|
currentImageUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
currentImageUrl = initialValues.imageUrl || null;
|
||||||
|
selectedFileName = null;
|
||||||
|
const fileInput = document.getElementById('puzzleImage') as HTMLInputElement;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fieldConfig = $derived.by(() => {
|
let fieldConfig = $derived.by(() => {
|
||||||
switch (selectedType) {
|
switch (selectedType) {
|
||||||
@@ -46,7 +86,8 @@
|
|||||||
contentPlaceholder: 'Text shown to players',
|
contentPlaceholder: 'Text shown to players',
|
||||||
showLocation: false,
|
showLocation: false,
|
||||||
showAnswer: false,
|
showAnswer: false,
|
||||||
showHint: false
|
showHint: false,
|
||||||
|
showPuzzle: false
|
||||||
};
|
};
|
||||||
case 'location':
|
case 'location':
|
||||||
return {
|
return {
|
||||||
@@ -54,7 +95,8 @@
|
|||||||
contentPlaceholder: 'Describe where players need to go',
|
contentPlaceholder: 'Describe where players need to go',
|
||||||
showLocation: true,
|
showLocation: true,
|
||||||
showAnswer: true,
|
showAnswer: true,
|
||||||
showHint: true
|
showHint: true,
|
||||||
|
showPuzzle: false
|
||||||
};
|
};
|
||||||
case 'puzzle':
|
case 'puzzle':
|
||||||
return {
|
return {
|
||||||
@@ -62,7 +104,8 @@
|
|||||||
contentPlaceholder: 'Describe the puzzle to solve',
|
contentPlaceholder: 'Describe the puzzle to solve',
|
||||||
showLocation: false,
|
showLocation: false,
|
||||||
showAnswer: true,
|
showAnswer: true,
|
||||||
showHint: true
|
showHint: true,
|
||||||
|
showPuzzle: true
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -70,7 +113,8 @@
|
|||||||
contentPlaceholder: 'Enter the question for players',
|
contentPlaceholder: 'Enter the question for players',
|
||||||
showLocation: false,
|
showLocation: false,
|
||||||
showAnswer: true,
|
showAnswer: true,
|
||||||
showHint: true
|
showHint: true,
|
||||||
|
showPuzzle: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -90,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||||
<form method="POST" use:enhance class="p-8 space-y-6">
|
<form method="POST" use:enhance enctype="multipart/form-data" class="p-8 space-y-6">
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -251,6 +295,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if fieldConfig.showPuzzle}
|
||||||
|
<div class="col-span-2 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||||
|
<h3 class="text-sm font-semibold text-purple-900 mb-4">Puzzle Configuration</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="puzzleImage" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Puzzle Image {#if !initialValues.imageUrl}<span class="text-red-500">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="puzzleImage"
|
||||||
|
type="file"
|
||||||
|
name="puzzleImage"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
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 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100"
|
||||||
|
/>
|
||||||
|
{#if selectedFileName}
|
||||||
|
<p class="text-sm text-green-600 mt-2 flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Selected: {selectedFileName}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if currentImageUrl}
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
<p class="text-sm text-gray-600 font-medium">
|
||||||
|
{selectedFileName ? 'Preview of new image:' : 'Current image:'}
|
||||||
|
</p>
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img src={currentImageUrl} alt="Puzzle preview" class="max-w-sm max-h-64 rounded-lg border-2 border-purple-300 shadow-md" />
|
||||||
|
{#if selectedFileName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearImage}
|
||||||
|
class="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors"
|
||||||
|
title="Remove selected image"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" 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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if initialValues.imageUrl && !selectedFileName}
|
||||||
|
<input type="hidden" name="existingImageUrl" value={initialValues.imageUrl} />
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Upload an image for the puzzle. Accepted formats: JPG, PNG, GIF, WebP (max 10MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="puzzlePieces" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Number of Pieces
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="puzzlePieces"
|
||||||
|
type="number"
|
||||||
|
name="puzzlePieces"
|
||||||
|
value={initialValues.puzzlePieces ?? 9}
|
||||||
|
min="4"
|
||||||
|
max="100"
|
||||||
|
placeholder="9"
|
||||||
|
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">Recommended: 4 (2×2), 9 (3×3), 16 (4×4), or 25 (5×5) pieces</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const step = pgTable('step', {
|
|||||||
latitude: doublePrecision('latitude'), // Target latitude for location steps
|
latitude: doublePrecision('latitude'), // Target latitude for location steps
|
||||||
longitude: doublePrecision('longitude'), // Target longitude for location steps
|
longitude: doublePrecision('longitude'), // Target longitude for location steps
|
||||||
proximityRadius: integer('proximity_radius').default(50), // Proximity radius in meters (default: 50m)
|
proximityRadius: integer('proximity_radius').default(50), // Proximity radius in meters (default: 50m)
|
||||||
|
imageUrl: text('image_url'), // Image URL for puzzle steps (stored in uploads folder)
|
||||||
|
puzzlePieces: integer('puzzle_pieces'), // Number of pieces for puzzle steps
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
64
src/lib/server/uploads.ts
Normal file
64
src/lib/server/uploads.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { mkdir, access } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the upload directory path from environment variable or use default
|
||||||
|
*/
|
||||||
|
export function getUploadDir(): string {
|
||||||
|
return process.env.UPLOAD_DIR || './uploads';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full path for an uploaded file
|
||||||
|
*/
|
||||||
|
export function getUploadPath(filename: string): string {
|
||||||
|
return join(getUploadDir(), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the upload directory exists, create it if not
|
||||||
|
*/
|
||||||
|
export async function ensureUploadDir(): Promise<void> {
|
||||||
|
const uploadDir = getUploadDir();
|
||||||
|
try {
|
||||||
|
await access(uploadDir);
|
||||||
|
} catch {
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a filename by removing or replacing unsafe characters
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(filename: string): string {
|
||||||
|
return filename
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
.replace(/_{2,}/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique filename using a random hash and original filename
|
||||||
|
*/
|
||||||
|
export function generateUniqueFilename(originalFilename: string): string {
|
||||||
|
const ext = originalFilename.split('.').pop() || '';
|
||||||
|
const hash = randomBytes(16).toString('hex');
|
||||||
|
const sanitized = sanitizeFilename(originalFilename.replace(`.${ext}`, ''));
|
||||||
|
return `${hash}_${sanitized}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type from file extension
|
||||||
|
*/
|
||||||
|
export function getMimeType(filename: string): string {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp'
|
||||||
|
};
|
||||||
|
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { escapeGame, step } from '$lib/server/db/schema';
|
import { escapeGame, step } from '$lib/server/db/schema';
|
||||||
import { and, eq, max } from 'drizzle-orm';
|
import { and, eq, max } from 'drizzle-orm';
|
||||||
|
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||||
type StepType = (typeof stepTypes)[number];
|
type StepType = (typeof stepTypes)[number];
|
||||||
|
|
||||||
@@ -68,6 +69,65 @@ export const actions: Actions = {
|
|||||||
const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
|
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 longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
|
||||||
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
|
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
|
||||||
|
const puzzleImage = formData.get('puzzleImage') as File | null;
|
||||||
|
const existingImageUrl = formData.get('existingImageUrl')?.toString() || null;
|
||||||
|
const puzzlePieces = formData.get('puzzlePieces')?.toString() ? parseInt(formData.get('puzzlePieces')!.toString(), 10) : null;
|
||||||
|
|
||||||
|
let imageUrl: string | null = existingImageUrl;
|
||||||
|
|
||||||
|
// Handle puzzle image upload
|
||||||
|
if (puzzleImage && puzzleImage.size > 0) {
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (puzzleImage.size > MAX_FILE_SIZE) {
|
||||||
|
return fail(400, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
order,
|
||||||
|
error: 'Image size exceeds 10MB limit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(puzzleImage.type)) {
|
||||||
|
return fail(400, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
order,
|
||||||
|
error: 'Invalid image format. Only JPG, PNG, GIF, and WebP are allowed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureUploadDir();
|
||||||
|
const uniqueFilename = generateUniqueFilename(puzzleImage.name);
|
||||||
|
const uploadPath = getUploadPath(uniqueFilename);
|
||||||
|
const arrayBuffer = await puzzleImage.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await writeFile(uploadPath, buffer);
|
||||||
|
imageUrl = `/uploads/${uniqueFilename}`;
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.error('Image upload error:', uploadError);
|
||||||
|
return fail(500, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
order,
|
||||||
|
error: 'Failed to upload image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
@@ -190,7 +250,9 @@ export const actions: Actions = {
|
|||||||
hint: hint || null,
|
hint: hint || null,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
proximityRadius
|
proximityRadius,
|
||||||
|
imageUrl,
|
||||||
|
puzzlePieces
|
||||||
})
|
})
|
||||||
.where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)))
|
.where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)))
|
||||||
.returning({ id: step.id });
|
.returning({ id: step.id });
|
||||||
|
|||||||
@@ -27,7 +27,11 @@
|
|||||||
: data.step.longitude,
|
: data.step.longitude,
|
||||||
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
||||||
? formMap.proximityRadius
|
? formMap.proximityRadius
|
||||||
: (data.step.proximityRadius ?? 50)
|
: (data.step.proximityRadius ?? 50),
|
||||||
|
imageUrl: typeof formMap.imageUrl === 'string' ? formMap.imageUrl : (data.step.imageUrl ?? null),
|
||||||
|
puzzlePieces: typeof formMap.puzzlePieces === 'string' || typeof formMap.puzzlePieces === 'number'
|
||||||
|
? formMap.puzzlePieces
|
||||||
|
: (data.step.puzzlePieces ?? 9)
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { escapeGame, step } from '$lib/server/db/schema';
|
import { escapeGame, step } from '$lib/server/db/schema';
|
||||||
import { eq, max } from 'drizzle-orm';
|
import { eq, max } from 'drizzle-orm';
|
||||||
|
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||||
type StepType = (typeof stepTypes)[number];
|
type StepType = (typeof stepTypes)[number];
|
||||||
@@ -60,6 +62,62 @@ export const actions: Actions = {
|
|||||||
const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
|
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 longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
|
||||||
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
|
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
|
||||||
|
const puzzleImage = formData.get('puzzleImage') as File | null;
|
||||||
|
const existingImageUrl = formData.get('existingImageUrl')?.toString() || null;
|
||||||
|
const puzzlePieces = formData.get('puzzlePieces')?.toString() ? parseInt(formData.get('puzzlePieces')!.toString(), 10) : null;
|
||||||
|
|
||||||
|
let imageUrl: string | null = existingImageUrl;
|
||||||
|
|
||||||
|
// Handle puzzle image upload
|
||||||
|
if (puzzleImage && puzzleImage.size > 0) {
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (puzzleImage.size > MAX_FILE_SIZE) {
|
||||||
|
return fail(400, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
error: 'Image size exceeds 10MB limit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(puzzleImage.type)) {
|
||||||
|
return fail(400, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
error: 'Invalid image format. Only JPG, PNG, GIF, and WebP are allowed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureUploadDir();
|
||||||
|
const uniqueFilename = generateUniqueFilename(puzzleImage.name);
|
||||||
|
const uploadPath = getUploadPath(uniqueFilename);
|
||||||
|
const arrayBuffer = await puzzleImage.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await writeFile(uploadPath, buffer);
|
||||||
|
imageUrl = `/uploads/${uniqueFilename}`;
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.error('Image upload error:', uploadError);
|
||||||
|
return fail(500, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
error: 'Failed to upload image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
@@ -177,7 +235,9 @@ export const actions: Actions = {
|
|||||||
hint: hint || null,
|
hint: hint || null,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
proximityRadius
|
proximityRadius,
|
||||||
|
imageUrl,
|
||||||
|
puzzlePieces
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create step error:', error);
|
console.error('Create step error:', error);
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
: '',
|
: '',
|
||||||
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
|
||||||
? formMap.proximityRadius
|
? formMap.proximityRadius
|
||||||
: 50
|
: 50,
|
||||||
|
imageUrl: typeof formMap.imageUrl === 'string' ? formMap.imageUrl : null,
|
||||||
|
puzzlePieces: typeof formMap.puzzlePieces === 'string' || typeof formMap.puzzlePieces === 'number'
|
||||||
|
? formMap.puzzlePieces
|
||||||
|
: 9
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export const load: LayoutServerLoad = async ({ params, url }) => {
|
|||||||
hint: displayedStepRecord.hint ?? undefined,
|
hint: displayedStepRecord.hint ?? undefined,
|
||||||
latitude: displayedStepRecord.latitude ?? null,
|
latitude: displayedStepRecord.latitude ?? null,
|
||||||
longitude: displayedStepRecord.longitude ?? null,
|
longitude: displayedStepRecord.longitude ?? null,
|
||||||
proximityRadius: displayedStepRecord.proximityRadius ?? 50
|
proximityRadius: displayedStepRecord.proximityRadius ?? 50,
|
||||||
|
imageUrl: displayedStepRecord.imageUrl ?? undefined,
|
||||||
|
puzzlePieces: displayedStepRecord.puzzlePieces ?? undefined
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
unlockedSteps: unlockedSteps.map((entry) => ({
|
unlockedSteps: unlockedSteps.map((entry) => ({
|
||||||
|
|||||||
@@ -209,6 +209,62 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redirect(303, `/game/play/${session.code}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
completePuzzle: 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 !== 'puzzle') {
|
||||||
|
return fail(400, { error: 'This step is not a puzzle step' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
completedAt: existingProgress.completedAt ?? new Date()
|
||||||
|
})
|
||||||
|
.where(eq(sessionProgress.id, existingProgress.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(sessionProgress).values({
|
||||||
|
gameSessionId: session.id,
|
||||||
|
stepId,
|
||||||
|
attempts: 1,
|
||||||
|
completedAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
redirect(303, `/game/play/${session.code}`);
|
redirect(303, `/game/play/${session.code}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import Puzzle from '$lib/components/Puzzle.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
latitude?: number | null;
|
latitude?: number | null;
|
||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
proximityRadius?: number | null;
|
proximityRadius?: number | null;
|
||||||
|
imageUrl?: string;
|
||||||
|
puzzlePieces?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentStep = $derived((data.displayedStep as Step | null) ?? null);
|
let currentStep = $derived((data.displayedStep as Step | null) ?? null);
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let puzzleForm = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
// Location tracking state
|
// Location tracking state
|
||||||
let userLat = $state<number | null>(null);
|
let userLat = $state<number | null>(null);
|
||||||
@@ -335,7 +339,49 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep}
|
{#if currentStep.type === 'puzzle' && isCurrentActiveStep}
|
||||||
|
{#if currentStep.imageUrl && currentStep.puzzlePieces}
|
||||||
|
<div class="mb-6">
|
||||||
|
<Puzzle
|
||||||
|
imageUrl={currentStep.imageUrl}
|
||||||
|
puzzlePieces={currentStep.puzzlePieces}
|
||||||
|
onComplete={() => {
|
||||||
|
if (puzzleForm) {
|
||||||
|
isLoading = true;
|
||||||
|
puzzleForm.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
bind:this={puzzleForm}
|
||||||
|
method="POST"
|
||||||
|
action="?/completePuzzle"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
isLoading = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="stepId" value={currentStep.id} />
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||||
|
Puzzle configuration error: Missing image or puzzle pieces
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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 === 'question' && isCurrentActiveStep}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/submitAnswer"
|
action="?/submitAnswer"
|
||||||
|
|||||||
52
src/routes/uploads/[filename]/+server.ts
Normal file
52
src/routes/uploads/[filename]/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const filename = params.filename as string;
|
||||||
|
|
||||||
|
if (!filename || typeof filename !== 'string' || filename.includes('..') || filename.includes('/')) {
|
||||||
|
return new Response('Invalid filename', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadsDir = env.UPLOAD_DIR || join(process.cwd(),'uploads');
|
||||||
|
const filepath = join(uploadsDir, filename);
|
||||||
|
|
||||||
|
if (!existsSync(filepath)) {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file is within uploads directory (prevent directory traversal)
|
||||||
|
if (!filepath.startsWith(uploadsDir)) {
|
||||||
|
return new Response('Access denied', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await readFile(filepath);
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
const ext = (filename as string).split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new Response(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File serving error:', error);
|
||||||
|
return new Response('Internal server error', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user