feat: add puzzle component and image upload functionality
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:
2026-03-08 19:37:57 +01:00
parent 8cda97a2f0
commit 2455c75732
17 changed files with 1722 additions and 12 deletions

View 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>

View File

@@ -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<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(() => {
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 @@
</div>
<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="col-span-2">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
@@ -251,6 +295,80 @@
</div>
</div>
{/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>
{#if errorMessage}

View File

@@ -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(),
});

64
src/lib/server/uploads.ts Normal file
View 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';
}

View File

@@ -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 });

View File

@@ -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)
}));
</script>

View File

@@ -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);

View File

@@ -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
}));
</script>

View File

@@ -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) => ({

View File

@@ -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}`);
}
};

View File

@@ -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<HTMLFormElement | null>(null);
// Location tracking state
let userLat = $state<number | null>(null);
@@ -335,7 +339,49 @@
</div>
{/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
method="POST"
action="?/submitAnswer"

View 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 });
}
};