diff --git a/.env.example b/.env.example index f2b6018..fc2d7c3 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,10 @@ ORIGIN="" # For production use 32 characters and generated with high entropy # https://www.better-auth.com/docs/installation BETTER_AUTH_SECRET="" + +# Uploads +UPLOAD_DIR="./uploads" + +# Upload Directory +# Path to store uploaded files (defaults to ./uploads if not set) +UPLOAD_DIR="" diff --git a/.gitignore b/.gitignore index 3b462cb..2280cac 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Uploads +/uploads diff --git a/drizzle/0002_naive_loa.sql b/drizzle/0002_naive_loa.sql new file mode 100644 index 0000000..1834c14 --- /dev/null +++ b/drizzle/0002_naive_loa.sql @@ -0,0 +1,2 @@ +ALTER TABLE "step" ADD COLUMN "image_url" text;--> statement-breakpoint +ALTER TABLE "step" ADD COLUMN "puzzle_pieces" integer; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..82b4d03 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f6999ad..2ec1e85 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772977993829, "tag": "0001_new_silver_samurai", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1772994053752, + "tag": "0002_naive_loa", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/components/Puzzle.svelte b/src/lib/components/Puzzle.svelte new file mode 100644 index 0000000..4014cd6 --- /dev/null +++ b/src/lib/components/Puzzle.svelte @@ -0,0 +1,301 @@ + + +
+ {#if !imageLoaded} +
+

Loading puzzle...

+
+ {/if} + +
+ {#each pieces as piece, index (piece.id)} +
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))}%; + " + > +
+ {/each} +
+ + Puzzle imageLoaded = true} + onerror={() => console.error('Failed to load puzzle image')} + /> + + {#if isComplete} +
+
+

🎉 Puzzle complete!

+
+
+ {/if} +
+ + diff --git a/src/lib/components/StepForm.svelte b/src/lib/components/StepForm.svelte index ce5d64b..c2929d8 100644 --- a/src/lib/components/StepForm.svelte +++ b/src/lib/components/StepForm.svelte @@ -13,6 +13,8 @@ latitude?: number | string | null; longitude?: number | string | null; proximityRadius?: number | string; + imageUrl?: string | null; + puzzlePieces?: number | string; }; let { @@ -37,6 +39,44 @@ const stepTypes = ['question', 'text', 'puzzle', 'location']; let selectedType = $derived(initialValues.type || 'question'); + let currentImageUrl = $state(null); + let selectedFileName = $state(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(() => { switch (selectedType) { @@ -46,7 +86,8 @@ contentPlaceholder: 'Text shown to players', showLocation: false, showAnswer: false, - showHint: false + showHint: false, + showPuzzle: false }; case 'location': return { @@ -54,7 +95,8 @@ contentPlaceholder: 'Describe where players need to go', showLocation: true, showAnswer: true, - showHint: true + showHint: true, + showPuzzle: false }; case 'puzzle': return { @@ -62,7 +104,8 @@ contentPlaceholder: 'Describe the puzzle to solve', showLocation: false, showAnswer: true, - showHint: true + showHint: true, + showPuzzle: true }; default: return { @@ -70,7 +113,8 @@ contentPlaceholder: 'Enter the question for players', showLocation: false, showAnswer: true, - showHint: true + showHint: true, + showPuzzle: false }; } }); @@ -90,7 +134,7 @@
-
+
{/if} + + {#if fieldConfig.showPuzzle} +
+

Puzzle Configuration

+
+
+ + + {#if selectedFileName} +

+ + + + Selected: {selectedFileName} +

+ {/if} + {#if currentImageUrl} +
+

+ {selectedFileName ? 'Preview of new image:' : 'Current image:'} +

+
+ Puzzle preview + {#if selectedFileName} + + {/if} +
+
+ {/if} + {#if initialValues.imageUrl && !selectedFileName} + + {/if} +

+ Upload an image for the puzzle. Accepted formats: JPG, PNG, GIF, WebP (max 10MB) +

+
+ +
+ + +

Recommended: 4 (2×2), 9 (3×3), 16 (4×4), or 25 (5×5) pieces

+
+
+
+ {/if}
{#if errorMessage} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8f7f518..b72b5db 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -30,6 +30,8 @@ export const step = pgTable('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) + 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(), }); diff --git a/src/lib/server/uploads.ts b/src/lib/server/uploads.ts new file mode 100644 index 0000000..f104c2d --- /dev/null +++ b/src/lib/server/uploads.ts @@ -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 { + 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 = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp' + }; + return mimeTypes[ext || ''] || 'application/octet-stream'; +} diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts index 2ee9768..c4ecf91 100644 --- a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts +++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts @@ -3,7 +3,8 @@ 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'; - +import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads'; +import { writeFile } from 'fs/promises'; const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; 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 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 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) { return fail(400, { @@ -190,7 +250,9 @@ export const actions: Actions = { hint: hint || null, latitude, longitude, - proximityRadius + proximityRadius, + imageUrl, + puzzlePieces }) .where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId))) .returning({ id: step.id }); diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte index 7a45eb6..7f4f323 100644 --- a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte +++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte @@ -27,7 +27,11 @@ : data.step.longitude, proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number' ? 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) })); diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts index 230c848..6c88f62 100644 --- a/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts +++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts @@ -3,6 +3,8 @@ 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'; +import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads'; +import { writeFile } from 'fs/promises'; const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; 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 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 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) { return fail(400, { @@ -177,7 +235,9 @@ export const actions: Actions = { hint: hint || null, latitude, longitude, - proximityRadius + proximityRadius, + imageUrl, + puzzlePieces }); } catch (error) { console.error('Create step error:', error); diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte index e7ca16e..a1f453b 100644 --- a/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte +++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte @@ -24,7 +24,11 @@ : '', proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number' ? formMap.proximityRadius - : 50 + : 50, + imageUrl: typeof formMap.imageUrl === 'string' ? formMap.imageUrl : null, + puzzlePieces: typeof formMap.puzzlePieces === 'string' || typeof formMap.puzzlePieces === 'number' + ? formMap.puzzlePieces + : 9 })); diff --git a/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts index 43531ef..31de47b 100644 --- a/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts +++ b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts @@ -103,7 +103,9 @@ export const load: LayoutServerLoad = async ({ params, url }) => { hint: displayedStepRecord.hint ?? undefined, latitude: displayedStepRecord.latitude ?? null, longitude: displayedStepRecord.longitude ?? null, - proximityRadius: displayedStepRecord.proximityRadius ?? 50 + proximityRadius: displayedStepRecord.proximityRadius ?? 50, + imageUrl: displayedStepRecord.imageUrl ?? undefined, + puzzlePieces: displayedStepRecord.puzzlePieces ?? undefined } : null, unlockedSteps: unlockedSteps.map((entry) => ({ diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.server.ts b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts index 75ffddf..67372e2 100644 --- a/src/routes/(game)/game/play/[sessionCode]/+page.server.ts +++ b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts @@ -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}`); } }; diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/+page.svelte index a23ec43..852fd77 100644 --- a/src/routes/(game)/game/play/[sessionCode]/+page.svelte +++ b/src/routes/(game)/game/play/[sessionCode]/+page.svelte @@ -3,6 +3,7 @@ import { onMount } from 'svelte'; import type { ActionData, PageData } from './$types'; import { t } from '$lib/i18n'; + import Puzzle from '$lib/components/Puzzle.svelte'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -16,6 +17,8 @@ latitude?: number | null; longitude?: number | null; proximityRadius?: number | null; + imageUrl?: string; + puzzlePieces?: number; }; let currentStep = $derived((data.displayedStep as Step | null) ?? null); @@ -28,6 +31,7 @@ : '' ); let isLoading = $state(false); + let puzzleForm = $state(null); // Location tracking state let userLat = $state(null); @@ -335,7 +339,49 @@ {/if} - {#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep} + {#if currentStep.type === 'puzzle' && isCurrentActiveStep} + {#if currentStep.imageUrl && currentStep.puzzlePieces} +
+ { + if (puzzleForm) { + isLoading = true; + puzzleForm.requestSubmit(); + } + }} + /> +
+ { + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + style="display: none;" + > + + + {:else} +
+ Puzzle configuration error: Missing image or puzzle pieces +
+ {/if} + + {#if currentStep.hint} +
+ + {$t.gameplay.needAHint} + +

{currentStep.hint}

+
+ {/if} + {:else if currentStep.type === 'question' && isCurrentActiveStep}
{ + 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 = { + '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 }); + } +};