feat: complete outdoor escape game platform with location-based steps

- Initialize SvelteKit project with authentication and database
- Implement multilingual support (English/French)
- Add authentication system with login, signup, and logout
- Create admin panel with games and sessions management
- Implement game and step management (CRUD operations)
- Add soft delete for escape games
- Create player game flow with step progression
- Implement inventory and collected items system
- Add location-based steps with GPS tracking and proximity validation
- Create compass arrow indicator pointing to destinations
- Add session management with code-based access
- Implement edit session and delete session functionality
- Add terms and conditions page
- Create completion screens with time tracking
- Add tutorial navigation guide
This commit is contained in:
2026-03-08 15:34:24 +01:00
parent fe8cfdd3f1
commit efeea1ae19
49 changed files with 6531 additions and 10 deletions

17
src/lib/auth/client.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createAuthClient } from 'better-auth/svelte';
import { browser } from '$app/environment';
export const authClient = browser ? createAuthClient({
baseURL: typeof window !== 'undefined' ? window.location.origin : ''
}) : null;
export function useAuth() {
if (!authClient) return null;
return {
signUp: authClient.signUp,
signIn: authClient.signIn,
signOut: authClient.signOut,
getSession: authClient.getSession
};
}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { language, setLanguage, availableLanguages } from '$lib/i18n';
const flags: Record<string, { flag: string; name: string }> = {
en: { flag: '🇬🇧', name: 'English' },
fr: { flag: '🇫🇷', name: 'Français' }
};
function handleLanguageChange(lang: string) {
setLanguage(lang);
}
</script>
<div class="flex gap-6 justify-center">
{#each availableLanguages as lang (lang)}
<button
onclick={() => handleLanguageChange(lang)}
class="w-20 flex flex-col items-center gap-2 transition-transform hover:scale-110 {$language ===
lang
? 'opacity-100'
: 'opacity-60 hover:opacity-80'}"
>
<div class="text-3xl">{flags[lang].flag}</div>
<span class="text-xs font-medium text-gray-700 text-center">{flags[lang].name}</span>
</button>
{/each}
</div>

View File

