feat: implement active session checks to disable step modifications and add warnings
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
This commit is contained in:
@@ -25,7 +25,8 @@
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
initialValues,
|
initialValues,
|
||||||
maxOrder
|
maxOrder,
|
||||||
|
disabled = false
|
||||||
}: {
|
}: {
|
||||||
gameId: number;
|
gameId: number;
|
||||||
gameTitle: string;
|
gameTitle: string;
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
initialValues: StepFormValues;
|
initialValues: StepFormValues;
|
||||||
maxOrder: number;
|
maxOrder: number;
|
||||||
|
disabled?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
||||||
@@ -439,7 +441,15 @@
|
|||||||
<div class="flex gap-4 pt-4">
|
<div class="flex gap-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
{disabled}
|
||||||
|
class="flex-1 py-3 px-6 rounded-lg font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors"
|
||||||
|
class:bg-indigo-600={!disabled}
|
||||||
|
class:text-white={!disabled}
|
||||||
|
class:hover:bg-indigo-700={!disabled}
|
||||||
|
class:focus:ring-indigo-500={!disabled}
|
||||||
|
class:bg-gray-300={disabled}
|
||||||
|
class:text-gray-500={disabled}
|
||||||
|
class:cursor-not-allowed={disabled}
|
||||||
>
|
>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
40
src/lib/server/gameValidation.ts
Normal file
40
src/lib/server/gameValidation.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { gameSession } from '$lib/server/db/schema';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a game has any active sessions that are not yet completed
|
||||||
|
* @param escapeGameId The ID of the escape game to check
|
||||||
|
* @returns true if there are active incomplete sessions, false otherwise
|
||||||
|
*/
|
||||||
|
export async function hasActiveIncompleteSessions(escapeGameId: number): Promise<boolean> {
|
||||||
|
const activeSessions = await db.query.gameSession.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(gameSession.escapeGameId, escapeGameId),
|
||||||
|
eq(gameSession.isActive, 1),
|
||||||
|
isNull(gameSession.completedAt)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
return activeSessions !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active incomplete sessions for a game
|
||||||
|
* @param escapeGameId The ID of the escape game to check
|
||||||
|
* @returns Number of active incomplete sessions
|
||||||
|
*/
|
||||||
|
export async function countActiveIncompleteSessions(escapeGameId: number): Promise<number> {
|
||||||
|
const sessions = await db
|
||||||
|
.select()
|
||||||
|
.from(gameSession)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gameSession.escapeGameId, escapeGameId),
|
||||||
|
eq(gameSession.isActive, 1),
|
||||||
|
isNull(gameSession.completedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessions.length;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { escapeGame, step } from '$lib/server/db/schema';
|
import { escapeGame, step } from '$lib/server/db/schema';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
const gameId = parseInt(params.id, 10);
|
const gameId = parseInt(params.id, 10);
|
||||||
@@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
where: eq(escapeGame.id, gameId),
|
where: eq(escapeGame.id, gameId),
|
||||||
with: {
|
with: {
|
||||||
steps: {
|
steps: {
|
||||||
orderBy: (steps) => steps.order
|
orderBy: (steps, { asc }) => [asc(steps.order), asc(steps.id)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -24,8 +25,11 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
error(404, 'Game not found');
|
error(404, 'Game not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
game
|
game,
|
||||||
|
activeSessionCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +40,13 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Invalid game ID' });
|
return fail(400, { error: 'Invalid game ID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
|
||||||
|
if (hasActiveSessions > 0) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Cannot reorder steps: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const orderRaw = formData.get('order')?.toString() ?? '';
|
const orderRaw = formData.get('order')?.toString() ?? '';
|
||||||
|
|
||||||
@@ -100,6 +111,13 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Invalid game ID' });
|
return fail(400, { error: 'Invalid game ID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
|
||||||
|
if (hasActiveSessions > 0) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Cannot delete step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
|
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,17 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let game = $derived.by(() => data.game);
|
let game = $derived.by(() => data.game);
|
||||||
let steps = $derived([...(game.steps ?? [])]);
|
let steps = $state<typeof data.game.steps>([]);
|
||||||
let draggedStepId = $state<number | null>(null);
|
let draggedStepId = $state<number | null>(null);
|
||||||
let reorderPayload = $state('');
|
let reorderPayload = $state('');
|
||||||
let reorderForm = $state<HTMLFormElement | undefined>(undefined);
|
let reorderForm = $state<HTMLFormElement | undefined>(undefined);
|
||||||
let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null);
|
let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null);
|
||||||
|
|
||||||
|
// Update steps when game data changes
|
||||||
|
$effect(() => {
|
||||||
|
steps = [...(game.steps ?? [])];
|
||||||
|
});
|
||||||
|
|
||||||
function openDeleteModal(step: { id: number; title: string; order: number }) {
|
function openDeleteModal(step: { id: number; title: string; order: number }) {
|
||||||
stepToDelete = step;
|
stepToDelete = step;
|
||||||
}
|
}
|
||||||
@@ -81,37 +86,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Sessions Warning -->
|
||||||
|
{#if data.activeSessionCount > 0}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="bg-amber-50 border-l-4 border-amber-500 rounded-lg p-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="h-6 w-6 text-amber-600 mt-0.5 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 class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-amber-900 mb-1">⚠️ Editing disabled</h3>
|
||||||
|
<p class="text-sm text-amber-800 mb-2">
|
||||||
|
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
|
||||||
|
You cannot add, edit, reorder, or delete steps while sessions are active.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-700">
|
||||||
|
Modifying steps would break the progression for active players. Wait for all sessions to complete or mark them as inactive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Steps Section -->
|
<!-- Steps Section -->
|
||||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||||
<div class="px-8 py-6 border-b border-gray-200">
|
<div class="px-8 py-6 border-b border-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
|
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
|
||||||
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
|
{#if data.activeSessionCount === 0}
|
||||||
<a
|
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
|
||||||
href={resolve(`/admin/games/${game.id}/steps/new`)}
|
{/if}
|
||||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
|
{#if data.activeSessionCount > 0}
|
||||||
>
|
<button
|
||||||
+ Add Step
|
type="button"
|
||||||
</a>
|
disabled
|
||||||
|
class="bg-gray-300 text-gray-500 px-4 py-2 rounded-lg font-semibold cursor-not-allowed"
|
||||||
|
title="Cannot add steps while sessions are active"
|
||||||
|
>
|
||||||
|
+ Add Step
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="?/reorderSteps" use:enhance bind:this={reorderForm}>
|
<form
|
||||||
<input type="hidden" name="order" bind:value={reorderPayload} />
|
method="POST"
|
||||||
</form>
|
action="?/reorderSteps"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
bind:this={reorderForm}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="order" bind:value={reorderPayload} />
|
||||||
|
|
||||||
{#if steps.length > 0}
|
{#if steps.length > 0}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200">
|
||||||
{#each steps as step (step.id)}
|
{#each steps as step (step.id)}
|
||||||
<div
|
<div
|
||||||
class="px-8 py-6 hover:bg-gray-50 transition-colors cursor-move"
|
class="px-8 py-6 transition-colors {data.activeSessionCount === 0 ? 'hover:bg-gray-50 cursor-move' : 'cursor-default'}"
|
||||||
draggable="true"
|
draggable={data.activeSessionCount === 0}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label={`Deplacer l'etape ${step.order}`}
|
aria-label={`Deplacer l'etape ${step.order}`}
|
||||||
ondragstart={() => onDragStart(step.id)}
|
ondragstart={() => data.activeSessionCount === 0 && onDragStart(step.id)}
|
||||||
ondragover={onDragOver}
|
ondragover={onDragOver}
|
||||||
ondrop={() => onDrop(step.id)}
|
ondrop={() => data.activeSessionCount === 0 && onDrop(step.id)}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -128,6 +177,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-2">
|
<div class="inline-flex items-center gap-2">
|
||||||
|
{#if data.activeSessionCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-gray-400 cursor-not-allowed"
|
||||||
|
title="Cannot edit while sessions are active"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-gray-400 cursor-not-allowed"
|
||||||
|
title="Cannot delete while sessions are active"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/admin/games/${game.id}/steps/${step.id}`)}
|
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"
|
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
|
||||||
@@ -147,7 +220,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -171,10 +245,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</form> </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if stepToDelete}
|
{#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="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">
|
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { escapeGame, step } from '$lib/server/db/schema';
|
import { escapeGame, step } from '$lib/server/db/schema';
|
||||||
import { and, eq, max } from 'drizzle-orm';
|
import { and, eq, max, not } from 'drizzle-orm';
|
||||||
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
||||||
import { writeFile } from 'fs/promises';
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||||
type StepType = (typeof stepTypes)[number];
|
type StepType = (typeof stepTypes)[number];
|
||||||
|
|
||||||
@@ -42,10 +43,13 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
.where(eq(step.escapeGameId, gameId))
|
.where(eq(step.escapeGameId, gameId))
|
||||||
.then((result) => result[0]?.order ?? 0);
|
.then((result) => result[0]?.order ?? 0);
|
||||||
|
|
||||||
|
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
game,
|
game,
|
||||||
step: gameStep,
|
step: gameStep,
|
||||||
totalSteps: lastStep
|
totalSteps: lastStep,
|
||||||
|
activeSessionCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +62,13 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Invalid ID' });
|
return fail(400, { error: 'Invalid ID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
|
||||||
|
if (hasActiveSessions > 0) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Cannot modify step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const title = formData.get('title')?.toString().trim() ?? '';
|
const title = formData.get('title')?.toString().trim() ?? '';
|
||||||
const description = formData.get('description')?.toString().trim() ?? '';
|
const description = formData.get('description')?.toString().trim() ?? '';
|
||||||
@@ -237,6 +248,48 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the current step to check if order changed
|
||||||
|
const currentStep = await db.query.step.findFirst({
|
||||||
|
where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
return fail(404, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
order,
|
||||||
|
error: 'Etape introuvable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if order changed and if there's a conflict
|
||||||
|
if (currentStep.order !== order) {
|
||||||
|
const conflictingStep = await db.query.step.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(step.escapeGameId, gameId),
|
||||||
|
eq(step.order, order),
|
||||||
|
not(eq(step.id, stepId))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflictingStep) {
|
||||||
|
return fail(400, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
answer,
|
||||||
|
hint,
|
||||||
|
order,
|
||||||
|
error: `Une autre etape utilise deja l'ordre ${order}. Veuillez reordonner les etapes via drag-and-drop.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await db
|
const updated = await db
|
||||||
.update(step)
|
.update(step)
|
||||||
|
|||||||
@@ -35,6 +35,28 @@
|
|||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if data.activeSessionCount > 0}
|
||||||
|
<div class="max-w-4xl mx-auto px-4 pt-8">
|
||||||
|
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-6 mb-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="h-6 w-6 text-red-600 mt-0.5 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 class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-red-900 mb-1">🚫 Cannot edit step</h3>
|
||||||
|
<p class="text-sm text-red-800 mb-2">
|
||||||
|
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
|
||||||
|
Editing steps would break the progression for active players.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-700">
|
||||||
|
Wait for all sessions to complete or mark them as inactive before modifying this step.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<StepForm
|
<StepForm
|
||||||
gameId={data.game.id}
|
gameId={data.game.id}
|
||||||
gameTitle={data.game.title}
|
gameTitle={data.game.title}
|
||||||
@@ -44,4 +66,5 @@
|
|||||||
errorMessage={form?.error}
|
errorMessage={form?.error}
|
||||||
{initialValues}
|
{initialValues}
|
||||||
maxOrder={data.totalSteps}
|
maxOrder={data.totalSteps}
|
||||||
|
disabled={data.activeSessionCount > 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { escapeGame, step } from '$lib/server/db/schema';
|
import { escapeGame, step } from '$lib/server/db/schema';
|
||||||
import { eq, max } from 'drizzle-orm';
|
import { and, eq, gte, max } from 'drizzle-orm';
|
||||||
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
|
||||||
import { writeFile } from 'fs/promises';
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||||
|
|
||||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||||
type StepType = (typeof stepTypes)[number];
|
type StepType = (typeof stepTypes)[number];
|
||||||
@@ -36,10 +37,13 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
|
|
||||||
const nextStepOrder = lastStep + 1;
|
const nextStepOrder = lastStep + 1;
|
||||||
|
|
||||||
|
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
game,
|
game,
|
||||||
nextStepOrder,
|
nextStepOrder,
|
||||||
totalSteps: lastStep
|
totalSteps: lastStep,
|
||||||
|
activeSessionCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +55,13 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Invalid game ID' });
|
return fail(400, { error: 'Invalid game ID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
|
||||||
|
if (hasActiveSessions > 0) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Cannot add step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const title = formData.get('title')?.toString().trim() ?? '';
|
const title = formData.get('title')?.toString().trim() ?? '';
|
||||||
const description = formData.get('description')?.toString().trim() ?? '';
|
const description = formData.get('description')?.toString().trim() ?? '';
|
||||||
@@ -224,6 +235,22 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// If inserting in the middle, shift existing steps
|
||||||
|
if (order <= lastStep) {
|
||||||
|
const stepsToShift = await db
|
||||||
|
.select({ id: step.id, order: step.order })
|
||||||
|
.from(step)
|
||||||
|
.where(and(eq(step.escapeGameId, gameId), gte(step.order, order)));
|
||||||
|
|
||||||
|
// Shift all steps >= order by 1
|
||||||
|
for (const s of stepsToShift) {
|
||||||
|
await db
|
||||||
|
.update(step)
|
||||||
|
.set({ order: s.order + 1 })
|
||||||
|
.where(eq(step.id, s.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(step).values({
|
await db.insert(step).values({
|
||||||
escapeGameId: gameId,
|
escapeGameId: gameId,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -32,6 +32,28 @@
|
|||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if data.activeSessionCount > 0}
|
||||||
|
<div class="max-w-4xl mx-auto px-4 pt-8">
|
||||||
|
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-6 mb-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="h-6 w-6 text-red-600 mt-0.5 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 class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-red-900 mb-1">🚫 Cannot add step</h3>
|
||||||
|
<p class="text-sm text-red-800 mb-2">
|
||||||
|
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
|
||||||
|
Adding steps would break the progression for active players.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-700">
|
||||||
|
Wait for all sessions to complete or mark them as inactive before adding new steps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<StepForm
|
<StepForm
|
||||||
gameId={data.game.id}
|
gameId={data.game.id}
|
||||||
gameTitle={data.game.title}
|
gameTitle={data.game.title}
|
||||||
@@ -41,4 +63,5 @@
|
|||||||
errorMessage={form?.error}
|
errorMessage={form?.error}
|
||||||
{initialValues}
|
{initialValues}
|
||||||
maxOrder={data.nextStepOrder}
|
maxOrder={data.nextStepOrder}
|
||||||
|
disabled={data.activeSessionCount > 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user