@@ -0,0 +1,279 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
type StepFormValues = {
title: string;
type: string;
order: number | string;
description: string;
content: string;
answer: string;
hint: string;
latitude?: number | string | null;
longitude?: number | string | null;
proximityRadius?: number | string;
};
let {
gameId,
gameTitle,
heading,
subheading,
submitLabel,
errorMessage,
initialValues,
maxOrder
}: {
gameId: number;
gameTitle: string;
heading: string;
subheading: string;
submitLabel: string;
errorMessage?: string;
initialValues: StepFormValues;
maxOrder: number;
} = $props();
const stepTypes = ['question', 'text', 'puzzle', 'location'];
let selectedType = $derived(initialValues.type || 'question');
let fieldConfig = $derived.by(() => {
switch (selectedType) {
case 'text':
return {
contentLabel: 'Text to display',
contentPlaceholder: 'Text shown to players',
showLocation: false,
showAnswer: false,
showHint: false
};
case 'location':
return {
contentLabel: 'Location instruction',
contentPlaceholder: 'Describe where players need to go',
showLocation: true,
showAnswer: true,
showHint: true
};
case 'puzzle':
return {
contentLabel: 'Puzzle statement',
contentPlaceholder: 'Describe the puzzle to solve',
showLocation: false,
showAnswer: true,
showHint: true
};
default:
return {
contentLabel: 'Question',
contentPlaceholder: 'Enter the question for players',
showLocation: false,
showAnswer: true,
showHint: true
};
}
});
</script>
<div class="min-h-screen bg-gray-50">
<div class="max-w-2xl mx-auto px-4 py-8">
<div class="mb-8">
<a
href={resolve(`/admin/games/${gameId}`)}
class="text-indigo-600 hover:text-indigo-700 text-sm font-medium mb-4 inline-block"
>
← Back to {gameTitle}
</a>
<h1 class="text-3xl font-bold text-gray-900 mb-2">{heading}</h1>
<p class="text-gray-600">{subheading}</p>
</div>
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<form method="POST" use:enhance class="p-8 space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="col-span-2">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
Step Title <span class="text-red-500">*</span>
</label>
<input
id="title"
type="text"
name="title"
value={initialValues.title}
placeholder="Enter step title"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
required
/>
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 mb-2">
Type <span class="text-red-500">*</span>
</label>
<select
id="type"
name="type"
bind:value={selectedType}
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
required
>
{#each stepTypes as stepType (stepType)}
<option value={stepType}>
{stepType.charAt(0).toUpperCase() + stepType.slice(1)}
</option>
{/each}
</select>
</div>
<div>
<label for="order" class="block text-sm font-medium text-gray-700 mb-2">
Step Order
</label>
<input
id="order"
type="number"
name="order"
value={initialValues.order}
min="1"
max={maxOrder}
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
/>
</div>
<div class="col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
name="description"
placeholder="Describe this step"
rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
>{initialValues.description}</textarea>
</div>
<div class="col-span-2">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">
{fieldConfig.contentLabel}
</label>
<textarea
id="content"
name="content"
placeholder={fieldConfig.contentPlaceholder}
rows="4"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
>{initialValues.content}</textarea>
</div>
{#if fieldConfig.showAnswer}
<div class="col-span-2">
<label for="answer" class="block text-sm font-medium text-gray-700 mb-2">
Expected answer
</label>
<input
id="answer"
type="text"
name="answer"
value={initialValues.answer}
placeholder="Expected answer for this step"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
/>
</div>
{/if}
{#if fieldConfig.showHint}
<div class="col-span-2">
<label for="hint" class="block text-sm font-medium text-gray-700 mb-2">
Hint
</label>
<textarea
id="hint"
name="hint"
placeholder="Optional hint for players"
rows="2"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
>{initialValues.hint}</textarea>
</div>
{/if}
{#if fieldConfig.showLocation}
<div class="col-span-2 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 class="text-sm font-semibold text-blue-900 mb-4">Location Coordinates</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="latitude" class="block text-sm font-medium text-gray-700 mb-2">
Latitude <span class="text-red-500">*</span>
</label>
<input
id="latitude"
type="number"
step="any"
name="latitude"
value={initialValues.latitude ?? ''}
placeholder="e.g., 48.8566"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
required
/>
</div>
<div>
<label for="longitude" class="block text-sm font-medium text-gray-700 mb-2">
Longitude <span class="text-red-500">*</span>
</label>
<input
id="longitude"
type="number"
step="any"
name="longitude"
value={initialValues.longitude ?? ''}
placeholder="e.g., 2.3522"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
required
/>
</div>
<div class="col-span-2">
<label for="proximityRadius" class="block text-sm font-medium text-gray-700 mb-2">
Proximity Radius (meters)
</label>
<input
id="proximityRadius"
type="number"
name="proximityRadius"
value={initialValues.proximityRadius ?? 50}
min="5"
max="500"
placeholder="50"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">Distance in meters within which the step will be validated (default: 50m)</p>
</div>
</div>
</div>
{/if}
</div>
{#if errorMessage}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{errorMessage}
</div>
{/if}
<div class="flex gap-4 pt-4">
<button
type="submit"
class="flex-1 bg-indigo-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors"
>
{submitLabel}
</button>
<a
href={resolve(`/admin/games/${gameId}`)}
class="flex-1 bg-gray-200 text-gray-800 py-3 px-6 rounded-lg font-semibold hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors text-center"
>
Cancel
</a>
</div>
</form>
</div>
</div>
</div>

138
src/lib/i18n/en.json Normal file
View File

@@ -0,0 +1,138 @@
{
"common": {
"language": "Language",
"selectLanguage": "Select Language",
"english": "English",
"french": "Français",
"german": "Deutsch",
"spanish": "Español"
},
"home": {
"title": "Outdoor Escape Game",
"subtitle": "Adventure awaits! Enter your session code to start playing.",
"playGame": "Play Game",
"joinWithCode": "Join with a session code"
},
"game": {
"sessionCode": "Session Code",
"enterSessionCode": "Enter session code",
"yourName": "Your Name",
"enterYourName": "Enter your name",
"acceptTerms": "I accept the",
"termsAndConditions": "terms and conditions",
"joinGame": "Join Game",
"joining": "Joining...",
"pleaseEnterCode": "Please enter a session code",
"pleaseEnterName": "Please enter your name",
"mustAcceptTerms": "You must accept the terms and conditions",
"failedToJoin": "Failed to join session",
"errorOccurred": "An error occurred. Please try again."
},
"gameplay": {
"progress": "Progress",
"step": "Step",
"of": "of",
"currentStep": "Current Step",
"yourAnswer": "Your Answer",
"enterYourAnswer": "Enter your answer",
"submitAnswer": "Submit Answer",
"checking": "Checking...",
"incorrectAnswer": "Incorrect answer",
"needAHint": "Need a hint?",
"continue": "Continue",
"loadingStep": "Loading step...",
"collectedItems": "Collected Items",
"inventory": "Inventory",
"previous": "Previous",
"next": "Next",
"emptyInventory": "No collected items yet.",
"viewingUnlockedStep": "You are viewing an already unlocked step. Go back to the active step to continue progression.",
"completedLabel": "Escape completed",
"completedTitle": "Great job, you completed the escape game!",
"completedIn": "Total time",
"playAgain": "Play again",
"sessionCode": "Session code",
"tutorial": "Tutorial",
"tutorialTitle": "Navigation bar guide",
"tutorialIntro": "This bar helps you move quickly during the game:",
"tutorialPrevious": "Previous: go back to the previous unlocked step.",
"tutorialInventory": "Inventory: open the collected items section.",
"tutorialNext": "Next: move to the next step only if it is already unlocked.",
"backToGame": "Back to game",
"locationError": "Location error",
"locatingYou": "Locating your position...",
"distance": "Distance",
"arrived": "You've arrived!",
"getWithin": "Get within",
"toValidate": "to validate",
"validateLocation": "Validate Location",
"locationDenied": "Location Access Denied",
"locationDeniedMessage": "Please enable location access in your browser settings to continue with this step.",
"locationRequired": "Location Access Required",
"locationRequiredMessage": "This step requires your location to show you the way to the destination.",
"enableLocation": "Enable Location",
"tryAgain": "Try Again"
},
"admin": {
"adminDashboard": "Admin Dashboard",
"createNewGame": "Create New Game",
"createSession": "Create Session",
"createSessionDescription": "Create a game session and generate an access code for players.",
"selectGame": "Select a game",
"expiresDate": "Expiration date",
"expiresTime": "Expiration time",
"expiresAtDateTime": "Expiration date and time",
"expiresAtDateTimeHelp": "Choose when this session should expire.",
"cancel": "Cancel",
"createGameBeforeSession": "You need at least one game before creating a session.",
"totalGames": "Total Games",
"activeSessions": "Active Sessions",
"totalPlayers": "Total Players",
"escapeGames": "Escape Games",
"gameTitle": "Game Title",
"steps": "Steps",
"sessions": "Sessions",
"created": "Created",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"confirmDeleteSessionTitle": "Delete session",
"confirmDeleteSession": "Are you sure you want to delete session",
"confirmDeleteTitle": "Delete game",
"confirmDeleteGame": "Are you sure you want to delete",
"confirmDelete": "Delete permanently",
"manage": "Manage",
"editSession": "Edit Session",
"editSessionDescription": "Update game, expiration date, and active status.",
"saveChanges": "Save changes",
"noGamesYet": "No escape games yet",
"createFirstGame": "Create Your First Game",
"recentSessions": "Recent Sessions",
"currentAndIncomingSessions": "Current and Incoming Sessions",
"meanResolutionTime": "Mean Resolution Time by Game",
"noResolutionData": "No completed sessions yet.",
"current": "Current",
"incoming": "Incoming",
"noCurrentOrIncomingSessions": "No current or incoming sessions",
"code": "Code",
"game": "Game",
"status": "Status",
"players": "Players",
"expires": "Expires",
"active": "Active",
"inactive": "Inactive",
"noSessions": "No sessions yet",
"logout": "Logout"
},
"login": {
"login": "Login",
"signup": "Sign Up",
"accessAdmin": "Access the admin dashboard",
"createAccount": "Create an admin account",
"emptyFields": "Please fill all fields",
"authFailed": "Authentication failed",
"hasAccount": "Already have an account?",
"noAccount": "Don't have an account?",
"loading": "Loading..."
}
}

138
src/lib/i18n/fr.json Normal file
View File

@@ -0,0 +1,138 @@
{
"common": {
"language": "Langue",
"selectLanguage": "Sélectionnez la Langue",
"english": "English",
"french": "Français",
"german": "Deutsch",
"spanish": "Español"
},
"home": {
"title": "Jeu d'Évasion en Plein Air",
"subtitle": "L'aventure vous attend ! Entrez votre code de session pour commencer à jouer.",
"playGame": "Jouez",
"joinWithCode": "Rejoignez avec un code de session"
},
"game": {
"sessionCode": "Code de Session",
"enterSessionCode": "Entrez le code de session",
"yourName": "Votre Nom",
"enterYourName": "Entrez votre nom",
"acceptTerms": "J'accepte les",
"termsAndConditions": "conditions d'utilisation",
"joinGame": "Rejoindre le Jeu",
"joining": "Connexion...",
"pleaseEnterCode": "Veuillez entrer un code de session",
"pleaseEnterName": "Veuillez entrer votre nom",
"mustAcceptTerms": "Vous devez accepter les conditions d'utilisation",
"failedToJoin": "Échec de la connexion à la session",
"errorOccurred": "Une erreur s'est produite. Veuillez réessayer."
},
"gameplay": {
"progress": "Progression",
"step": "Étape",
"of": "sur",
"currentStep": "Étape Actuelle",
"yourAnswer": "Votre Réponse",
"enterYourAnswer": "Entrez votre réponse",
"submitAnswer": "Soumettre la Réponse",
"checking": "Vérification...",
"incorrectAnswer": "Réponse incorrecte",
"needAHint": "Besoin d'un indice ?",
"continue": "Continuer",
"loadingStep": "Chargement de l'étape...",
"collectedItems": "Articles Collectés",
"inventory": "Inventaire",
"previous": "Précédent",
"next": "Suivant",
"emptyInventory": "Aucun objet collecté pour le moment.",
"viewingUnlockedStep": "Vous consultez une étape déjà débloquée. Revenez à l'étape active pour continuer la progression.",
"completedLabel": "Escape termine",
"completedTitle": "Bravo, vous avez termine l'escape game !",
"completedIn": "Temps total",
"playAgain": "Rejouer",
"sessionCode": "Code session",
"tutorial": "Tutoriel",
"tutorialTitle": "Guide de la barre de navigation",
"tutorialIntro": "Cette barre vous aide a naviguer rapidement pendant le jeu :",
"tutorialPrevious": "Precedent : revenir a l'etape debloquee precedente.",
"tutorialInventory": "Inventaire : ouvrir la zone des objets recoltes.",
"tutorialNext": "Suivant : aller a l'etape suivante uniquement si elle est deja debloquee.",
"backToGame": "Retour au jeu",
"locationError": "Erreur de localisation",
"locatingYou": "Localisation en cours...",
"distance": "Distance",
"arrived": "Vous êtes arrivé !",
"getWithin": "Approchez-vous à",
"toValidate": "pour valider",
"validateLocation": "Valider la position",
"locationDenied": "Accès à la localisation refusé",
"locationDeniedMessage": "Veuillez activer l'accès à la localisation dans les paramètres de votre navigateur pour continuer cette étape.",
"locationRequired": "Accès à la localisation requis",
"locationRequiredMessage": "Cette étape nécessite votre position pour vous montrer le chemin vers la destination.",
"enableLocation": "Activer la localisation",
"tryAgain": "Réessayer"
},
"admin": {
"adminDashboard": "Tableau de Bord Admin",
"createNewGame": "Créer un Nouveau Jeu",
"createSession": "Créer une Session",
"createSessionDescription": "Créez une session de jeu et générez un code d'accès pour les joueurs.",
"selectGame": "Sélectionnez un jeu",
"expiresDate": "Date d'expiration",
"expiresTime": "Heure d'expiration",
"expiresAtDateTime": "Date et heure d'expiration",
"expiresAtDateTimeHelp": "Choisissez quand cette session doit expirer.",
"cancel": "Annuler",
"createGameBeforeSession": "Vous avez besoin d'au moins un jeu avant de créer une session.",
"totalGames": "Jeux Totaux",
"activeSessions": "Sessions Actives",
"totalPlayers": "Joueurs Totaux",
"escapeGames": "Jeux d'Évasion",
"gameTitle": "Titre du Jeu",
"steps": "Étapes",
"sessions": "Sessions",
"created": "Créé",
"actions": "Actions",
"edit": "Modifier",
"delete": "Supprimer",
"confirmDeleteSessionTitle": "Supprimer la session",
"confirmDeleteSession": "Voulez-vous vraiment supprimer la session",
"confirmDeleteTitle": "Supprimer le jeu",
"confirmDeleteGame": "Voulez-vous vraiment supprimer",
"confirmDelete": "Supprimer definitivement",
"manage": "Gérer",
"editSession": "Modifier la session",
"editSessionDescription": "Mettez a jour le jeu, la date d'expiration et le statut actif.",
"saveChanges": "Enregistrer les modifications",
"noGamesYet": "Aucun jeu d'évasion pour le moment",
"createFirstGame": "Créez Votre Premier Jeu",
"recentSessions": "Sessions Récentes",
"currentAndIncomingSessions": "Sessions en cours et a venir",
"meanResolutionTime": "Temps moyen de resolution par jeu",
"noResolutionData": "Aucune session terminee pour le moment.",
"current": "En cours",
"incoming": "A venir",
"noCurrentOrIncomingSessions": "Aucune session en cours ou a venir",
"code": "Code",
"game": "Jeu",
"status": "Statut",
"players": "Joueurs",
"expires": "Expire",
"active": "Actif",
"inactive": "Inactif",
"noSessions": "Aucune session",
"logout": "Déconnexion"
},
"login": {
"login": "Connexion",
"signup": "Inscription",
"accessAdmin": "Accédez au tableau de bord d'administration",
"createAccount": "Créer un compte administrateur",
"emptyFields": "Veuillez remplir tous les champs",
"authFailed": "L'authentification a échoué",
"hasAccount": "Vous avez déjà un compte ?",
"noAccount": "Vous n'avez pas de compte ?",
"loading": "Chargement..."
}
}

51
src/lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import { writable, derived } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
import en from './en.json';
import fr from './fr.json';
type Messages = typeof en;
const translations: Record<string, Messages> = { en, fr };
// Get initial language
function getInitialLanguage(): string {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('language');
if (stored && stored in translations) {
return stored;
}
const browserLang = navigator.language.split('-')[0];
if (browserLang in translations) {
return browserLang;
}
}
return 'en';
}
// Create writable store for the current language
export const language: Writable<string> = writable(getInitialLanguage());
// Create derived store for the current messages
export const t: Readable<Messages> = derived(language, ($language) => {
return translations[$language] || translations['en'];
});
export function setLanguage(lang: string) {
if (lang in translations) {
if (typeof window !== 'undefined') {
localStorage.setItem('language', lang);
}
language.set(lang);
}
}
export function getLanguage(): string {
let currentLang = 'en';
language.subscribe((lang) => {
currentLang = lang;
})();
return currentLang;
}
export const availableLanguages = Object.keys(translations);

View File

@@ -1,14 +1,15 @@
import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean, doublePrecision } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Enum for step types
export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'challenge', 'photo', 'location']);
export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'location']);
// Escape Game table
// Escape Game table (soft delete)
export const escapeGame = pgTable('escape_game', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
description: text('description'),
isDeleted: boolean('is_deleted').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
@@ -23,9 +24,12 @@ export const step = pgTable('step', {
description: text('description'),
type: stepTypeEnum('type').notNull(),
order: integer('order').notNull(), // Order of the step in the game
content: text('content'), // Question text, puzzle instructions, etc.
answer: text('answer'), // Expected answer for question/puzzle types
content: text('content'), // Question text, puzzle, etc.
answer: text('answer'), // Expected answer for question types
hint: text('hint'), // Optional hint for the step
latitude: doublePrecision('latitude'), // Target latitude for location steps
longitude: doublePrecision('longitude'), // Target longitude for location steps
proximityRadius: integer('proximity_radius').default(50), // Proximity radius in meters (default: 50m)
createdAt: timestamp('created_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,15 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { auth } from '$lib/server/auth';
export const load: LayoutServerLoad = async (event) => {
const session = await auth.api.getSession({ headers: event.request.headers });
if (!session?.user) {
redirect(303, '/login');
}
return {
user: session.user
};
};

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { t } from '$lib/i18n';
let { children } = $props();
</script>
<div class="flex h-screen bg-gray-50">
<aside class="hidden w-72 shrink-0 border-r border-gray-200 bg-white lg:flex lg:flex-col">
<div class="border-b border-gray-200 px-6 py-5">
<h2 class="text-lg font-bold text-gray-900">{$t.admin?.adminDashboard || 'Admin'}</h2>
<p class="mt-1 text-sm text-gray-500">{$t.admin?.adminDashboard || 'Dashboard menu'}</p>
</div>
<nav class="flex-1 space-y-2 px-4 py-4">
<a
href={resolve('/admin')}
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/admin' ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100'
}`}
>
{$t.admin?.adminDashboard || 'Dashboard'}
</a>
<a
href={resolve('/admin/games')}
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname.startsWith('/admin/games')
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{$t.admin?.game || 'Games'}
</a>
<a
href={resolve('/admin/sessions')}
class={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname.startsWith('/admin/sessions')
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{$t.admin?.sessions || 'Sessions'}
</a>
</nav>
<div class="border-t border-gray-200 p-4">
<form method="POST" action="/admin?/logout">
<button
type="submit"
class="w-full rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50"
>
{$t.admin?.logout || 'Logout'}
</button>
</form>
</div>
</aside>
<main class="min-w-0 flex-1 overflow-auto">
<div class="border-b border-gray-200 bg-white px-4 py-3 lg:hidden">
<p class="text-sm font-semibold text-gray-700">{$t.admin?.adminDashboard || 'Admin'}</p>
</div>
{@render children()}
</main>
</div>

View File

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

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { t } from '$lib/i18n';
let { data }: { data: PageData } = $props();
function formatDuration(minutes: number): string {
if (minutes < 60) {
return `${Math.round(minutes)} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = Math.round(minutes % 60);
return `${hours}h ${remainingMinutes}m`;
}
</script>
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.adminDashboard}</h1>
<a
href={resolve('/admin/sessions/new')}
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
>
{$t.admin.createSession}
</a>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">{$t.admin.totalGames}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.totalGames || 0}</p>
</div>
<div class="bg-indigo-100 rounded-full p-3">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">{$t.admin.activeSessions}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.activeSessions || 0}</p>
</div>
<div class="bg-green-100 rounded-full p-3">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">{$t.admin.totalPlayers}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{data.stats?.totalPlayers || 0}</p>
</div>
<div class="bg-blue-100 rounded-full p-3">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
</div>
</div>
</div>
<!-- Current & Incoming Sessions -->
<div class="mb-8 bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">{$t.admin.currentAndIncomingSessions}</h2>
</div>
{#if data.currentAndIncomingSessions && data.currentAndIncomingSessions.length > 0}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.code}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.game}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.status}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.players}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.expires}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.currentAndIncomingSessions as session (session.id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm font-medium text-gray-900">{session.code}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{session.gameName}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={`px-2 py-1 text-xs font-semibold rounded-full ${
session.startedAt && !session.completedAt
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{session.startedAt && !session.completedAt ? $t.admin.current : $t.admin.incoming}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{session.playerCount || 0}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(session.expiresAt).toLocaleString()}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="px-6 py-12 text-center text-gray-500">
{$t.admin.noCurrentOrIncomingSessions}
</div>
{/if}
</div>
<!-- Mean Resolution Time Graph -->
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">{$t.admin.meanResolutionTime}</h2>
</div>
{#if data.resolutionMetrics && data.resolutionMetrics.length > 0}
<div class="p-6 space-y-4">
{#each data.resolutionMetrics as metric (metric.gameId)}
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-900 truncate">{metric.gameTitle}</div>
<div class="text-sm text-gray-600 whitespace-nowrap">
{formatDuration(metric.meanResolutionMinutes)}
<span class="text-gray-400">({metric.completedSessions})</span>
</div>
</div>
<div class="h-3 w-full rounded-full bg-gray-100">
<div
class="h-3 rounded-full bg-indigo-500"
style={`width: ${Math.max(
6,
(metric.meanResolutionMinutes / (data.maxMeanResolutionMinutes || 1)) * 100
)}%`}
></div>
</div>
</div>
{/each}
</div>
{:else}
<div class="px-6 py-12 text-center">
<p class="text-gray-500">{$t.admin.noResolutionData}</p>
</div>
{/if}
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
let { data, form }: { data: PageData; form: ActionData } = $props();
let gameToDelete = $state<{ id: number; title: string } | null>(null);
function openDeleteModal(game: { id: number; title: string }) {
gameToDelete = game;
}
function closeDeleteModal() {
gameToDelete = null;
}
$effect(() => {
if (form?.success === true) {
closeDeleteModal();
}
});
</script>
<div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-7xl px-4 py-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.escapeGames}</h1>
<p class="mt-1 text-sm text-gray-500">{$t.admin.manage}</p>
</div>
<a
href={resolve('/admin/games/new')}
class="rounded-lg bg-indigo-600 px-4 py-2 font-semibold text-white transition-colors hover:bg-indigo-700"
>
{$t.admin.createNewGame}
</a>
</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">
{#if form?.error}
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{$t.admin.gameTitle}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{$t.admin.steps}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{$t.admin.sessions}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{$t.admin.created}
</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
{$t.admin.actions}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if data.games && data.games.length > 0}
{#each data.games as game (game.id)}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{game.title}</div>
{#if game.description}
<div class="text-sm text-gray-500">{game.description}</div>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{game.stepCount}</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{game.sessionCount}</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{new Date(game.createdAt).toLocaleDateString()}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
<div class="inline-flex items-center gap-2">
<a
href={resolve(`/admin/games/${game.id}`)}
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{$t.admin.edit}
</a>
<button
type="button"
onclick={() => openDeleteModal({ id: game.id, title: game.title })}
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
</svg>
{$t.admin.delete}
</button>
</div>
</td>
</tr>
{/each}
{:else}
<tr>
<td class="px-6 py-10 text-center text-sm text-gray-500" colspan="5">
{$t.admin.noGamesYet}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
{#if gameToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="text-lg font-semibold text-gray-900">{$t.admin.confirmDeleteTitle}</h2>
<p class="mt-2 text-sm text-gray-600">
{$t.admin.confirmDeleteGame} <span class="font-semibold text-gray-900">{gameToDelete.title}</span>?
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeDeleteModal}
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
{$t.admin.cancel}
</button>
<form method="POST" action="?/deleteGame" use:enhance>
<input type="hidden" name="gameId" value={gameToDelete.id} />
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
>
{$t.admin.confirmDelete}
</button>
</form>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,127 @@
import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema';
import { and, eq } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params }) => {
const gameId = parseInt(params.id, 10);
if (isNaN(gameId)) {
error(400, 'Invalid game ID');
}
const game = await db.query.escapeGame.findFirst({
where: eq(escapeGame.id, gameId),
with: {
steps: {
orderBy: (steps) => steps.order
}
}
});
if (!game) {
error(404, 'Game not found');
}
return {
game
};
};
export const actions: Actions = {
reorderSteps: async ({ params, request }) => {
const gameId = Number.parseInt(params.id, 10);
if (Number.isNaN(gameId)) {
return fail(400, { error: 'Invalid game ID' });
}
const formData = await request.formData();
const orderRaw = formData.get('order')?.toString() ?? '';
if (!orderRaw) {
return fail(400, { error: 'Missing order payload' });
}
let orderedIds: number[] = [];
try {
const parsed = JSON.parse(orderRaw);
if (!Array.isArray(parsed)) {
return fail(400, { error: 'Invalid order payload' });
}
orderedIds = parsed
.map((value) => Number.parseInt(String(value), 10))
.filter((value) => Number.isInteger(value) && value > 0);
} catch {
return fail(400, { error: 'Invalid JSON payload' });
}
if (orderedIds.length === 0) {
return fail(400, { error: 'No steps to reorder' });
}
const uniqueIds = new Set(orderedIds);
if (uniqueIds.size !== orderedIds.length) {
return fail(400, { error: 'Duplicate step IDs in payload' });
}
const gameSteps = await db
.select({ id: step.id })
.from(step)
.where(eq(step.escapeGameId, gameId));
if (gameSteps.length !== orderedIds.length) {
return fail(400, { error: 'Order payload does not match game steps' });
}
const gameStepIds = new Set(gameSteps.map((gameStep) => gameStep.id));
for (const id of orderedIds) {
if (!gameStepIds.has(id)) {
return fail(400, { error: 'Order payload contains invalid step IDs' });
}
}
await db.transaction(async (tx) => {
for (let index = 0; index < orderedIds.length; index += 1) {
const stepId = orderedIds[index];
await tx
.update(step)
.set({ order: index + 1 })
.where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)));
}
});
return { success: true };
},
deleteStep: async ({ params, request }) => {
const gameId = Number.parseInt(params.id, 10);
if (Number.isNaN(gameId)) {
return fail(400, { error: 'Invalid game ID' });
}
const formData = await request.formData();
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
if (Number.isNaN(stepId)) {
return fail(400, { error: 'Invalid step ID' });
}
// Verify the step exists and belongs to this game
const existingStep = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
});
if (!existingStep) {
return fail(404, { error: 'Step not found' });
}
try {
await db.delete(step).where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)));
return { success: true };
} catch (err) {
console.error('Delete step error:', err);
return fail(500, { error: 'Failed to delete step' });
}
}
};

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let game = $derived.by(() => data.game);
let steps = $derived([...(game.steps ?? [])]);
let draggedStepId = $state<number | null>(null);
let reorderPayload = $state('');
let reorderForm = $state<HTMLFormElement | undefined>(undefined);
let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null);
function openDeleteModal(step: { id: number; title: string; order: number }) {
stepToDelete = step;
}
function closeDeleteModal() {
stepToDelete = null;
}
function moveStep(list: typeof steps, fromIndex: number, toIndex: number) {
const clone = [...list];
const [moved] = clone.splice(fromIndex, 1);
if (!moved) {
return list;
}
clone.splice(toIndex, 0, moved);
return clone.map((item, index) => ({ ...item, order: index + 1 }));
}
function onDragStart(stepId: number) {
draggedStepId = stepId;
}
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(targetStepId: number) {
if (draggedStepId === null || draggedStepId === targetStepId) {
draggedStepId = null;
return;
}
const fromIndex = steps.findIndex((item) => item.id === draggedStepId);
const toIndex = steps.findIndex((item) => item.id === targetStepId);
if (fromIndex === -1 || toIndex === -1) {
draggedStepId = null;
return;
}
steps = moveStep(steps, fromIndex, toIndex);
reorderPayload = JSON.stringify(steps.map((item) => item.id));
reorderForm?.requestSubmit();
draggedStepId = null;
}
</script>
<div class="min-h-screen bg-gray-50">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Game Header -->
<div class="mb-8">
<a
href={resolve('/admin')}
class="text-indigo-600 hover:text-indigo-700 text-sm font-medium mb-4 inline-block"
>
← Back to Dashboard
</a>
<div class="bg-white rounded-xl shadow-md p-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">{game.title}</h1>
{#if game.description}
<p class="text-gray-600 mb-4">{game.description}</p>
{/if}
<div class="flex gap-4 text-sm text-gray-500">
<span>Created: {new Date(game.createdAt).toLocaleDateString()}</span>
<span>Steps: {game.steps?.length || 0}</span>
</div>
</div>
</div>
<!-- Steps Section -->
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-8 py-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
<a
href={resolve(`/admin/games/${game.id}/steps/new`)}
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
>
+ Add Step
</a>
</div>
</div>
<form method="POST" action="?/reorderSteps" use:enhance bind:this={reorderForm}>
<input type="hidden" name="order" bind:value={reorderPayload} />
</form>
{#if steps.length > 0}
<div class="divide-y divide-gray-200">
{#each steps as step (step.id)}
<div
class="px-8 py-6 hover:bg-gray-50 transition-colors cursor-move"
draggable="true"
role="button"
tabindex="0"
aria-label={`Deplacer l'etape ${step.order}`}
ondragstart={() => onDragStart(step.id)}
ondragover={onDragOver}
ondrop={() => onDrop(step.id)}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<span class="text-gray-400 text-sm">::</span>
<span class="inline-block bg-indigo-100 text-indigo-800 px-3 py-1 rounded-full text-sm font-semibold">
Step {step.order}
</span>
<span class="text-gray-500 text-sm">{step.type}</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-1">{step.title}</h3>
{#if step.description}
<p class="text-gray-600 text-sm">{step.description}</p>
{/if}
</div>
<div class="inline-flex items-center gap-2">
<a
href={resolve(`/admin/games/${game.id}/steps/${step.id}`)}
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</a>
<button
type="button"
onclick={() => openDeleteModal({ id: step.id, title: step.title, order: step.order })}
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
</svg>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="px-8 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m0 0h6"
/>
</svg>
<p class="text-gray-500 mb-4">No steps yet. Add your first step to get started.</p>
<a
href={resolve(`/admin/games/${game.id}/steps/new`)}
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
>
Add First Step
</a>
</div>
{/if}
</div>
</div>
</div>
{#if stepToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="text-lg font-semibold text-gray-900">Confirm Deletion</h2>
<p class="mt-2 text-sm text-gray-600">
Are you sure you want to delete <span class="font-semibold text-gray-900">Step {stepToDelete.order}: {stepToDelete.title}</span>?
This action cannot be undone.
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeDeleteModal}
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
Cancel
</button>
<form method="POST" action="?/deleteStep" use:enhance>
<input type="hidden" name="stepId" value={stepToDelete.id} />
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
>
Delete Step
</button>
</form>
</div>
</div>
</div>
{/if}
{#if stepToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="text-lg font-semibold text-gray-900">Confirm Deletion</h2>
<p class="mt-2 text-sm text-gray-600">
Are you sure you want to delete <span class="font-semibold text-gray-900">Step {stepToDelete.order}: {stepToDelete.title}</span>?
This action cannot be undone.
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeDeleteModal}
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
Cancel
</button>
<form method="POST" action="?/deleteStep">
<input type="hidden" name="stepId" value={stepToDelete.id} />
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
>
Delete Step
</button>
</form>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,226 @@
import { fail, redirect, error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema';
import { and, eq, max } from 'drizzle-orm';
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
type StepType = (typeof stepTypes)[number];
const isStepType = (value: string): value is StepType =>
stepTypes.includes(value as StepType);
export const load: PageServerLoad = async ({ params }) => {
const gameId = Number.parseInt(params.id, 10);
const stepId = Number.parseInt(params.stepId, 10);
if (Number.isNaN(gameId) || Number.isNaN(stepId)) {
error(400, 'Invalid ID');
}
const game = await db.query.escapeGame.findFirst({
where: eq(escapeGame.id, gameId)
});
if (!game) {
error(404, 'Game not found');
}
const gameStep = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
});
if (!gameStep) {
error(404, 'Step not found');
}
// Get the total number of steps
const lastStep = await db
.select({ order: max(step.order) })
.from(step)
.where(eq(step.escapeGameId, gameId))
.then((result) => result[0]?.order ?? 0);
return {
game,
step: gameStep,
totalSteps: lastStep
};
};
export const actions: Actions = {
default: async ({ params, request }) => {
const gameId = Number.parseInt(params.id, 10);
const stepId = Number.parseInt(params.stepId, 10);
if (Number.isNaN(gameId) || Number.isNaN(stepId)) {
return fail(400, { error: 'Invalid ID' });
}
const formData = await request.formData();
const title = formData.get('title')?.toString().trim() ?? '';
const description = formData.get('description')?.toString().trim() ?? '';
const type = formData.get('type')?.toString() ?? '';
const content = formData.get('content')?.toString().trim() ?? '';
const answer = formData.get('answer')?.toString().trim() ?? '';
const hint = formData.get('hint')?.toString().trim() ?? '';
const order = Number.parseInt(formData.get('order')?.toString() ?? '1', 10);
const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
if (!title) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Le titre est requis'
});
}
if (!type) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Le type est requis'
});
}
if (!isStepType(type)) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Type invalide'
});
}
// Validate location-specific fields
if (type === 'location') {
if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
latitude: latitude?.toString() ?? '',
longitude: longitude?.toString() ?? '',
proximityRadius,
error: 'Latitude and longitude are required for location steps'
});
}
if (latitude < -90 || latitude > 90) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
latitude: latitude.toString(),
longitude: longitude?.toString() ?? '',
proximityRadius,
error: 'Latitude must be between -90 and 90'
});
}
if (longitude < -180 || longitude > 180) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
latitude: latitude?.toString() ?? '',
longitude: longitude.toString(),
proximityRadius,
error: 'Longitude must be between -180 and 180'
});
}
}
// Get the current total steps to validate order
const lastStep = await db
.select({ order: max(step.order) })
.from(step)
.where(eq(step.escapeGameId, gameId))
.then((result) => result[0]?.order ?? 0);
if (!Number.isInteger(order) || order < 1 || order > lastStep) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: `L'ordre doit etre entre 1 et ${lastStep}`
});
}
try {
const updated = await db
.update(step)
.set({
title,
description: description || null,
type,
order,
content: content || null,
answer: answer || null,
hint: hint || null,
latitude,
longitude,
proximityRadius
})
.where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)))
.returning({ id: step.id });
if (!updated[0]) {
return fail(404, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Etape introuvable'
});
}
} catch (err) {
console.error('Update step error:', err);
return fail(500, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Une erreur est survenue'
});
}
redirect(302, `/admin/games/${gameId}`);
}
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import StepForm from '$lib/components/StepForm.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let formMap = $derived.by<Record<string, unknown>>(() => (form ?? {}) as Record<string, unknown>);
let initialValues = $derived.by(() => ({
title: typeof formMap.title === 'string' ? formMap.title : data.step.title,
type: typeof formMap.type === 'string' ? formMap.type : data.step.type,
order:
typeof formMap.order === 'number' || typeof formMap.order === 'string'
? formMap.order
: data.step.order,
description:
typeof formMap.description === 'string'
? formMap.description
: (data.step.description ?? ''),
content: typeof formMap.content === 'string' ? formMap.content : (data.step.content ?? ''),
answer: typeof formMap.answer === 'string' ? formMap.answer : (data.step.answer ?? ''),
hint: typeof formMap.hint === 'string' ? formMap.hint : (data.step.hint ?? ''),
latitude: typeof formMap.latitude === 'string' || typeof formMap.latitude === 'number'
? formMap.latitude
: data.step.latitude,
longitude: typeof formMap.longitude === 'string' || typeof formMap.longitude === 'number'
? formMap.longitude
: data.step.longitude,
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
? formMap.proximityRadius
: (data.step.proximityRadius ?? 50)
}));
</script>
<StepForm
gameId={data.game.id}
gameTitle={data.game.title}
heading="Edit Step"
subheading="Modify this step for your escape game."
submitLabel="Save Changes"
errorMessage={form?.error}
{initialValues}
maxOrder={data.totalSteps}
/>

View File

@@ -0,0 +1,197 @@
import { fail, redirect, error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema';
import { eq, max } from 'drizzle-orm';
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
type StepType = (typeof stepTypes)[number];
const isStepType = (value: string): value is StepType =>
stepTypes.includes(value as StepType);
export const load: PageServerLoad = async ({ params }) => {
const gameId = parseInt(params.id, 10);
if (isNaN(gameId)) {
error(400, 'Invalid game ID');
}
const game = await db.query.escapeGame.findFirst({
where: eq(escapeGame.id, gameId)
});
if (!game) {
error(404, 'Game not found');
}
// Get the highest step order
const lastStep = await db
.select({ order: max(step.order) })
.from(step)
.where(eq(step.escapeGameId, gameId))
.then((result) => result[0]?.order ?? 0);
const nextStepOrder = lastStep + 1;
return {
game,
nextStepOrder,
totalSteps: lastStep
};
};
export const actions: Actions = {
default: async ({ params, request }) => {
const gameId = parseInt(params.id, 10);
if (isNaN(gameId)) {
return fail(400, { error: 'Invalid game ID' });
}
const formData = await request.formData();
const title = formData.get('title')?.toString().trim() ?? '';
const description = formData.get('description')?.toString().trim() ?? '';
const type = formData.get('type')?.toString() ?? '';
const content = formData.get('content')?.toString().trim() ?? '';
const answer = formData.get('answer')?.toString().trim() ?? '';
const hint = formData.get('hint')?.toString().trim() ?? '';
const order = parseInt(formData.get('order')?.toString() ?? '1', 10);
const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
if (!title) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
error: 'Le titre est requis'
});
}
if (!type) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
error: 'Le type est requis'
});
}
if (!isStepType(type)) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
error: 'Type invalide'
});
}
// Validate location-specific fields
if (type === 'location') {
if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
latitude: latitude?.toString() ?? '',
longitude: longitude?.toString() ?? '',
proximityRadius,
error: 'Latitude and longitude are required for location steps'
});
}
if (latitude < -90 || latitude > 90) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
latitude: latitude.toString(),
longitude: longitude?.toString() ?? '',
proximityRadius,
error: 'Latitude must be between -90 and 90'
});
}
if (longitude < -180 || longitude > 180) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
latitude: latitude?.toString() ?? '',
longitude: longitude.toString(),
proximityRadius,
error: 'Longitude must be between -180 and 180'
});
}
}
// Get the current total steps to validate order
const lastStep = await db
.select({ order: max(step.order) })
.from(step)
.where(eq(step.escapeGameId, gameId))
.then((result) => result[0]?.order ?? 0);
const maxOrder = lastStep + 1;
if (!Number.isInteger(order) || order < 1 || order > maxOrder) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: `L'ordre doit etre entre 1 et ${maxOrder}`
});
}
try {
await db.insert(step).values({
escapeGameId: gameId,
title,
description: description || null,
type,
order,
content: content || null,
answer: answer || null,
hint: hint || null,
latitude,
longitude,
proximityRadius
});
} catch (error) {
console.error('Create step error:', error);
return fail(500, {
title,
description,
type,
content,
answer,
hint,
error: 'Une erreur est survenue'
});
}
redirect(302, `/admin/games/${gameId}`);
}
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import StepForm from '$lib/components/StepForm.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let formMap = $derived.by<Record<string, unknown>>(() => (form ?? {}) as Record<string, unknown>);
let initialValues = $derived.by(() => ({
title: typeof formMap.title === 'string' ? formMap.title : '',
type: typeof formMap.type === 'string' ? formMap.type : 'question',
order:
typeof formMap.order === 'number' || typeof formMap.order === 'string'
? formMap.order
: data.nextStepOrder,
description: typeof formMap.description === 'string' ? formMap.description : '',
content: typeof formMap.content === 'string' ? formMap.content : '',
answer: typeof formMap.answer === 'string' ? formMap.answer : '',
hint: typeof formMap.hint === 'string' ? formMap.hint : '',
latitude: typeof formMap.latitude === 'string' || typeof formMap.latitude === 'number'
? formMap.latitude
: '',
longitude: typeof formMap.longitude === 'string' || typeof formMap.longitude === 'number'
? formMap.longitude
: '',
proximityRadius: typeof formMap.proximityRadius === 'string' || typeof formMap.proximityRadius === 'number'
? formMap.proximityRadius
: 50
}));
</script>
<StepForm
gameId={data.game.id}
gameTitle={data.game.title}
heading="Add New Step"
subheading="Create a new step for your escape game."
submitLabel="Create Step"
errorMessage={form?.error}
{initialValues}
maxOrder={data.nextStepOrder}
/>

View File

@@ -0,0 +1,66 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db';
import { escapeGame } from '$lib/server/db/schema';
export const actions: Actions = {
default: async (event) => {
const formData = await event.request.formData();
const title = formData.get('title')?.toString().trim() ?? '';
const description = formData.get('description')?.toString().trim() ?? '';
let createdGameId: number | null = null;
if (!title) {
return fail(400, {
title,
description,
error: 'Le titre est requis'
});
}
if (title.length < 3) {
return fail(400, {
title,
description,
error: 'Le titre doit contenir au moins 3 caractères'
});
}
try {
const result = await db
.insert(escapeGame)
.values({
title,
description: description || null
})
.returning({ id: escapeGame.id });
if (!result[0]) {
return fail(500, {
title,
description,
error: 'Erreur lors de la création du jeu'
});
}
createdGameId = result[0].id;
} catch (error) {
console.error('Create game error:', error);
return fail(500, {
title,
description,
error: 'Une erreur est survenue'
});
}
if (createdGameId === null) {
return fail(500, {
title,
description,
error: 'Erreur lors de la création du jeu'
});
}
redirect(302, `/admin/games/${createdGameId}`);
}
};

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let title = $state('');
let description = $state('');
let { form }: { form: ActionData } = $props();
</script>
<div class="min-h-screen bg-gray-50">
<div class="max-w-2xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Create New Game</h1>
<p class="text-gray-600">Create a new escape game and add steps to it.</p>
</div>
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<form method="POST" use:enhance class="p-8 space-y-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
Game Title <span class="text-red-500">*</span>
</label>
<input
id="title"
type="text"
name="title"
bind:value={title}
placeholder="Enter game title"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors"
required
/>
<p class="mt-1 text-sm text-gray-500">Minimum 3 characters</p>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
name="description"
bind:value={description}
placeholder="Describe your escape game"
rows="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none transition-colors resize-none"
></textarea>
<p class="mt-1 text-sm text-gray-500">Optional</p>
</div>
{#if form?.error}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<div class="flex gap-4 pt-4">
<button
type="submit"
class="flex-1 bg-indigo-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors"
>
Create Game
</button>
<a
href="/admin"
class="flex-1 bg-gray-200 text-gray-800 py-3 px-6 rounded-lg font-semibold hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors text-center"
>
Cancel
</a>
</div>
</form>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
let { data, form }: { data: PageData; form: ActionData } = $props();
let sessionToDelete = $state<{ id: number; code: string } | null>(null);
function openDeleteModal(session: { id: number; code: string }) {
sessionToDelete = session;
}
function closeDeleteModal() {
sessionToDelete = null;
}
$effect(() => {
if (form?.success === true) {
closeDeleteModal();
}
});
</script>
<div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-7xl px-4 py-8">
<div class="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900">{$t.admin.sessions}</h1>
<p class="mt-1 text-sm text-gray-500">{$t.admin.recentSessions}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a
href={resolve('/admin/sessions/new')}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
>
{$t.admin.createSession}
</a>
<form method="GET" action={resolve('/admin/sessions')} class="flex items-center gap-2">
<label for="gameId" class="text-sm font-medium text-gray-700">{$t.admin.game}</label>
<select
id="gameId"
name="gameId"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All games</option>
{#each data.games as game (game.id)}
<option value={game.id} selected={data.selectedGameId === game.id}>{game.title}</option>
{/each}
</select>
<button
type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
>
Filter
</button>
</form>
</div>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{form.error}</div>
{/if}
<div class="overflow-hidden rounded-xl bg-white shadow-md">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.code}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.game}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.status}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.players}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.created}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.expires}</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">{$t.admin.actions}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if data.sessions.length > 0}
{#each data.sessions as session (session.id)}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">{session.code}</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{session.gameTitle}</td>
<td class="whitespace-nowrap px-6 py-4">
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${session.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700'}`}>
{session.isActive ? $t.admin.active : $t.admin.inactive}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{session.players}</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{new Date(session.createdAt).toLocaleString()}</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-600">{new Date(session.expiresAt).toLocaleString()}</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
<div class="inline-flex items-center gap-2">
<a
href={resolve('/(admin)/admin/sessions/[id]', { id: session.id.toString() })}
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{$t.admin.edit}
</a>
<button
type="button"
onclick={() => openDeleteModal({ id: session.id, code: session.code })}
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-3 py-1.5 text-red-700 hover:bg-red-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
</svg>
{$t.admin.delete}
</button>
</div>
</td>
</tr>
{/each}
{:else}
<tr>
<td class="px-6 py-10 text-center text-sm text-gray-500" colspan="7">{$t.admin.noSessions}</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
{#if sessionToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="text-lg font-semibold text-gray-900">{$t.admin.confirmDeleteSessionTitle}</h2>
<p class="mt-2 text-sm text-gray-600">
{$t.admin.confirmDeleteSession}
<span class="font-semibold text-gray-900">{sessionToDelete.code}</span>?
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeDeleteModal}
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
{$t.admin.cancel}
</button>
<form method="POST" action="?/deleteSession" use:enhance>
<input type="hidden" name="sessionId" value={sessionToDelete.id} />
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
>
{$t.admin.confirmDelete}
</button>
</form>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,131 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { escapeGame, gameSession } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
function toDateTimeLocalInputValue(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hours = `${date.getHours()}`.padStart(2, '0');
const minutes = `${date.getMinutes()}`.padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
export const load: PageServerLoad = async ({ params }) => {
const sessionId = Number.parseInt(params.id, 10);
if (!Number.isInteger(sessionId) || sessionId <= 0) {
error(400, 'Invalid session ID');
}
const session = await db.query.gameSession.findFirst({
where: eq(gameSession.id, sessionId),
with: {
escapeGame: true
}
});
if (!session) {
error(404, 'Session not found');
}
const gamesRaw = await db.query.escapeGame.findMany({
orderBy: (game, { asc }) => [asc(game.title)],
columns: {
id: true,
title: true
}
});
return {
session: {
id: session.id,
code: session.code,
gameId: session.escapeGameId,
expiresAt: toDateTimeLocalInputValue(session.expiresAt),
isActive: session.isActive === 1
},
games: gamesRaw
};
};
export const actions: Actions = {
default: async ({ params, request }) => {
const formData = await request.formData();
const rawGameId = formData.get('gameId')?.toString() ?? '';
const rawExpiresAt = formData.get('expiresAt')?.toString() ?? '';
const isActive = formData.get('isActive') === 'on';
const sessionId = Number.parseInt(params.id, 10);
if (!Number.isInteger(sessionId) || sessionId <= 0) {
return fail(400, {
error: 'Invalid session ID',
gameId: rawGameId,
expiresAt: rawExpiresAt,
isActive
});
}
const gameId = Number.parseInt(rawGameId, 10);
const expiresAt = new Date(rawExpiresAt);
if (!Number.isInteger(gameId) || gameId <= 0) {
return fail(400, {
error: 'Please select a valid game',
gameId: rawGameId,
expiresAt: rawExpiresAt,
isActive
});
}
if (!rawExpiresAt || Number.isNaN(expiresAt.getTime())) {
return fail(400, {
error: 'Please provide a valid expiration date and time',
gameId: rawGameId,
expiresAt: rawExpiresAt,
isActive
});
}
const existingGame = await db.query.escapeGame.findFirst({
where: eq(escapeGame.id, gameId),
columns: { id: true }
});
if (!existingGame) {
return fail(404, {
error: 'Selected game does not exist',
gameId: rawGameId,
expiresAt: rawExpiresAt,
isActive
});
}
const existingSession = await db.query.gameSession.findFirst({
where: eq(gameSession.id, sessionId),
columns: { id: true }
});
if (!existingSession) {
return fail(404, {
error: 'Session not found',
gameId: rawGameId,
expiresAt: rawExpiresAt,
isActive
});
}
await db
.update(gameSession)
.set({
escapeGameId: gameId,
expiresAt,
isActive: isActive ? 1 : 0
})
.where(eq(gameSession.id, sessionId));
redirect(303, `/admin/sessions?gameId=${gameId}`);
}
};

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold text-gray-900">{$t.admin.editSession}</h1>
<p class="text-gray-600">{$t.admin.editSessionDescription}</p>
</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">
<form method="POST" use:enhance class="space-y-6 p-8">
<div>
<label for="sessionCode" class="mb-2 block text-sm font-medium text-gray-700">
{$t.admin.code}
</label>
<input
id="sessionCode"
type="text"
value={data.session.code}
readonly
class="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-100 px-4 py-3 text-sm text-gray-700"
/>
</div>
<div>
<label for="gameId" class="mb-2 block text-sm font-medium text-gray-700">
{$t.admin.game} <span class="text-red-500">*</span>
</label>
<select
id="gameId"
name="gameId"
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
>
<option value="">{$t.admin.selectGame}</option>
{#each data.games as game (game.id)}
<option value={game.id} selected={Number(form?.gameId ?? data.session.gameId) === game.id}>
{game.title}
</option>
{/each}
</select>
</div>
<div>
<label for="expiresAt" class="mb-2 block text-sm font-medium text-gray-700">
{$t.admin.expiresAtDateTime} <span class="text-red-500">*</span>
</label>
<input
id="expiresAt"
type="datetime-local"
name="expiresAt"
value={form?.expiresAt ?? data.session.expiresAt}
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
<p class="mt-1 text-sm text-gray-500">{$t.admin.expiresAtDateTimeHelp}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<label for="isActive" class="flex items-center gap-3 text-sm font-medium text-gray-700">
<input
id="isActive"
type="checkbox"
name="isActive"
checked={Boolean(form?.isActive ?? data.session.isActive)}
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>{$t.admin.active}</span>
</label>
</div>
{#if form?.error}
<div class="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<div class="flex gap-4 pt-2">
<button
type="submit"
class="flex-1 rounded-lg bg-indigo-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{$t.admin.saveChanges}
</button>
<a
href={resolve('/admin/sessions')}
class="flex-1 rounded-lg bg-gray-200 px-6 py-3 text-center font-semibold text-gray-800 transition-colors hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
{$t.admin.cancel}
</a>
</div>
</form>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold text-gray-900">{$t.admin.createSession}</h1>
<p class="text-gray-600">{$t.admin.createSessionDescription}</p>
</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">
<form method="POST" use:enhance class="space-y-6 p-8">
<div>
<label for="gameId" class="mb-2 block text-sm font-medium text-gray-700">
{$t.admin.game} <span class="text-red-500">*</span>
</label>
<select
id="gameId"
name="gameId"
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
>
<option value="">{$t.admin.selectGame}</option>
{#each data.games as game (game.id)}
<option
value={game.id}
selected={Number(form?.gameId ?? data.selectedGameId ?? 0) === game.id}
>
{game.title}
</option>
{/each}
</select>
</div>
<div>
<label for="expiresAt" class="mb-2 block text-sm font-medium text-gray-700">
{$t.admin.expiresAtDateTime} <span class="text-red-500">*</span>
</label>
<input
id="expiresAt"
type="datetime-local"
name="expiresAt"
value={form?.expiresAt ?? ''}
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
<p class="mt-1 text-sm text-gray-500">{$t.admin.expiresAtDateTimeHelp}</p>
</div>
{#if form?.error}
<div class="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<div class="flex gap-4 pt-2">
<button
type="submit"
class="flex-1 rounded-lg bg-indigo-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400"
disabled={data.games.length === 0}
>
{$t.admin.createSession}
</button>
<a
href={resolve('/admin/sessions')}
class="flex-1 rounded-lg bg-gray-200 px-6 py-3 text-center font-semibold text-gray-800 transition-colors hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
{$t.admin.cancel}
</a>
</div>
</form>
</div>
{#if data.games.length === 0}
<p class="mt-4 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800">
{$t.admin.createGameBeforeSession}
<a href={resolve('/admin/games/new')} class="font-semibold underline">{$t.admin.createNewGame}</a>
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-dvh flex-col">
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>

View File

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

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData } from './$types';
let sessionCode = $state('');
let cguAccepted = $state(false);
let { form }: { form: ActionData } = $props();
</script>
<div class="flex min-h-dvh items-center justify-center bg-linear-to-br from-blue-50 to-indigo-100 p-3 sm:p-4">
<div class="w-full max-w-md rounded-xl bg-white p-5 shadow-xl sm:rounded-2xl sm:p-8">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 sm:text-3xl">{$t.home.title}</h1>
<p class="mb-6 text-center text-sm text-gray-600 sm:mb-8 sm:text-base">{$t.home.subtitle}</p>
<form method="POST" action="?/joinSession" use:enhance class="space-y-5 sm:space-y-6">
<div>
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
{$t.game.sessionCode}
</label>
<input
id="code"
type="text"
name="code"
bind:value={sessionCode}
placeholder={$t.game.enterSessionCode}
class="w-full rounded-lg border border-gray-300 px-4 py-3.5 text-base uppercase focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div class="flex items-start">
<input
id="cgu"
type="checkbox"
name="cguAccepted"
bind:checked={cguAccepted}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
required
/>
<label for="cgu" class="ml-2 block text-sm leading-6 text-gray-700">
{$t.game.acceptTerms} <a href={resolve('/terms')} class="text-indigo-600 hover:text-indigo-700 underline" data-sveltekit-preload-data="hover">{$t.game.termsAndConditions}</a>
</label>
</div>
{#if form?.error}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<button
type="submit"
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{$t.game.joinGame}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,120 @@
import { error, redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db';
import { gameSession } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const load: LayoutServerLoad = async ({ params, url }) => {
const sessionCode = params.sessionCode.toUpperCase().trim();
if (!sessionCode) {
error(400, 'Invalid session code');
}
const session = await db.query.gameSession.findFirst({
where: eq(gameSession.code, sessionCode),
with: {
escapeGame: {
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.order)]
}
}
},
progress: {
with: {
step: true
}
},
collectedItems: {
with: {
item: true
}
}
}
});
if (!session) {
error(404, 'Session not found');
}
if (session.isActive !== 1) {
error(400, 'Session is inactive');
}
if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
error(400, 'Session has expired');
}
const steps = session.escapeGame.steps;
const totalSteps = steps.length;
const completedStepIds = new Set(
session.progress.filter((entry) => entry.completedAt !== null).map((entry) => entry.stepId)
);
const currentStep = steps.find((entry) => !completedStepIds.has(entry.id)) ?? null;
const currentStepOrder = currentStep?.order ?? totalSteps;
if (!currentStep && !url.pathname.endsWith('/complete')) {
if (!session.completedAt) {
await db
.update(gameSession)
.set({ completedAt: new Date() })
.where(eq(gameSession.id, session.id));
}
redirect(303, `/game/play/${session.code}/complete`);
}
const unlockedSteps = currentStep
? steps.filter((entry) => entry.order <= currentStep.order)
: steps;
const requestedStepId = Number.parseInt(url.searchParams.get('step') ?? '', 10);
const isRequestedStepUnlocked = unlockedSteps.some((entry) => entry.id === requestedStepId);
const displayedStepRecord = isRequestedStepUnlocked
? (unlockedSteps.find((entry) => entry.id === requestedStepId) ?? null)
: (currentStep ?? unlockedSteps.at(-1) ?? null);
const displayedStepIndex = displayedStepRecord
? unlockedSteps.findIndex((entry) => entry.id === displayedStepRecord.id)
: -1;
const previousStepId = displayedStepIndex > 0 ? unlockedSteps[displayedStepIndex - 1].id : null;
const nextStepId =
displayedStepIndex >= 0 && displayedStepIndex < unlockedSteps.length - 1
? unlockedSteps[displayedStepIndex + 1].id
: null;
return {
sessionCode: session.code,
currentStepOrder,
totalSteps,
activeStepId: currentStep?.id ?? null,
displayedStep: displayedStepRecord
? {
id: displayedStepRecord.id,
title: displayedStepRecord.title,
description: displayedStepRecord.description ?? undefined,
content: displayedStepRecord.content ?? undefined,
type: displayedStepRecord.type,
hint: displayedStepRecord.hint ?? undefined
}
: null,
unlockedSteps: unlockedSteps.map((entry) => ({
id: entry.id,
order: entry.order,
title: entry.title,
isCompleted: completedStepIds.has(entry.id)
})),
previousStepId,
nextStepId,
collectedItems: session.collectedItems.map((entry) => ({
id: entry.item.id,
name: entry.item.name,
imageUrl: entry.item.imageUrl ?? undefined
}))
};
};

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import type { LayoutData } from './$types';
import { t } from '$lib/i18n';
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
let inventoryOpen = $state(false);
let dragStartY = $state(0);
let dragCurrentY = $state(0);
const isCompletePage = $derived($page.url.pathname.endsWith('/complete'));
const showMenu = $derived(!isCompletePage);
const openInventory = () => {
inventoryOpen = true;
};
const closeInventory = () => {
inventoryOpen = false;
dragStartY = 0;
dragCurrentY = 0;
};
const handleTouchStart = (e: TouchEvent) => {
dragStartY = e.touches[0].clientY;
dragCurrentY = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
dragCurrentY = e.touches[0].clientY;
};
const handleTouchEnd = () => {
const dragDistance = dragCurrentY - dragStartY;
if (dragDistance > 100) {
closeInventory();
}
dragStartY = 0;
dragCurrentY = 0;
};
$effect(() => {
if (!showMenu || !inventoryOpen) {
dragStartY = 0;
dragCurrentY = 0;
}
if (!showMenu && inventoryOpen) {
inventoryOpen = false;
}
});
</script>
<div class:pb-28={showMenu}>
{@render children()}
</div>
{#if showMenu}
<nav class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white/95 px-3 py-2 backdrop-blur sm:px-4">
<div class="mx-auto grid max-w-4xl grid-cols-4 gap-2">
<button
type="button"
onclick={openInventory}
class="rounded-lg bg-indigo-600 px-3 py-2 text-center text-indigo-700 transition-colors hover:bg-indigo-700 flex items-center justify-center"
aria-label="{$t.gameplay.inventory}"
>
<svg class="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4l1-12z" />
</svg>
</button>
<a
href={resolve('/(game)/game/play/[sessionCode]/tutorial', { sessionCode: data.sessionCode })}
class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-center transition-colors hover:bg-indigo-100 flex items-center justify-center"
aria-label="{$t.gameplay.tutorial}"
>
<svg class="h-5 w-5 text-indigo-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
{#if data.previousStepId}
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
<input type="hidden" name="step" value={data.previousStepId} />
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
>
{$t.gameplay.previous}
</button>
</form>
{:else}
<span class="rounded-lg border border-gray-200 px-3 py-2 text-center text-sm font-medium text-gray-400">
{$t.gameplay.previous}
</span>
{/if}
{#if data.nextStepId}
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
<input type="hidden" name="step" value={data.nextStepId} />
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
>
{$t.gameplay.next}
</button>
</form>
{:else}
<span class="rounded-lg border border-gray-200 px-3 py-2 text-center text-sm font-medium text-gray-400">
{$t.gameplay.next}
</span>
{/if}
</div>
</nav>
{#if inventoryOpen}
<div
class="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300"
onclick={closeInventory}
onkeydown={(e) => {
if (e.key === 'Escape') closeInventory();
}}
role="button"
tabindex={0}
></div>
<div
class="fixed bottom-0 left-0 right-0 z-50 flex max-h-[80vh] flex-col overflow-y-auto rounded-t-2xl bg-white shadow-lg transition-transform duration-300"
style="transform: translateY({Math.max(0, dragCurrentY - dragStartY)}px)"
role="dialog"
aria-label="Inventory"
aria-modal="true"
tabindex={-1}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<div
class="sticky top-0 select-none border-b border-gray-200 bg-white px-4 py-4"
role="button"
tabindex={0}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
onkeydown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
closeInventory();
}
}}
aria-label="Drag to close inventory"
>
<div class="mb-2 flex items-center justify-center">
<div class="h-1 w-12 rounded-full bg-gray-300"></div>
</div>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900">{$t.gameplay.collectedItems}</h3>
<button
type="button"
onclick={closeInventory}
class="text-gray-400 hover:text-gray-600"
aria-label="Close inventory"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-4 py-4">
{#if data.collectedItems && data.collectedItems.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
{#each data.collectedItems as item (item.id)}
<div class="rounded-lg border border-gray-200 p-3 text-center">
{#if item.imageUrl}
<img src={item.imageUrl} alt={item.name} class="mb-2 h-24 w-full rounded object-cover" />
{/if}
<p class="text-sm font-medium text-gray-900">{item.name}</p>
</div>
{/each}
</div>
{:else}
<p class="py-8 text-center text-sm text-gray-500">{$t.gameplay.emptyInventory}</p>
{/if}
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,214 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db';
import { gameSession, sessionProgress, step } from '$lib/server/db/schema';
import { and, eq } from 'drizzle-orm';
export const actions: Actions = {
continueStep: async ({ params, request }) => {
const sessionCode = params.sessionCode.toUpperCase().trim();
if (!sessionCode) {
return fail(400, { error: 'Invalid session code' });
}
const formData = await request.formData();
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
if (!Number.isInteger(stepId) || stepId <= 0) {
return fail(400, { error: 'Invalid step ID' });
}
const session = await db.query.gameSession.findFirst({
where: eq(gameSession.code, sessionCode)
});
if (!session) {
return fail(404, { error: 'Session not found' });
}
const stepRecord = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
});
if (!stepRecord) {
return fail(404, { error: 'Step not found' });
}
if (stepRecord.type !== 'text') {
return fail(400, { error: 'Only text steps can be continued without an answer' });
}
const existingProgress = await db.query.sessionProgress.findFirst({
where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
});
if (existingProgress) {
await db
.update(sessionProgress)
.set({
completedAt: existingProgress.completedAt ?? new Date()
})
.where(eq(sessionProgress.id, existingProgress.id));
} else {
await db.insert(sessionProgress).values({
gameSessionId: session.id,
stepId,
attempts: 0,
completedAt: new Date()
});
}
redirect(303, `/game/play/${session.code}`);
},
submitAnswer: async ({ params, request }) => {
const sessionCode = params.sessionCode.toUpperCase().trim();
if (!sessionCode) {
return fail(400, { error: 'Invalid session code' });
}
const formData = await request.formData();
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
const answer = formData.get('answer')?.toString().trim() ?? '';
if (!Number.isInteger(stepId) || stepId <= 0) {
return fail(400, { error: 'Invalid step ID' });
}
if (!answer) {
return fail(400, { error: 'Please enter your answer', answer });
}
const session = await db.query.gameSession.findFirst({
where: eq(gameSession.code, sessionCode)
});
if (!session) {
return fail(404, { error: 'Session not found', answer });
}
const stepRecord = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
});
if (!stepRecord) {
return fail(404, { error: 'Step not found', answer });
}
const expectedAnswer = (stepRecord.answer ?? '').trim().toLowerCase();
const submittedAnswer = answer.trim().toLowerCase();
const isCorrect = expectedAnswer.length > 0 && expectedAnswer === submittedAnswer;
const existingProgress = await db.query.sessionProgress.findFirst({
where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
});
if (existingProgress) {
await db
.update(sessionProgress)
.set({
attempts: existingProgress.attempts + 1,
lastAttemptAt: new Date(),
completedAt: isCorrect ? new Date() : existingProgress.completedAt
})
.where(eq(sessionProgress.id, existingProgress.id));
} else {
await db.insert(sessionProgress).values({
gameSessionId: session.id,
stepId,
attempts: 1,
lastAttemptAt: new Date(),
completedAt: isCorrect ? new Date() : null
});
}
if (!isCorrect) {
return fail(400, { error: 'Incorrect answer', answer });
}
redirect(303, `/game/play/${session.code}`);
},
validateLocation: async ({ params, request }) => {
const sessionCode = params.sessionCode.toUpperCase().trim();
if (!sessionCode) {
return fail(400, { error: 'Invalid session code' });
}
const formData = await request.formData();
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
const userLat = parseFloat(formData.get('userLat')?.toString() ?? '');
const userLon = parseFloat(formData.get('userLon')?.toString() ?? '');
if (!Number.isInteger(stepId) || stepId <= 0) {
return fail(400, { error: 'Invalid step ID' });
}
if (isNaN(userLat) || isNaN(userLon)) {
return fail(400, { error: 'Unable to get your location' });
}
const session = await db.query.gameSession.findFirst({
where: eq(gameSession.code, sessionCode)
});
if (!session) {
return fail(404, { error: 'Session not found' });
}
const stepRecord = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
});
if (!stepRecord) {
return fail(404, { error: 'Step not found' });
}
if (stepRecord.type !== 'location') {
return fail(400, { error: 'This step is not a location step' });
}
if (stepRecord.latitude === null || stepRecord.longitude === null) {
return fail(400, { error: 'Location coordinates not set for this step' });
}
// Calculate distance using Haversine formula
const R = 6371e3; // Earth radius in meters
const φ1 = (userLat * Math.PI) / 180;
const φ2 = (stepRecord.latitude * Math.PI) / 180;
const Δφ = ((stepRecord.latitude - userLat) * Math.PI) / 180;
const Δλ = ((stepRecord.longitude - userLon) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
const proximityRadius = stepRecord.proximityRadius ?? 50;
if (distance > proximityRadius) {
return fail(400, {
error: `You are ${Math.round(distance)}m away. Get within ${proximityRadius}m to validate.`
});
}
// User is within proximity - mark step as completed
const existingProgress = await db.query.sessionProgress.findFirst({
where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
});
if (existingProgress) {
await db
.update(sessionProgress)
.set({
completedAt: existingProgress.completedAt ?? new Date()
})
.where(eq(sessionProgress.id, existingProgress.id));
} else {
await db.insert(sessionProgress).values({
gameSessionId: session.id,
stepId,
attempts: 0,
completedAt: new Date()
});
}
redirect(303, `/game/play/${session.code}`);
}
};

View File

@@ -0,0 +1,513 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
let { data, form }: { data: PageData; form: ActionData } = $props();
type Step = {
id: number;
title: string;
description?: string;
content?: string;
type: string;
hint?: string;
latitude?: number | null;
longitude?: number | null;
proximityRadius?: number | null;
};
let currentStep = $derived((data.displayedStep as Step | null) ?? null);
let isCurrentActiveStep = $derived(
currentStep !== null && data.activeStepId !== null && currentStep.id === data.activeStepId
);
let answer = $derived(
typeof (form as { answer?: string } | null)?.answer === 'string'
? ((form as { answer?: string }).answer ?? '')
: ''
);
let isLoading = $state(false);
// Location tracking state
let userLat = $state<number | null>(null);
let userLon = $state<number | null>(null);
let heading = $state<number | null>(null);
let watchId: number | null = null;
let locationError = $state<string | null>(null);
let locationPermission = $state<'prompt' | 'granted' | 'denied' | 'checking'>('prompt');
let distance = $state<number | null>(null);
let arrowRotation = $state<number>(0);
// Calculate distance between two coordinates in meters (Haversine formula)
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3; // Earth radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Calculate bearing (direction) from user to target in degrees
function calculateBearing(lat1: number, lon1: number, lat2: number, lon2: number): number {
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return ((θ * 180) / Math.PI + 360) % 360;
}
// Update arrow rotation based on user heading and target bearing
$effect(() => {
if (
currentStep?.type === 'location' &&
isCurrentActiveStep &&
userLat !== null &&
userLon !== null &&
currentStep.latitude != null &&
currentStep.longitude != null
) {
const dist = calculateDistance(
userLat,
userLon,
currentStep.latitude,
currentStep.longitude
);
distance = Math.round(dist);
const bearing = calculateBearing(
userLat,
userLon,
currentStep.latitude,
currentStep.longitude
);
// If we have device heading, rotate relative to it; otherwise just use absolute bearing
arrowRotation = heading !== null ? bearing - heading : bearing;
}
});
// Check and request location permission
async function checkLocationPermission() {
if (!('geolocation' in navigator)) {
locationError = 'Geolocation is not supported by your device';
locationPermission = 'denied';
return false;
}
// Check permission API if available
if ('permissions' in navigator) {
try {
const result = await navigator.permissions.query({ name: 'geolocation' });
locationPermission = result.state as 'granted' | 'denied' | 'prompt';
// Listen for permission changes
result.addEventListener('change', () => {
locationPermission = result.state as 'granted' | 'denied' | 'prompt';
});
return result.state === 'granted';
} catch (e) {
// Some browsers don't support permissions API for geolocation
console.log('Permissions API not fully supported', e);
}
}
return false;
}
function startLocationTracking() {
if (!('geolocation' in navigator)) {
return;
}
locationPermission = 'checking';
locationError = null;
watchId = navigator.geolocation.watchPosition(
(position) => {
userLat = position.coords.latitude;
userLon = position.coords.longitude;
locationError = null;
locationPermission = 'granted';
// Try to get device heading if available
if (position.coords.heading !== null) {
heading = position.coords.heading;
}
},
(error) => {
console.error('Geolocation error:', error);
if (error.code === error.PERMISSION_DENIED) {
locationError = 'Location access was denied. Please enable location in your browser settings.';
locationPermission = 'denied';
} else if (error.code === error.POSITION_UNAVAILABLE) {
locationError = 'Location information is unavailable.';
locationPermission = 'prompt';
} else if (error.code === error.TIMEOUT) {
locationError = 'Location request timed out. Please try again.';
locationPermission = 'prompt';
} else {
locationError = error.message;
locationPermission = 'prompt';
}
},
{
enableHighAccuracy: true,
maximumAge: 5000,
timeout: 10000
}
);
// Try to use device orientation for compass heading
if (typeof DeviceOrientationEvent !== 'undefined') {
const hasAbsoluteOrientation = 'ondeviceorientationabsolute' in window;
const hasOrientation = 'ondeviceorientation' in window;
if (hasAbsoluteOrientation) {
window.addEventListener('deviceorientationabsolute', handleOrientation as EventListener);
} else if (hasOrientation) {
window.addEventListener('deviceorientation', handleOrientation as EventListener);
}
}
}
function stopLocationTracking() {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
}
window.removeEventListener('deviceorientationabsolute', handleOrientation as EventListener);
window.removeEventListener('deviceorientation', handleOrientation as EventListener);
}
// Initialize location tracking
onMount(() => {
if (currentStep?.type === 'location' && isCurrentActiveStep) {
checkLocationPermission().then(hasPermission => {
if (hasPermission) {
startLocationTracking();
}
});
}
return () => {
stopLocationTracking();
};
});
function handleOrientation(event: Event) {
const e = event as DeviceOrientationEvent;
if (e.absolute && e.alpha !== null) {
heading = 360 - e.alpha; // Convert to compass heading
} else if (e.alpha !== null) {
// Fallback for iOS with webkitCompassHeading
const webkit = e as DeviceOrientationEvent & { webkitCompassHeading?: number };
if (webkit.webkitCompassHeading !== undefined) {
heading = webkit.webkitCompassHeading;
}
}
}
// Auto-submit when within proximity
let proximityForm = $state<HTMLFormElement | null>(null);
$effect(() => {
if (
currentStep?.type === 'location' &&
isCurrentActiveStep &&
distance !== null &&
currentStep.proximityRadius != null &&
distance <= currentStep.proximityRadius &&
!isLoading &&
proximityForm
) {
// Auto-submit the form
isLoading = true;
proximityForm.requestSubmit();
}
});
</script>
<div class="min-h-dvh bg-gray-50">
<header class="sticky top-0 z-10 bg-white/95 shadow-sm backdrop-blur">
<div class="mx-auto max-w-4xl px-3 py-3 sm:px-4 sm:py-4">
<h1 class="text-xl font-bold text-gray-900 sm:text-2xl">{$t.home.title}</h1>
<p class="text-sm text-gray-600">{$t.gameplay.progress}: {data.sessionCode || 'Loading...'}</p>
</div>
</header>
<main class="mx-auto max-w-4xl px-3 py-5 sm:px-4 sm:py-8">
<div class="mb-5 sm:mb-8">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">{$t.gameplay.progress}</span>
<span class="text-xs text-gray-500 sm:text-sm">{$t.gameplay.step} {data.currentStepOrder || 0} {$t.gameplay.of} {data.totalSteps || 0}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="bg-indigo-600 h-2.5 rounded-full transition-all duration-300"
style="width: {((data.currentStepOrder || 0) / (data.totalSteps || 1)) * 100}%"
></div>
</div>
</div>
<div class="mb-5 rounded-xl bg-white p-4 shadow-md sm:mb-6 sm:p-6">
{#if currentStep}
<h2 class="mb-3 text-xl font-bold text-gray-900 sm:mb-4 sm:text-2xl">{currentStep.title}</h2>
{#if currentStep.description}
<p class="text-gray-700 mb-4">{currentStep.description}</p>
{/if}
{#if currentStep.content}
<div class="bg-gray-50 rounded-lg p-4 mb-6">
<p class="text-gray-800">{currentStep.content}</p>
</div>
{/if}
{#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep}
<form
method="POST"
action="?/submitAnswer"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
await update();
isLoading = false;
};
}}
class="space-y-4"
>
<input type="hidden" name="stepId" value={currentStep.id} />
<div>
<label for="answer" class="block text-sm font-medium text-gray-700 mb-2">
{$t.gameplay.yourAnswer}
</label>
<input
id="answer"
type="text"
name="answer"
bind:value={answer}
placeholder={$t.gameplay.enterYourAnswer}
class="w-full rounded-lg border border-gray-300 px-4 py-3.5 text-base focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{#if form?.error}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{isLoading ? $t.gameplay.checking : $t.gameplay.submitAnswer}
</button>
</form>
{#if currentStep.hint}
<details class="mt-4">
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-700 font-medium">
{$t.gameplay.needAHint}
</summary>
<p class="mt-2 text-gray-700 bg-yellow-50 p-4 rounded-lg">{currentStep.hint}</p>
</details>
{/if}
{:else if currentStep.type === 'text' && isCurrentActiveStep}
<form
method="POST"
action="?/continueStep"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
await update();
isLoading = false;
};
}}
>
<input type="hidden" name="stepId" value={currentStep.id} />
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? $t.gameplay.loadingStep : $t.gameplay.continue}
</button>
</form>
{:else if currentStep.type === 'location' && isCurrentActiveStep}
<form
bind:this={proximityForm}
method="POST"
action="?/validateLocation"
use:enhance={() => {
return async ({ update }) => {
await update();
isLoading = false;
};
}}
>
<input type="hidden" name="stepId" value={currentStep.id} />
<input type="hidden" name="userLat" value={userLat ?? ''} />
<input type="hidden" name="userLon" value={userLon ?? ''} />
<div class="space-y-4">
{#if locationPermission === 'denied'}
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-4 rounded-lg text-sm space-y-3">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-semibold mb-1">{$t.gameplay.locationDenied}</p>
<p>{locationError || $t.gameplay.locationDeniedMessage}</p>
</div>
</div>
<button
type="button"
onclick={() => startLocationTracking()}
class="w-full rounded-lg bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-700"
>
{$t.gameplay.tryAgain}
</button>
</div>
{:else if locationPermission === 'prompt'}
<div class="bg-blue-50 border border-blue-200 text-blue-900 px-4 py-4 rounded-lg text-sm space-y-3">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<p class="font-semibold mb-1">{$t.gameplay.locationRequired}</p>
<p>{$t.gameplay.locationRequiredMessage}</p>
</div>
</div>
<button
type="button"
onclick={() => startLocationTracking()}
class="w-full rounded-lg bg-indigo-600 px-4 py-3 text-base font-semibold text-white transition-colors hover:bg-indigo-700"
>
{$t.gameplay.enableLocation}
</button>
</div>
{:else if locationError && locationPermission === 'checking'}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{$t.gameplay.locationError}: {locationError}
</div>
{:else if userLat === null || userLon === null}
<div class="bg-blue-50 text-blue-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{$t.gameplay.locatingYou}</span>
</div>
{:else}
<!-- Arrow indicator -->
<div class="relative bg-gradient-to-br from-indigo-50 to-purple-50 rounded-2xl p-8 flex flex-col items-center justify-center" style="min-height: 300px;">
<div class="absolute top-4 left-4 right-4 flex justify-between items-start">
<div class="bg-white/90 backdrop-blur rounded-lg px-3 py-2 shadow-md">
<p class="text-xs text-gray-600 font-medium uppercase tracking-wide">{$t.gameplay.distance}</p>
<p class="text-2xl font-bold text-indigo-600">
{#if distance !== null}
{distance < 1000 ? `${distance}m` : `${(distance / 1000).toFixed(1)}km`}
{:else}
--
{/if}
</p>
</div>
</div>
<!-- Compass arrow -->
<div class="relative">
<div
class="w-32 h-32 flex items-center justify-center transition-transform duration-500 ease-out"
style="transform: rotate({arrowRotation}deg);"
>
<svg class="w-full h-full drop-shadow-lg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="arrowGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4F46E5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7C3AED;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Arrow shape -->
<path d="M 50 10 L 70 80 L 50 70 L 30 80 Z" fill="url(#arrowGradient)" stroke="white" stroke-width="2"/>
<!-- Center dot -->
<circle cx="50" cy="50" r="6" fill="white" stroke="#4F46E5" stroke-width="2"/>
</svg>
</div>
<!-- Outer ring -->
<div class="absolute inset-0 rounded-full border-4 border-indigo-200 opacity-30" style="width: 180px; height: 180px; left: -24px; top: -24px;"></div>
</div>
{#if distance !== null && currentStep.proximityRadius != null}
{#if distance <= currentStep.proximityRadius}
<p class="mt-6 text-lg font-semibold text-green-600 animate-pulse">
🎯 {$t.gameplay.arrived}
</p>
{:else}
<p class="mt-6 text-sm text-gray-600">
{$t.gameplay.getWithin} {currentStep.proximityRadius}m {$t.gameplay.toValidate}
</p>
{/if}
{/if}
</div>
<!-- Manual validation button (as fallback) -->
<button
type="submit"
disabled={isLoading || distance === null}
class="w-full rounded-lg bg-indigo-600 px-4 py-3.5 text-base font-semibold text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{isLoading ? $t.gameplay.checking : $t.gameplay.validateLocation}
</button>
{#if form?.error}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
{/if}
</div>
</form>
{#if currentStep.hint}
<details class="mt-4">
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-700 font-medium">
{$t.gameplay.needAHint}
</summary>
<p class="mt-2 text-gray-700 bg-yellow-50 p-4 rounded-lg">{currentStep.hint}</p>
</details>
{/if}
{:else if !isCurrentActiveStep}
<p class="rounded-lg bg-indigo-50 p-3 text-sm text-indigo-800">
{$t.gameplay.viewingUnlockedStep}
</p>
{:else}
<p class="text-gray-600">{$t.gameplay.loadingStep}</p>
{/if}
{:else}
<div class="py-10 text-center sm:py-12">
<p class="text-gray-500">{$t.gameplay.loadingStep}</p>
</div>
{/if}
</div>
</main>
</div>

View File

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

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { t } from '$lib/i18n';
let { data }: { data: PageData } = $props();
</script>
<div class="flex min-h-dvh items-center justify-center bg-linear-to-br from-emerald-50 to-cyan-100 p-4">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl sm:p-8">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-emerald-700">{$t.gameplay.completedLabel}</p>
<h1 class="mb-2 text-2xl font-bold text-gray-900 sm:text-3xl">{$t.gameplay.completedTitle}</h1>
<p class="mb-6 text-sm text-gray-600 sm:text-base">{data.gameTitle} - {$t.gameplay.sessionCode}: {data.sessionCode}</p>
<div class="mb-6 rounded-xl bg-emerald-50 p-4 text-center">
<p class="mb-1 text-sm font-medium text-emerald-800">{$t.gameplay.completedIn}</p>
<p class="text-3xl font-bold text-emerald-900 sm:text-4xl">{data.formattedDuration}</p>
</div>
<a
href={resolve('/(game)/game')}
class="block w-full rounded-lg bg-emerald-600 px-4 py-3 text-center text-base font-semibold text-white transition-colors hover:bg-emerald-700"
>
{$t.gameplay.playAgain}
</a>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { t } from '$lib/i18n';
</script>
<div class="bg-gray-50 px-3 py-5 sm:px-4 sm:py-8">
<div class="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-7">
<h1 class="mb-2 text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
<p class="mb-5 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
<div class="space-y-3">
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
</div>
</div>
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
</p>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { t } from '$lib/i18n';
</script>
<div class="min-h-dvh bg-gray-50 px-3 py-6 sm:px-4 sm:py-10">
<div class="mx-auto max-w-3xl rounded-xl bg-white p-5 shadow-md sm:p-8">
<a href={resolve('/(game)/game')} class="mb-5 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-700 sm:mb-6">
← Back
</a>
<h1 class="mb-4 text-2xl font-bold text-gray-900 sm:text-3xl">{$t.game.termsAndConditions}</h1>
<p class="mb-4 text-sm leading-6 text-gray-700 sm:text-base">
By joining a session, you agree to follow the game instructions, respect other players,
and use the platform appropriately.
</p>
<p class="mb-4 text-sm leading-6 text-gray-700 sm:text-base">
The organizer may collect minimal session data needed to operate the game
(code, progress, and participation timestamps).
</p>
<p class="text-sm leading-6 text-gray-700 sm:text-base">
If you do not agree with these terms, please do not join the game session.
</p>
</div>
</div>

View File

@@ -6,4 +6,5 @@
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
{@render children()}

View File

@@ -1,2 +1,34 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import LanguagePicker from '$lib/components/LanguagePicker.svelte';
</script>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-indigo-100 via-purple-50 to-pink-100">
<div class="flex flex-col items-center justify-center px-4">
<h1 class="text-4xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6 text-center">
{$t.home.title}
</h1>
<p class="text-xl text-gray-700 mb-12 max-w-2xl text-center">
{$t.home.subtitle}
</p>
<button
onclick={() => goto('/game')}
class="bg-indigo-600 text-white px-12 py-8 rounded-2xl font-semibold text-xl hover:bg-indigo-700 shadow-lg hover:shadow-xl transition-all transform hover:-translate-y-1"
>
<div class="flex flex-col items-center">
<svg class="w-16 h-16 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{$t.home.playGame}</span>
<span class="text-sm text-indigo-200 mt-2">{$t.home.joinWithCode}</span>
</div>
</button>
</div>
<div class="mt-16">
<p class="text-gray-600 text-sm mb-6 font-medium">{$t.common?.selectLanguage || 'Select Language'}</p>
<LanguagePicker />
</div>
</div>

View File

@@ -1 +1,5 @@
@import 'tailwindcss';
input:where([type='datetime-local']) {
min-height: 2.75rem;
}

View File

@@ -0,0 +1,106 @@
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { auth } from '$lib/server/auth';
import { user } from '$lib/server/db/schema';
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import { APIError } from 'better-auth/api';
export const load: PageServerLoad = async (event) => {
const session = await auth.api.getSession({ headers: event.request.headers });
if (session?.user) {
redirect(303, '/admin');
}
// Check if any users exist
const users = await db.select().from(user).limit(1);
const noUsersExist = users.length === 0;
return {
noUsersExist
};
};
export const actions: Actions = {
signIn: async (event) => {
const formData = await event.request.formData();
const identifier = formData.get('identifier')?.toString().trim() ?? formData.get('email')?.toString().trim() ?? '';
const password = formData.get('password')?.toString() ?? '';
if (!identifier) {
return fail(400, { message: 'Email ou nom d\'utilisateur requis' });
}
let email = identifier;
if (!identifier.includes('@')) {
const [foundUser] = await db
.select({ email: user.email })
.from(user)
.where(sql`lower(${user.email}) = ${identifier.toLowerCase()}`)
.limit(1);
if (!foundUser) {
return fail(400, { message: 'Identifiants invalides' });
}
email = foundUser.email;
}
try {
await auth.api.signInEmail({
body: {
email,
password,
callbackURL: '/auth/verification-success'
}
});
} catch (error) {
if (error instanceof APIError) {
return fail(400, { message: error.message || 'Signin failed' });
}
return fail(500, { message: 'Unexpected error' });
}
return redirect(302, '/');
},
createFirstUser: async (event) => {
// Check if any users already exist
const users = await db.select().from(user).limit(1);
if (users.length > 0) {
return fail(400, { message: 'Un utilisateur existe déjà' });
}
const formData = await event.request.formData();
const email = formData.get('email')?.toString().trim() ?? '';
const password = formData.get('password')?.toString() ?? '';
const name = formData.get('name')?.toString().trim() ?? email;
if (!email || !password) {
return fail(400, { message: 'Email et mot de passe requis' });
}
if (password.length < 6) {
return fail(400, { message: 'Le mot de passe doit contenir au moins 6 caractères' });
}
try {
await auth.api.signUpEmail({
body: {
email,
password,
name,
callbackURL: '/admin'
}
});
} catch (error) {
if (error instanceof APIError) {
return fail(400, { message: error.message || 'Creation failed' });
}
return fail(500, { message: 'Unexpected error' });
}
return redirect(302, '/admin');
}
};

View File

@@ -0,0 +1,143 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { t } from '$lib/i18n';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
let email = $state('');
let password = $state('');
let name = $state('');
let { form, data }: { form: ActionData; data: PageData } = $props();
const noUsersExist = $derived(data?.noUsersExist ?? false);
</script>
<div class="min-h-screen flex items-center justify-center bg-linear-to-br from-indigo-100 via-purple-50 to-pink-100 p-4">
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
{#if noUsersExist}
<h1 class="text-3xl font-bold text-gray-900 mb-2 text-center">
Créer le premier utilisateur
</h1>
<p class="text-gray-600 mb-8 text-center">
Aucun utilisateur n'existe. Créez le premier compte administrateur.
</p>
<form method="POST" action="?/createFirstUser" use:enhance class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Nom
</label>
<input
id="name"
type="text"
name="name"
bind:value={name}
placeholder="John Doe"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
id="email"
type="email"
name="email"
bind:value={email}
placeholder="admin@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Mot de passe
</label>
<input
id="password"
type="password"
name="password"
bind:value={password}
placeholder="••••••••"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{#if form?.message}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.message}
</div>
{/if}
<button
type="submit"
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Créer le compte
</button>
</form>
{:else}
<h1 class="text-3xl font-bold text-gray-900 mb-2 text-center">
{$t.login?.login || 'Login'}
</h1>
<p class="text-gray-600 mb-8 text-center">
{$t.login?.accessAdmin || 'Access the admin dashboard'}
</p>
<form method="POST" action="?/signIn" use:enhance class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
id="email"
type="email"
name="email"
bind:value={email}
placeholder="admin@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
id="password"
type="password"
name="password"
bind:value={password}
placeholder="••••••••"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{#if form?.message}
<div class="bg-red-50 text-red-700 px-4 py-3 rounded-lg text-sm">
{form.message}
</div>
{/if}
<button
type="submit"
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{$t.login?.login || 'Login'}
</button>
</form>
{/if}
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
<a href={resolve('/')} class="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
{$t.home?.title || 'Back Home'}
</a>
</div>
</div>
</div>