parent
a3fa056c1f
commit
d4a25746b2
47 changed files with 4712 additions and 1656 deletions
@ -0,0 +1,28 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { toastStore } from "$lib/stores/toast.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#each toastStore.notifications as notification (notification.id)} |
||||||
|
<div |
||||||
|
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] px-6 py-4 rounded-lg shadow-lg |
||||||
|
font-[family-name:var(--kv-font-button)] text-lg uppercase |
||||||
|
{notification.type === 'error' |
||||||
|
? 'bg-red-600 text-kv-white' |
||||||
|
: notification.type === 'success' |
||||||
|
? 'bg-green-600 text-kv-white' |
||||||
|
: 'bg-blue-600 text-kv-white'}" |
||||||
|
role="alert" |
||||||
|
style="bottom: calc(2rem + {toastStore.notifications.indexOf(notification) * 4}rem)" |
||||||
|
> |
||||||
|
<div class="flex items-center gap-3"> |
||||||
|
<span>{notification.message}</span> |
||||||
|
<button |
||||||
|
onclick={() => toastStore.dismiss(notification.id)} |
||||||
|
class="ml-2 opacity-70 hover:opacity-100" |
||||||
|
aria-label="Dismiss" |
||||||
|
> |
||||||
|
✕ |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
@ -0,0 +1,153 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { editorStore } from "$lib/stores/editor.svelte"; |
||||||
|
import { KvButton } from "$lib/components/kuldvillak/ui"; |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
gameName: string; |
||||||
|
onStart: () => void; |
||||||
|
onReset: () => void; |
||||||
|
onSettings?: () => void; |
||||||
|
onImportSuccess?: () => void; |
||||||
|
onImportError?: (error: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
gameName = $bindable(), |
||||||
|
onStart, |
||||||
|
onReset, |
||||||
|
onSettings, |
||||||
|
onImportSuccess, |
||||||
|
onImportError, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let fileInput: HTMLInputElement; |
||||||
|
|
||||||
|
function handleExport() { |
||||||
|
const { blob, filename } = editorStore.exportGame(); |
||||||
|
const url = URL.createObjectURL(blob); |
||||||
|
const a = document.createElement("a"); |
||||||
|
a.href = url; |
||||||
|
a.download = filename; |
||||||
|
a.click(); |
||||||
|
URL.revokeObjectURL(url); |
||||||
|
} |
||||||
|
|
||||||
|
function handleImport(event: Event) { |
||||||
|
const file = (event.target as HTMLInputElement).files?.[0]; |
||||||
|
if (!file) return; |
||||||
|
|
||||||
|
const reader = new FileReader(); |
||||||
|
reader.onload = (e) => { |
||||||
|
try { |
||||||
|
const data = JSON.parse(e.target?.result as string); |
||||||
|
const result = editorStore.importGame(data); |
||||||
|
if (result === true) { |
||||||
|
gameName = editorStore.gameName; |
||||||
|
onImportSuccess?.(); |
||||||
|
} else { |
||||||
|
onImportError?.(result); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
onImportError?.(m.kv_toast_invalid_file()); |
||||||
|
} |
||||||
|
}; |
||||||
|
reader.readAsText(file); |
||||||
|
(event.target as HTMLInputElement).value = ""; |
||||||
|
} |
||||||
|
|
||||||
|
// SVG Icons |
||||||
|
const BackIcon = `<path d="M29.5334 40L13.5334 24L29.5334 8L33.2668 11.7333L21.0001 24L33.2668 36.2667L29.5334 40Z"/>`; |
||||||
|
const LoadIcon = `<path d="M5.41634 33.3332C4.49967 33.3332 3.71495 33.0068 3.06217 32.354C2.4094 31.7012 2.08301 30.9165 2.08301 29.9998V9.99984C2.08301 9.08317 2.4094 8.29845 3.06217 7.64567C3.71495 6.99289 4.49967 6.6665 5.41634 6.6665H15.4163L18.7497 9.99984H32.083C32.9997 9.99984 33.7844 10.3262 34.4372 10.979C35.09 11.6318 35.4163 12.4165 35.4163 13.3332H17.3747L14.0413 9.99984H5.41634V29.9998L9.41634 16.6665H37.9163L33.6247 30.9582C33.4025 31.6804 32.9927 32.2568 32.3955 32.6873C31.7983 33.1179 31.1386 33.3332 30.4163 33.3332H5.41634ZM8.91634 29.9998H30.4163L33.4163 19.9998H11.9163L8.91634 29.9998Z"/>`; |
||||||
|
const SaveIcon = `<path d="M35 11.6667V31.6667C35 32.5833 34.6736 33.3681 34.0208 34.0208C33.3681 34.6736 32.5833 35 31.6667 35H8.33333C7.41667 35 6.63194 34.6736 5.97917 34.0208C5.32639 33.3681 5 32.5833 5 31.6667V8.33333C5 7.41667 5.32639 6.63194 5.97917 5.97917C6.63194 5.32639 7.41667 5 8.33333 5H28.3333L35 11.6667ZM31.6667 13.0833L26.9167 8.33333H8.33333V31.6667H31.6667V13.0833ZM20 30C21.3889 30 22.5694 29.5139 23.5417 28.5417C24.5139 27.5694 25 26.3889 25 25C25 23.6111 24.5139 22.4306 23.5417 21.4583C22.5694 20.4861 21.3889 20 20 20C18.6111 20 17.4306 20.4861 16.4583 21.4583C15.4861 22.4306 15 23.6111 15 25C15 26.3889 15.4861 27.5694 16.4583 28.5417C17.4306 29.5139 18.6111 30 20 30ZM10 16.6667H25V10H10V16.6667Z"/>`; |
||||||
|
const ResetIcon = `<path d="M20.0837 33.3332C16.3614 33.3332 13.1948 32.0415 10.5837 29.4582C7.97255 26.8748 6.66699 23.7221 6.66699 19.9998V19.7082L4.00033 22.3748L1.66699 20.0415L8.33366 13.3748L15.0003 20.0415L12.667 22.3748L10.0003 19.7082V19.9998C10.0003 22.7776 10.9795 25.1387 12.9378 27.0832C14.8962 29.0276 17.2781 29.9998 20.0837 29.9998C20.8059 29.9998 21.5142 29.9165 22.2087 29.7498C22.9031 29.5832 23.5837 29.3332 24.2503 28.9998L26.7503 31.4998C25.6948 32.111 24.6114 32.5693 23.5003 32.8748C22.3892 33.1804 21.2503 33.3332 20.0837 33.3332ZM31.667 26.6248L25.0003 19.9582L27.3337 17.6248L30.0003 20.2915V19.9998C30.0003 17.2221 29.0212 14.8609 27.0628 12.9165C25.1045 10.9721 22.7225 9.99984 19.917 9.99984C19.1948 9.99984 18.4864 10.0832 17.792 10.2498C17.0975 10.4165 16.417 10.6665 15.7503 10.9998L13.2503 8.49984C14.3059 7.88873 15.3892 7.43039 16.5003 7.12484C17.6114 6.81928 18.7503 6.6665 19.917 6.6665C23.6392 6.6665 26.8059 7.95817 29.417 10.5415C32.0281 13.1248 33.3337 16.2776 33.3337 19.9998V20.2915L36.0003 17.6248L38.3337 19.9582L31.667 26.6248Z"/>`; |
||||||
|
const SettingsIcon = `<path d="M12.125 27.5L11.5 23.34Q10.91 23.09 10.25 22.72Q9.59 22.34 9.09 21.94L5.19 23.63L2.5 18.88L5.91 16.28Q5.84 15.97 5.83 15.61Q5.81 15.25 5.81 15Q5.81 14.75 5.83 14.39Q5.84 14.03 5.91 13.72L2.5 11.13L5.19 6.38L9.09 8.06Q9.59 7.66 10.25 7.28Q10.91 6.91 11.5 6.66L12.125 2.5H17.875L18.5 6.66Q19.09 6.91 19.75 7.28Q20.41 7.66 20.91 8.06L24.81 6.38L27.5 11.13L24.09 13.72Q24.16 14.03 24.17 14.39Q24.19 14.75 24.19 15Q24.19 15.25 24.17 15.61Q24.16 15.97 24.09 16.28L27.5 18.88L24.81 23.63L20.91 21.94Q20.41 22.34 19.75 22.72Q19.09 23.09 18.5 23.34L17.875 27.5ZM15 19.06Q16.69 19.06 17.88 17.88Q19.06 16.69 19.06 15Q19.06 13.31 17.88 12.13Q16.69 10.94 15 10.94Q13.31 10.94 12.13 12.13Q10.94 13.31 10.94 15Q10.94 16.69 12.13 17.88Q13.31 19.06 15 19.06Z"/>`; |
||||||
|
</script> |
||||||
|
|
||||||
|
<header |
||||||
|
class="bg-kv-blue flex flex-wrap items-center gap-4 lg:gap-8 p-2 md:p-4" |
||||||
|
> |
||||||
|
<!-- Back + Game Name --> |
||||||
|
<div class="flex items-center gap-4 flex-1"> |
||||||
|
<a |
||||||
|
href="/kuldvillak" |
||||||
|
class="block w-12 h-12 hover:opacity-80 transition-opacity text-kv-yellow" |
||||||
|
aria-label={m.kv_edit_back()} |
||||||
|
> |
||||||
|
<svg viewBox="0 0 48 48" fill="currentColor" class="w-full h-full"> |
||||||
|
{@html BackIcon} |
||||||
|
</svg> |
||||||
|
</a> |
||||||
|
<div class="flex-1 px-4 py-4 bg-kv-black/50"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
bind:value={gameName} |
||||||
|
placeholder={m.kv_edit_game_name()} |
||||||
|
class="w-full bg-transparent border-none outline-none font-kv-body text-2xl text-kv-white uppercase kv-shadow-text placeholder:text-kv-white/50" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Action Buttons --> |
||||||
|
<div class="flex items-center gap-8"> |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={() => fileInput.click()} |
||||||
|
ariaLabel={m.kv_edit_load()} |
||||||
|
> |
||||||
|
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full"> |
||||||
|
{@html LoadIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={handleExport} |
||||||
|
ariaLabel={m.kv_edit_save()} |
||||||
|
> |
||||||
|
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full"> |
||||||
|
{@html SaveIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={onReset} |
||||||
|
ariaLabel={m.kv_edit_reset()} |
||||||
|
> |
||||||
|
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full"> |
||||||
|
{@html ResetIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
{#if onSettings} |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={onSettings} |
||||||
|
ariaLabel={m.kv_settings()} |
||||||
|
class="w-8 h-8" |
||||||
|
> |
||||||
|
<svg |
||||||
|
viewBox="0 0 30 30" |
||||||
|
fill="currentColor" |
||||||
|
class="w-full h-full" |
||||||
|
> |
||||||
|
{@html SettingsIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<input |
||||||
|
type="file" |
||||||
|
accept=".json" |
||||||
|
class="hidden" |
||||||
|
bind:this={fileInput} |
||||||
|
onchange={handleImport} |
||||||
|
/> |
||||||
|
|
||||||
|
<KvButton variant="secondary" onclick={onStart}> |
||||||
|
▶ {m.kv_edit_start()} |
||||||
|
</KvButton> |
||||||
|
</header> |
||||||
@ -0,0 +1,284 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { editorStore } from "$lib/stores/editor.svelte"; |
||||||
|
import { KvButton, KvCheckbox } from "$lib/components/kuldvillak/ui"; |
||||||
|
import { ConfirmDialog } from "$lib/components"; |
||||||
|
import { trapFocus } from "$lib/utils/focusTrap"; |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
roundIndex?: number; |
||||||
|
catIndex?: number; |
||||||
|
qIndex?: number; |
||||||
|
onClose: () => void; |
||||||
|
finalRound?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
roundIndex = 0, |
||||||
|
catIndex = 0, |
||||||
|
qIndex = 0, |
||||||
|
onClose, |
||||||
|
finalRound = false, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
// Get the original question data |
||||||
|
let originalQuestion = $derived( |
||||||
|
finalRound |
||||||
|
? { |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
imageUrl: "", |
||||||
|
isDailyDouble: false, |
||||||
|
points: 0, |
||||||
|
} |
||||||
|
: editorStore.getQuestion(roundIndex, catIndex, qIndex), |
||||||
|
); |
||||||
|
let category = $derived( |
||||||
|
finalRound |
||||||
|
? null |
||||||
|
: editorStore.rounds[roundIndex]?.categories[catIndex], |
||||||
|
); |
||||||
|
let maxDD = $derived( |
||||||
|
finalRound |
||||||
|
? 0 |
||||||
|
: (editorStore.settings.dailyDoublesPerRound[roundIndex] ?? 1), |
||||||
|
); |
||||||
|
let currentDD = $derived( |
||||||
|
finalRound ? 0 : editorStore.countDailyDoubles(roundIndex), |
||||||
|
); |
||||||
|
|
||||||
|
// Local editing state (only saved when user clicks save) |
||||||
|
let localCategory = $state(""); |
||||||
|
let localQuestion = $state(""); |
||||||
|
let localAnswer = $state(""); |
||||||
|
let localImageUrl = $state(""); |
||||||
|
let localIsDailyDouble = $state(false); |
||||||
|
|
||||||
|
// Track if there are unsaved changes |
||||||
|
let hasChanges = $derived( |
||||||
|
finalRound |
||||||
|
? localCategory !== editorStore.finalRound.category || |
||||||
|
localQuestion !== editorStore.finalRound.question || |
||||||
|
localAnswer !== editorStore.finalRound.answer |
||||||
|
: originalQuestion && |
||||||
|
(localQuestion !== originalQuestion.question || |
||||||
|
localAnswer !== originalQuestion.answer || |
||||||
|
localImageUrl !== (originalQuestion.imageUrl || "") || |
||||||
|
localIsDailyDouble !== originalQuestion.isDailyDouble), |
||||||
|
); |
||||||
|
|
||||||
|
// Can toggle DD (either already DD or have room for more) |
||||||
|
let canToggleDD = $derived(localIsDailyDouble || currentDD < maxDD); |
||||||
|
|
||||||
|
// Confirmation dialog state |
||||||
|
let showConfirmClose = $state(false); |
||||||
|
|
||||||
|
// Initialize local state when question changes |
||||||
|
$effect(() => { |
||||||
|
if (finalRound) { |
||||||
|
localCategory = editorStore.finalRound.category; |
||||||
|
localQuestion = editorStore.finalRound.question; |
||||||
|
localAnswer = editorStore.finalRound.answer; |
||||||
|
} else if (originalQuestion) { |
||||||
|
localQuestion = originalQuestion.question; |
||||||
|
localAnswer = originalQuestion.answer; |
||||||
|
localImageUrl = originalQuestion.imageUrl || ""; |
||||||
|
localIsDailyDouble = originalQuestion.isDailyDouble; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function handleSaveAndClose() { |
||||||
|
if (finalRound) { |
||||||
|
editorStore.updateFinalRound({ |
||||||
|
category: localCategory, |
||||||
|
question: localQuestion, |
||||||
|
answer: localAnswer, |
||||||
|
}); |
||||||
|
} else if (originalQuestion) { |
||||||
|
editorStore.updateQuestion(roundIndex, catIndex, qIndex, { |
||||||
|
question: localQuestion, |
||||||
|
answer: localAnswer, |
||||||
|
imageUrl: localImageUrl || undefined, |
||||||
|
isDailyDouble: localIsDailyDouble, |
||||||
|
}); |
||||||
|
} |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
|
||||||
|
function handleCloseAttempt() { |
||||||
|
if (hasChanges) { |
||||||
|
showConfirmClose = true; |
||||||
|
} else { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleDiscardAndClose() { |
||||||
|
showConfirmClose = false; |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
|
||||||
|
function handleSaveFromConfirm() { |
||||||
|
showConfirmClose = false; |
||||||
|
handleSaveAndClose(); |
||||||
|
} |
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) { |
||||||
|
if (e.target === e.currentTarget) handleCloseAttempt(); |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) { |
||||||
|
if (e.key === "Escape") handleCloseAttempt(); |
||||||
|
} |
||||||
|
|
||||||
|
const CloseIcon = `<path d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z"/>`; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if originalQuestion || finalRound} |
||||||
|
<div |
||||||
|
class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8" |
||||||
|
onclick={handleBackdropClick} |
||||||
|
onkeydown={handleKeydown} |
||||||
|
role="dialog" |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div |
||||||
|
class="bg-kv-blue border-8 md:border-[16px] border-kv-black p-4 md:p-8 w-full max-w-2xl flex flex-col gap-4 md:gap-8 max-h-[95vh] overflow-y-auto" |
||||||
|
use:trapFocus |
||||||
|
> |
||||||
|
<!-- Header --> |
||||||
|
<div class="flex items-start justify-between gap-4"> |
||||||
|
<h3 |
||||||
|
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text m-0" |
||||||
|
> |
||||||
|
{#if finalRound} |
||||||
|
{m.kv_edit_final_round()} |
||||||
|
{:else} |
||||||
|
{category?.name || m.kv_edit_category()} - {originalQuestion?.points}€ |
||||||
|
{/if} |
||||||
|
</h3> |
||||||
|
<button |
||||||
|
onclick={handleCloseAttempt} |
||||||
|
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow" |
||||||
|
aria-label={m.kv_settings_close()} |
||||||
|
> |
||||||
|
<svg |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="currentColor" |
||||||
|
class="w-full h-full" |
||||||
|
> |
||||||
|
{@html CloseIcon} |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Category Input (Final Round only) --> |
||||||
|
{#if finalRound} |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<label |
||||||
|
for="q-category" |
||||||
|
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text" |
||||||
|
> |
||||||
|
{m.kv_edit_category()} |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="q-category" |
||||||
|
type="text" |
||||||
|
bind:value={localCategory} |
||||||
|
placeholder={m.kv_edit_category()} |
||||||
|
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none placeholder:text-kv-white/50 uppercase" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Question Input --> |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<label |
||||||
|
for="q-text" |
||||||
|
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text" |
||||||
|
> |
||||||
|
{m.kv_edit_question()} |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
id="q-text" |
||||||
|
bind:value={localQuestion} |
||||||
|
placeholder={m.kv_edit_question()} |
||||||
|
rows={4} |
||||||
|
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none resize-none placeholder:text-kv-white/50 uppercase" |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Answer Input --> |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<label |
||||||
|
for="q-answer" |
||||||
|
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text" |
||||||
|
> |
||||||
|
{m.kv_edit_answer()} |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
id="q-answer" |
||||||
|
bind:value={localAnswer} |
||||||
|
placeholder={m.kv_edit_answer()} |
||||||
|
rows={3} |
||||||
|
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none resize-none placeholder:text-kv-white/50 uppercase" |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Image URL Input (not for final round) --> |
||||||
|
{#if !finalRound} |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<label |
||||||
|
for="q-image" |
||||||
|
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text" |
||||||
|
> |
||||||
|
{m.kv_edit_image_link()} |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="q-image" |
||||||
|
type="text" |
||||||
|
bind:value={localImageUrl} |
||||||
|
placeholder="https://example.com/image.jpg" |
||||||
|
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none placeholder:text-kv-white/50 uppercase" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Daily Double Toggle --> |
||||||
|
<div class="flex items-center gap-4"> |
||||||
|
<KvCheckbox |
||||||
|
checked={localIsDailyDouble} |
||||||
|
disabled={!canToggleDD && !localIsDailyDouble} |
||||||
|
onclick={() => |
||||||
|
(localIsDailyDouble = !localIsDailyDouble)} |
||||||
|
/> |
||||||
|
<span |
||||||
|
class="font-kv-body text-lg text-kv-white uppercase kv-shadow-text" |
||||||
|
> |
||||||
|
{m.kv_edit_daily_double()} |
||||||
|
</span> |
||||||
|
<span class="font-kv-body text-sm text-kv-yellow"> |
||||||
|
({currentDD}/{maxDD}) |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Save Button (left aligned) --> |
||||||
|
<div class="flex justify-start"> |
||||||
|
<KvButton variant="secondary" onclick={handleSaveAndClose}> |
||||||
|
{m.kv_settings_save_exit()} |
||||||
|
</KvButton> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Confirmation Dialog for unsaved changes --> |
||||||
|
<ConfirmDialog |
||||||
|
bind:open={showConfirmClose} |
||||||
|
title={m.kv_confirm_close_title()} |
||||||
|
message={m.kv_confirm_close_message()} |
||||||
|
confirmText={m.kv_confirm_discard()} |
||||||
|
cancelText={m.kv_settings_save_exit()} |
||||||
|
onconfirm={handleDiscardAndClose} |
||||||
|
oncancel={handleSaveFromConfirm} |
||||||
|
/> |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { editorStore } from "$lib/stores/editor.svelte"; |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
activeRoundIndex?: number; |
||||||
|
onQuestionClick?: ( |
||||||
|
roundIndex: number, |
||||||
|
catIndex: number, |
||||||
|
qIndex: number, |
||||||
|
) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { activeRoundIndex = $bindable(0), onQuestionClick }: Props = $props(); |
||||||
|
|
||||||
|
// Ensure activeRoundIndex stays in bounds when rounds change |
||||||
|
$effect(() => { |
||||||
|
if (activeRoundIndex >= editorStore.rounds.length) { |
||||||
|
activeRoundIndex = Math.max(0, editorStore.rounds.length - 1); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Get round display name |
||||||
|
function getRoundName(index: number): string { |
||||||
|
if (index === 0) return m.kv_edit_r1(); |
||||||
|
if (index === 1) return m.kv_edit_r2(); |
||||||
|
return `Round ${index + 1}`; |
||||||
|
} |
||||||
|
|
||||||
|
// Handle question slot click |
||||||
|
function handleQuestionClick(catIndex: number, qIndex: number) { |
||||||
|
onQuestionClick?.(activeRoundIndex, catIndex, qIndex); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:gap-4 flex-1"> |
||||||
|
<!-- Active Round Content --> |
||||||
|
{#each editorStore.rounds as round, ri} |
||||||
|
{#if ri === activeRoundIndex} |
||||||
|
<section class="flex-1 flex flex-col bg-kv-black"> |
||||||
|
<!-- Round Header --> |
||||||
|
<div class="bg-kv-blue p-4"> |
||||||
|
<h2 |
||||||
|
class="font-kv-body text-[28px] uppercase kv-shadow-text m-0" |
||||||
|
> |
||||||
|
<span class="text-kv-white">{getRoundName(ri)}</span> |
||||||
|
<span class="text-kv-yellow text-xl ml-2"> |
||||||
|
({m.kv_edit_dd_count()} |
||||||
|
{editorStore.countDailyDoubles(ri)}/{editorStore |
||||||
|
.settings.dailyDoublesPerRound[ri] ?? 1}) |
||||||
|
</span> |
||||||
|
</h2> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Categories Row --> |
||||||
|
<div |
||||||
|
class="grid grid-cols-3 md:grid-cols-6 gap-1 md:gap-2 bg-kv-black py-2" |
||||||
|
> |
||||||
|
{#each round.categories as cat, ci} |
||||||
|
<div class="bg-kv-blue py-2"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={cat.name} |
||||||
|
oninput={(e) => |
||||||
|
editorStore.updateCategoryName( |
||||||
|
ri, |
||||||
|
ci, |
||||||
|
e.currentTarget.value, |
||||||
|
)} |
||||||
|
placeholder={m.kv_edit_category()} |
||||||
|
class="w-full bg-transparent border-none outline-none font-kv-body text-sm text-kv-white text-center uppercase kv-shadow-text placeholder:text-kv-white/50" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Questions Grid --> |
||||||
|
<div |
||||||
|
class="grid grid-cols-3 md:grid-cols-6 gap-1 md:gap-2 bg-kv-black flex-1" |
||||||
|
> |
||||||
|
{#each { length: editorStore.settings.questionsPerCategory } as _, qi} |
||||||
|
{#each round.categories as cat, ci} |
||||||
|
{@const q = cat.questions[qi]} |
||||||
|
{#if q} |
||||||
|
<button |
||||||
|
onclick={() => handleQuestionClick(ci, qi)} |
||||||
|
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative min-h-[60px] md:min-h-[80px] |
||||||
|
{q.question.trim() |
||||||
|
? 'opacity-100' |
||||||
|
: 'opacity-50'} |
||||||
|
{q.isDailyDouble |
||||||
|
? 'ring-4 ring-inset ring-kv-yellow' |
||||||
|
: ''}" |
||||||
|
> |
||||||
|
<span |
||||||
|
class="font-kv-price text-kv-yellow text-2xl md:text-4xl kv-shadow-price kv-shadow-text" |
||||||
|
> |
||||||
|
{q.points}€ |
||||||
|
</span> |
||||||
|
<!-- Daily Double indicator: black star with yellow box --> |
||||||
|
{#if q.isDailyDouble} |
||||||
|
<div |
||||||
|
class="absolute -top-0 right-1 bg-kv-yellow px-1 py-0.5 flex items-center justify-center" |
||||||
|
> |
||||||
|
<span class="text-kv-black text-sm" |
||||||
|
>★</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
@ -0,0 +1,265 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { editorStore } from "$lib/stores/editor.svelte"; |
||||||
|
import { KvCheckbox, KvButton } from "$lib/components/kuldvillak/ui"; |
||||||
|
import { TeamEditor } from "./index"; |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onShowRules?: () => void; |
||||||
|
onShowHowTo?: () => void; |
||||||
|
onEditFinalRound?: () => void; |
||||||
|
activeRoundIndex?: number; |
||||||
|
onRoundSelect?: (index: number) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
onShowRules, |
||||||
|
onShowHowTo, |
||||||
|
onEditFinalRound, |
||||||
|
activeRoundIndex = 0, |
||||||
|
onRoundSelect, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
function setRoundCount(count: 1 | 2) { |
||||||
|
editorStore.setRoundCount(count, m.kv_edit_r2()); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<section class="bg-kv-blue p-2 md:p-4 overflow-x-auto"> |
||||||
|
<!-- Title Row --> |
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 md:mb-8"> |
||||||
|
<h2 class="kv-h3 text-kv-white m-0">{m.kv_edit_settings_teams()}</h2> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#if onShowRules} |
||||||
|
<KvButton variant="secondary" onclick={onShowRules} size="md" |
||||||
|
>{m.kv_edit_rules()}</KvButton |
||||||
|
> |
||||||
|
{/if} |
||||||
|
{#if onShowHowTo} |
||||||
|
<KvButton variant="secondary" onclick={onShowHowTo} size="md" |
||||||
|
>{m.kv_edit_how_to()}</KvButton |
||||||
|
> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 lg:gap-16 xl:gap-32"> |
||||||
|
<!-- Left: Core Settings --> |
||||||
|
<div class="flex gap-8"> |
||||||
|
<div class="flex flex-col gap-4"> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span class="kv-label text-kv-white" |
||||||
|
>{m.kv_edit_rounds()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span class="kv-label text-kv-white" |
||||||
|
>{m.kv_play_timer()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span class="kv-label text-kv-white" |
||||||
|
>{m.kv_play_timer_reveal()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span class="kv-label text-kv-white" |
||||||
|
>{m.kv_edit_final_round()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
{#if editorStore.settings.numberOfRounds === 2 && onRoundSelect} |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span class="kv-label text-kv-white" |
||||||
|
>{m.kv_edit_edit_round()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div class="flex flex-col gap-4"> |
||||||
|
<!-- Round count --> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="square" |
||||||
|
active={editorStore.settings.numberOfRounds === 1} |
||||||
|
onclick={() => setRoundCount(1)}>1</KvButton |
||||||
|
> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="square" |
||||||
|
active={editorStore.settings.numberOfRounds === 2} |
||||||
|
onclick={() => setRoundCount(2)}>2</KvButton |
||||||
|
> |
||||||
|
</div> |
||||||
|
<!-- Timer --> |
||||||
|
<div class="h-12 flex items-center gap-2"> |
||||||
|
<div |
||||||
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center" |
||||||
|
> |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
bind:value={ |
||||||
|
editorStore.settings.defaultTimerSeconds |
||||||
|
} |
||||||
|
min={1} |
||||||
|
max={60} |
||||||
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-xl text-kv-white text-center uppercase [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_play_seconds()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<!-- Reveal time --> |
||||||
|
<div class="h-12 flex items-center gap-2"> |
||||||
|
<div |
||||||
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center" |
||||||
|
> |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
bind:value={ |
||||||
|
editorStore.settings.answerRevealSeconds |
||||||
|
} |
||||||
|
min={1} |
||||||
|
max={60} |
||||||
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-xl text-kv-white text-center uppercase [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_play_seconds()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<!-- Final round toggle --> |
||||||
|
<div class="h-12 flex items-center gap-2"> |
||||||
|
<KvCheckbox |
||||||
|
checked={editorStore.settings.enableFinalRound} |
||||||
|
onclick={() => |
||||||
|
(editorStore.settings.enableFinalRound = |
||||||
|
!editorStore.settings.enableFinalRound)} |
||||||
|
/> |
||||||
|
{#if editorStore.settings.enableFinalRound && onEditFinalRound} |
||||||
|
<KvButton |
||||||
|
variant="secondary" |
||||||
|
size="sm" |
||||||
|
onclick={onEditFinalRound} |
||||||
|
> |
||||||
|
{m.kv_edit_question()} |
||||||
|
</KvButton> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<!-- Round selection (only show if 2 rounds) --> |
||||||
|
{#if editorStore.settings.numberOfRounds === 2 && onRoundSelect} |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="hug" |
||||||
|
active={activeRoundIndex === 0} |
||||||
|
onclick={() => onRoundSelect(0)} |
||||||
|
> |
||||||
|
{m.kv_edit_r1()} |
||||||
|
</KvButton> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="hug" |
||||||
|
active={activeRoundIndex === 1} |
||||||
|
onclick={() => onRoundSelect(1)} |
||||||
|
> |
||||||
|
{m.kv_edit_r2()} |
||||||
|
</KvButton> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Middle: Points & Teams --> |
||||||
|
<div class="flex gap-8"> |
||||||
|
<div class="flex flex-col gap-4"> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_edit_points()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_edit_values()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_edit_negative_scores()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<span |
||||||
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||||
|
>{m.kv_edit_teams_label()}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="flex flex-col gap-4"> |
||||||
|
<!-- Point preset --> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="hug" |
||||||
|
active={editorStore.settings.pointValuePreset === |
||||||
|
"round1"} |
||||||
|
onclick={() => editorStore.updatePreset("round1")} |
||||||
|
>{m.kv_edit_preset_normal()}</KvButton |
||||||
|
> |
||||||
|
<KvButton |
||||||
|
variant="toggle" |
||||||
|
size="hug" |
||||||
|
active={editorStore.settings.pointValuePreset === |
||||||
|
"custom"} |
||||||
|
onclick={() => editorStore.updatePreset("custom")} |
||||||
|
>{m.kv_edit_custom()}</KvButton |
||||||
|
> |
||||||
|
</div> |
||||||
|
<!-- Point values --> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
{#each editorStore.settings.pointValues as val, i} |
||||||
|
<div |
||||||
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center" |
||||||
|
> |
||||||
|
{#if editorStore.settings.pointValuePreset === "custom"} |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
bind:value={ |
||||||
|
editorStore.settings.pointValues[i] |
||||||
|
} |
||||||
|
onchange={() => |
||||||
|
editorStore.updateQuestionPoints()} |
||||||
|
min={1} |
||||||
|
max={9999} |
||||||
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-lg text-kv-white text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<span |
||||||
|
class="font-kv-body text-lg text-kv-white/50" |
||||||
|
>{val}</span |
||||||
|
> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
<!-- Negative scores --> |
||||||
|
<div class="h-12 flex items-center"> |
||||||
|
<KvCheckbox |
||||||
|
checked={editorStore.settings.allowNegativeScores} |
||||||
|
onclick={() => |
||||||
|
(editorStore.settings.allowNegativeScores = |
||||||
|
!editorStore.settings.allowNegativeScores)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<!-- Teams --> |
||||||
|
<TeamEditor /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { editorStore } from "$lib/stores/editor.svelte"; |
||||||
|
import { KvButton } from "$lib/components/kuldvillak/ui"; |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
|
||||||
|
// Icons as components for cleaner markup |
||||||
|
const RemoveIcon = `<path d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z"/>`; |
||||||
|
const AddIcon = `<path d="M22.2857 25.7143H12V22.2857H22.2857V12H25.7143V22.2857H36V25.7143H25.7143V36H22.2857V25.7143Z"/>`; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap"> |
||||||
|
{#each editorStore.teams as team (team.id)} |
||||||
|
<div class="flex items-center h-12 border-4 border-kv-black px-2"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={team.name} |
||||||
|
oninput={(e) => |
||||||
|
editorStore.updateTeamName(team.id, e.currentTarget.value)} |
||||||
|
class="bg-transparent border-none outline-none font-kv-body text-xl text-kv-text uppercase min-w-[80px] max-w-[120px] kv-shadow-text" |
||||||
|
/> |
||||||
|
{#if editorStore.canRemoveTeam} |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={() => editorStore.removeTeam(team.id)} |
||||||
|
ariaLabel={m.kv_edit_remove_team()} |
||||||
|
class="w-8 h-8" |
||||||
|
> |
||||||
|
<svg |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="currentColor" |
||||||
|
class="w-6 h-6" |
||||||
|
> |
||||||
|
{@html RemoveIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#if editorStore.canAddTeam} |
||||||
|
<KvButton |
||||||
|
variant="icon" |
||||||
|
size="icon" |
||||||
|
onclick={() => editorStore.addTeam()} |
||||||
|
ariaLabel={m.kv_edit_add_team()} |
||||||
|
> |
||||||
|
<svg viewBox="0 0 48 48" fill="currentColor" class="w-8 h-8"> |
||||||
|
{@html AddIcon} |
||||||
|
</svg> |
||||||
|
</KvButton> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
// Editor Components
|
||||||
|
export { default as TeamEditor } from './TeamEditor.svelte'; |
||||||
|
export { default as RoundEditor } from './RoundEditor.svelte'; |
||||||
|
export { default as EditorHeader } from './EditorHeader.svelte'; |
||||||
|
export { default as QuestionEditModal } from './QuestionEditModal.svelte'; |
||||||
|
export { default as SettingsPanel } from './SettingsPanel.svelte'; |
||||||
@ -0,0 +1,101 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { Snippet } from "svelte"; |
||||||
|
|
||||||
|
type Variant = |
||||||
|
| "primary" |
||||||
|
| "secondary" |
||||||
|
| "success" |
||||||
|
| "danger" |
||||||
|
| "warning" |
||||||
|
| "ghost" |
||||||
|
| "toggle" |
||||||
|
| "icon"; |
||||||
|
type Size = "sm" | "md" | "lg" | "icon" | "square" | "hug"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
variant?: Variant; |
||||||
|
size?: Size; |
||||||
|
disabled?: boolean; |
||||||
|
active?: boolean; |
||||||
|
onclick?: () => void; |
||||||
|
children: Snippet; |
||||||
|
class?: string; |
||||||
|
ariaLabel?: string; |
||||||
|
type?: "button" | "submit" | "reset"; |
||||||
|
href?: string; |
||||||
|
reload?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
variant = "primary", |
||||||
|
size = "md", |
||||||
|
disabled = false, |
||||||
|
active = false, |
||||||
|
onclick, |
||||||
|
children, |
||||||
|
class: className = "", |
||||||
|
ariaLabel, |
||||||
|
type = "button", |
||||||
|
href, |
||||||
|
reload = false, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
const baseClasses = |
||||||
|
"inline-flex items-center justify-center border-4 border-black box-border cursor-pointer transition-all hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"; |
||||||
|
|
||||||
|
const variantClasses: Record<Variant, string> = { |
||||||
|
primary: "bg-kv-blue text-kv-white kv-shadow-button", |
||||||
|
secondary: "bg-kv-yellow text-black kv-shadow-button", |
||||||
|
success: "bg-kv-green text-kv-white", |
||||||
|
danger: "bg-kv-red text-kv-white", |
||||||
|
warning: "bg-kv-yellow text-black", |
||||||
|
ghost: "bg-transparent text-kv-white", |
||||||
|
toggle: "bg-transparent", |
||||||
|
icon: "bg-transparent border-none p-0", |
||||||
|
}; |
||||||
|
|
||||||
|
// Use -plain variants (no text shadow on buttons) |
||||||
|
const sizeClasses: Record<Size, string> = { |
||||||
|
sm: "px-3 py-1 kv-body-sm-plain", |
||||||
|
md: "px-4 py-2 kv-body-lg-plain", |
||||||
|
lg: "px-6 py-4 kv-h3-plain", |
||||||
|
icon: "w-10 h-10", |
||||||
|
square: "w-12 h-12 font-kv-body text-xl uppercase", |
||||||
|
hug: "px-4 h-12 font-kv-body text-xl uppercase", |
||||||
|
}; |
||||||
|
|
||||||
|
// For toggle variant, apply active state colors (must be reactive) |
||||||
|
let activeClass = $derived( |
||||||
|
variant === "toggle" && active ? "text-kv-yellow" : "", |
||||||
|
); |
||||||
|
let inactiveClass = $derived( |
||||||
|
variant === "toggle" && !active ? "text-kv-white" : "", |
||||||
|
); |
||||||
|
let iconColorClass = $derived(variant === "icon" ? "text-kv-yellow" : ""); |
||||||
|
|
||||||
|
let combinedClasses = $derived( |
||||||
|
`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${activeClass} ${inactiveClass} ${iconColorClass} ${className}`, |
||||||
|
); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if href} |
||||||
|
<a |
||||||
|
{href} |
||||||
|
class={combinedClasses} |
||||||
|
aria-label={ariaLabel} |
||||||
|
data-sveltekit-reload={reload || undefined} |
||||||
|
> |
||||||
|
{@render children()} |
||||||
|
</a> |
||||||
|
{:else} |
||||||
|
<button |
||||||
|
{type} |
||||||
|
class={combinedClasses} |
||||||
|
{disabled} |
||||||
|
{onclick} |
||||||
|
aria-label={ariaLabel} |
||||||
|
aria-disabled={disabled} |
||||||
|
> |
||||||
|
{@render children()} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
@ -1,48 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { Snippet } from "svelte"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
href?: string; |
|
||||||
disabled?: boolean; |
|
||||||
onclick?: () => void; |
|
||||||
children: Snippet; |
|
||||||
class?: string; |
|
||||||
reload?: boolean; |
|
||||||
ariaLabel?: string; |
|
||||||
} |
|
||||||
|
|
||||||
let { |
|
||||||
href, |
|
||||||
disabled = false, |
|
||||||
onclick, |
|
||||||
children, |
|
||||||
class: className = "", |
|
||||||
reload = false, |
|
||||||
ariaLabel, |
|
||||||
}: Props = $props(); |
|
||||||
|
|
||||||
const baseClasses = |
|
||||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue text-kv-white kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-yellow focus:ring-offset-2 focus:ring-offset-black"; |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if href && !disabled} |
|
||||||
<a |
|
||||||
{href} |
|
||||||
class="{baseClasses} {className}" |
|
||||||
data-sveltekit-reload={reload ? "" : undefined} |
|
||||||
aria-label={ariaLabel} |
|
||||||
role="button" |
|
||||||
> |
|
||||||
<span class="kv-shadow-text">{@render children()}</span> |
|
||||||
</a> |
|
||||||
{:else} |
|
||||||
<button |
|
||||||
class="{baseClasses} {className}" |
|
||||||
{disabled} |
|
||||||
{onclick} |
|
||||||
aria-label={ariaLabel} |
|
||||||
aria-disabled={disabled} |
|
||||||
> |
|
||||||
<span class="kv-shadow-text">{@render children()}</span> |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
@ -1,48 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { Snippet } from "svelte"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
href?: string; |
|
||||||
disabled?: boolean; |
|
||||||
onclick?: () => void; |
|
||||||
children: Snippet; |
|
||||||
class?: string; |
|
||||||
reload?: boolean; |
|
||||||
ariaLabel?: string; |
|
||||||
} |
|
||||||
|
|
||||||
let { |
|
||||||
href, |
|
||||||
disabled = false, |
|
||||||
onclick, |
|
||||||
children, |
|
||||||
class: className = "", |
|
||||||
reload = false, |
|
||||||
ariaLabel, |
|
||||||
}: Props = $props(); |
|
||||||
|
|
||||||
const baseClasses = |
|
||||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-kv-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-blue focus:ring-offset-2 focus:ring-offset-black"; |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if href && !disabled} |
|
||||||
<a |
|
||||||
{href} |
|
||||||
class="{baseClasses} {className}" |
|
||||||
data-sveltekit-reload={reload ? "" : undefined} |
|
||||||
aria-label={ariaLabel} |
|
||||||
role="button" |
|
||||||
> |
|
||||||
<span class="kv-shadow-text">{@render children()}</span> |
|
||||||
</a> |
|
||||||
{:else} |
|
||||||
<button |
|
||||||
class="{baseClasses} {className}" |
|
||||||
{disabled} |
|
||||||
{onclick} |
|
||||||
aria-label={ariaLabel} |
|
||||||
aria-disabled={disabled} |
|
||||||
> |
|
||||||
<span class="kv-shadow-text">{@render children()}</span> |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
@ -0,0 +1,38 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
checked: boolean; |
||||||
|
onclick: () => void; |
||||||
|
disabled?: boolean; |
||||||
|
class?: string; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
checked, |
||||||
|
onclick, |
||||||
|
disabled = false, |
||||||
|
class: className = "", |
||||||
|
}: Props = $props(); |
||||||
|
</script> |
||||||
|
|
||||||
|
<button |
||||||
|
{onclick} |
||||||
|
{disabled} |
||||||
|
class="w-8 h-8 cursor-pointer p-0 flex items-center justify-center border-4 border-black disabled:opacity-50 disabled:cursor-not-allowed {checked |
||||||
|
? 'bg-kv-yellow' |
||||||
|
: 'bg-white'} {className}" |
||||||
|
> |
||||||
|
{#if checked} |
||||||
|
<svg width="18" height="15" viewBox="0 0 18 15" fill="none"> |
||||||
|
<path |
||||||
|
d="M6 14.1L0 8.1L2.1 6L6 9.9L15.9 0L18 2.1L6 14.1Z" |
||||||
|
fill="black" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
{:else} |
||||||
|
<svg viewBox="0 0 24 24" fill="black" class="w-4 h-4"> |
||||||
|
<path |
||||||
|
d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
// ============================================
|
||||||
|
// Services Index
|
||||||
|
// Export storage service and keys
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type { StorageService } from './storage'; |
||||||
|
export { STORAGE_KEYS } from './storage'; |
||||||
|
export { localStorageService as storage } from './localStorage'; |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
// ============================================
|
||||||
|
// LocalStorage Service Implementation
|
||||||
|
// Browser localStorage backend for StorageService
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import { browser } from '$app/environment'; |
||||||
|
import type { StorageService } from './storage'; |
||||||
|
|
||||||
|
/** |
||||||
|
* LocalStorage implementation of StorageService. |
||||||
|
* Handles JSON serialization/deserialization automatically. |
||||||
|
* Safe for SSR - returns null/no-op when not in browser. |
||||||
|
*/ |
||||||
|
class LocalStorageService implements StorageService { |
||||||
|
/** |
||||||
|
* Get a value from localStorage |
||||||
|
* @param key Storage key |
||||||
|
* @returns Parsed value or null if not found/invalid |
||||||
|
*/ |
||||||
|
get<T>(key: string): T | null { |
||||||
|
if (!browser) return null; |
||||||
|
|
||||||
|
try { |
||||||
|
const item = localStorage.getItem(key); |
||||||
|
if (item === null) return null; |
||||||
|
return JSON.parse(item) as T; |
||||||
|
} catch (error) { |
||||||
|
console.warn(`[Storage] Failed to parse key "${key}":`, error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set a value in localStorage |
||||||
|
* @param key Storage key |
||||||
|
* @param value Value to store (will be JSON serialized) |
||||||
|
*/ |
||||||
|
set<T>(key: string, value: T): void { |
||||||
|
if (!browser) return; |
||||||
|
|
||||||
|
try { |
||||||
|
const serialized = JSON.stringify(value); |
||||||
|
localStorage.setItem(key, serialized); |
||||||
|
} catch (error) { |
||||||
|
console.error(`[Storage] Failed to set key "${key}":`, error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove a value from localStorage |
||||||
|
* @param key Storage key |
||||||
|
*/ |
||||||
|
remove(key: string): void { |
||||||
|
if (!browser) return; |
||||||
|
localStorage.removeItem(key); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a key exists in localStorage |
||||||
|
* @param key Storage key |
||||||
|
*/ |
||||||
|
has(key: string): boolean { |
||||||
|
if (!browser) return false; |
||||||
|
return localStorage.getItem(key) !== null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all localStorage data |
||||||
|
* Use with caution - affects all stored data |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
if (!browser) return; |
||||||
|
localStorage.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get raw string value without JSON parsing |
||||||
|
* Useful for checking data format or migration |
||||||
|
*/ |
||||||
|
getRaw(key: string): string | null { |
||||||
|
if (!browser) return null; |
||||||
|
return localStorage.getItem(key); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set raw string value without JSON serialization |
||||||
|
* Useful for storing pre-serialized data |
||||||
|
*/ |
||||||
|
setRaw(key: string, value: string): void { |
||||||
|
if (!browser) return; |
||||||
|
localStorage.setItem(key, value); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* List all keys matching a prefix |
||||||
|
* @param prefix Key prefix to filter by |
||||||
|
* @returns Array of matching keys |
||||||
|
*/ |
||||||
|
keys(prefix?: string): string[] { |
||||||
|
if (!browser) return []; |
||||||
|
|
||||||
|
const allKeys: string[] = []; |
||||||
|
for (let i = 0; i < localStorage.length; i++) { |
||||||
|
const key = localStorage.key(i); |
||||||
|
if (key) { |
||||||
|
if (!prefix || key.startsWith(prefix)) { |
||||||
|
allKeys.push(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return allKeys; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const localStorageService = new LocalStorageService(); |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
// ============================================
|
||||||
|
// Storage Service Interface
|
||||||
|
// Abstract interface for data persistence
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Abstract storage interface for data persistence. |
||||||
|
* Allows swapping between localStorage, IndexedDB, or API backends. |
||||||
|
*/ |
||||||
|
export interface StorageService { |
||||||
|
/** |
||||||
|
* Get a value from storage |
||||||
|
* @param key Storage key |
||||||
|
* @returns The stored value or null if not found |
||||||
|
*/ |
||||||
|
get<T>(key: string): T | null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Set a value in storage |
||||||
|
* @param key Storage key |
||||||
|
* @param value Value to store (will be serialized) |
||||||
|
*/ |
||||||
|
set<T>(key: string, value: T): void; |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove a value from storage |
||||||
|
* @param key Storage key |
||||||
|
*/ |
||||||
|
remove(key: string): void; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a key exists in storage |
||||||
|
* @param key Storage key |
||||||
|
*/ |
||||||
|
has(key: string): boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all stored data (use with caution) |
||||||
|
*/ |
||||||
|
clear(): void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Storage keys used throughout the application |
||||||
|
* Centralized to prevent typos and enable easy refactoring |
||||||
|
*/ |
||||||
|
export const STORAGE_KEYS = { |
||||||
|
EDITOR_AUTOSAVE: 'kuldvillak-editor-autosave', |
||||||
|
GAMES_LIST: 'kuldvillak-games', |
||||||
|
GAME_PREFIX: 'kuldvillak-game-', |
||||||
|
GAME_SESSION: 'kuldvillak-game-session', |
||||||
|
THEME: 'kuldvillak-theme', |
||||||
|
AUDIO: 'kuldvillak-audio', |
||||||
|
} as const; |
||||||
|
|
||||||
|
export type StorageKey = typeof STORAGE_KEYS[keyof typeof STORAGE_KEYS]; |
||||||
@ -0,0 +1,448 @@ |
|||||||
|
// ============================================
|
||||||
|
// Editor Store - Game Editor State Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
import { gameSession } from "./gameSession.svelte"; |
||||||
|
import { storage, STORAGE_KEYS } from "$lib/services"; |
||||||
|
import { validateGameData, type GameData } from "$lib/utils/validation"; |
||||||
|
import type { |
||||||
|
GameSettings, |
||||||
|
Team, |
||||||
|
Round, |
||||||
|
Category, |
||||||
|
Question, |
||||||
|
FinalRound, |
||||||
|
PointValuePreset, |
||||||
|
} from "$lib/types/kuldvillak"; |
||||||
|
import { DEFAULT_SETTINGS } from "$lib/types/kuldvillak"; |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Helper Functions (Pure, no state dependency)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function generateId(): string { |
||||||
|
return crypto.randomUUID(); |
||||||
|
} |
||||||
|
|
||||||
|
function createQuestion(points: number): Question { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
points, |
||||||
|
isDailyDouble: false, |
||||||
|
isRevealed: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createCategory(settings: GameSettings, multiplier: number = 1): Category { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
name: "", |
||||||
|
questions: settings.pointValues.map((p) => createQuestion(p * multiplier)), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createRound(name: string, multiplier: number, settings: GameSettings): Round { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
name, |
||||||
|
categories: Array.from({ length: settings.categoriesPerRound }, () => |
||||||
|
createCategory(settings, multiplier) |
||||||
|
), |
||||||
|
pointMultiplier: multiplier, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Editor Store Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
class EditorStore { |
||||||
|
// Core game data
|
||||||
|
gameName = $state(""); |
||||||
|
settings = $state<GameSettings>({ |
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
defaultTimerSeconds: 5, |
||||||
|
answerRevealSeconds: 5, |
||||||
|
}); |
||||||
|
teams = $state<Team[]>([ |
||||||
|
{ id: generateId(), name: "Mängija 1", score: 0 }, |
||||||
|
{ id: generateId(), name: "Mängija 2", score: 0 }, |
||||||
|
]); |
||||||
|
rounds = $state<Round[]>([]); |
||||||
|
finalRound = $state<FinalRound>({ |
||||||
|
category: "", |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
}); |
||||||
|
|
||||||
|
// UI state
|
||||||
|
isStarting = $state(false); |
||||||
|
private initialized = false; |
||||||
|
|
||||||
|
constructor() { |
||||||
|
// Initialize rounds with default names (will be set properly on load)
|
||||||
|
this.rounds = [ |
||||||
|
createRound("Villak", 1, DEFAULT_SETTINGS), |
||||||
|
createRound("Topeltvillak", 2, DEFAULT_SETTINGS), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Initialization & Persistence
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Load saved data from localStorage |
||||||
|
* Call this from onMount in the component |
||||||
|
*/ |
||||||
|
load() { |
||||||
|
if (this.initialized) return; |
||||||
|
this.initialized = true; |
||||||
|
|
||||||
|
const saved = storage.get<GameData>(STORAGE_KEYS.EDITOR_AUTOSAVE); |
||||||
|
if (saved) { |
||||||
|
const result = validateGameData(saved); |
||||||
|
if (result.success) { |
||||||
|
const data = result.data; |
||||||
|
this.gameName = data.name || ""; |
||||||
|
this.settings = { |
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
...data.settings, |
||||||
|
}; |
||||||
|
this.teams = data.teams; |
||||||
|
this.rounds = data.rounds; |
||||||
|
this.finalRound = data.finalRound || { |
||||||
|
category: "", |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Save current state to localStorage |
||||||
|
*/ |
||||||
|
save() { |
||||||
|
const data: GameData = { |
||||||
|
name: this.gameName, |
||||||
|
settings: $state.snapshot(this.settings), |
||||||
|
teams: $state.snapshot(this.teams), |
||||||
|
rounds: $state.snapshot(this.rounds), |
||||||
|
finalRound: $state.snapshot(this.finalRound), |
||||||
|
}; |
||||||
|
storage.set(STORAGE_KEYS.EDITOR_AUTOSAVE, data); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear saved data from localStorage |
||||||
|
*/ |
||||||
|
clearSaved() { |
||||||
|
storage.remove(STORAGE_KEYS.EDITOR_AUTOSAVE); |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Validate game configuration |
||||||
|
* @returns Error message string or null if valid |
||||||
|
*/ |
||||||
|
validateGame(): string | null { |
||||||
|
if (this.teams.length < 2) { |
||||||
|
return "At least 2 players required"; |
||||||
|
} |
||||||
|
|
||||||
|
for (let i = 0; i < this.rounds.length; i++) { |
||||||
|
const hasQuestions = this.rounds[i].categories.some((cat) => |
||||||
|
cat.questions.some((q) => q.question.trim()) |
||||||
|
); |
||||||
|
if (!hasQuestions) { |
||||||
|
return `Round ${i + 1} needs at least one question`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (this.settings.enableFinalRound && !this.finalRound.question.trim()) { |
||||||
|
return "Final round question is required"; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Game Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Start the game - validates, initializes game session, navigates to play |
||||||
|
* @returns Object with success status and optional error message |
||||||
|
*/ |
||||||
|
async startGame(): Promise<{ success: boolean; error?: string }> { |
||||||
|
const error = this.validateGame(); |
||||||
|
if (error) { |
||||||
|
return { success: false, error }; |
||||||
|
} |
||||||
|
|
||||||
|
this.isStarting = true; |
||||||
|
try { |
||||||
|
gameSession.startGame({ |
||||||
|
name: this.gameName, |
||||||
|
settings: $state.snapshot(this.settings), |
||||||
|
teams: $state.snapshot(this.teams), |
||||||
|
rounds: $state.snapshot(this.rounds), |
||||||
|
finalRound: this.settings.enableFinalRound |
||||||
|
? $state.snapshot(this.finalRound) |
||||||
|
: null, |
||||||
|
}); |
||||||
|
gameSession.openProjector(); |
||||||
|
await goto("/kuldvillak/play"); |
||||||
|
return { success: true }; |
||||||
|
} catch (err) { |
||||||
|
this.isStarting = false; |
||||||
|
return { success: false, error: String(err) }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Export game to JSON file for download |
||||||
|
*/ |
||||||
|
exportGame(): { blob: Blob; filename: string } { |
||||||
|
const game = { |
||||||
|
name: this.gameName, |
||||||
|
settings: $state.snapshot(this.settings), |
||||||
|
teams: $state.snapshot(this.teams), |
||||||
|
rounds: $state.snapshot(this.rounds), |
||||||
|
finalRound: $state.snapshot(this.finalRound), |
||||||
|
}; |
||||||
|
const blob = new Blob([JSON.stringify(game, null, 2)], { |
||||||
|
type: "application/json", |
||||||
|
}); |
||||||
|
const filename = `${this.gameName.replace(/\s+/g, "_") || "game"}.json`; |
||||||
|
return { blob, filename }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Import game from JSON data |
||||||
|
* @returns true if successful, error message if failed |
||||||
|
*/ |
||||||
|
importGame(data: unknown): true | string { |
||||||
|
const result = validateGameData(data); |
||||||
|
if (!result.success) { |
||||||
|
return result.error; |
||||||
|
} |
||||||
|
|
||||||
|
const game = result.data; |
||||||
|
this.gameName = game.name || "Loaded Game"; |
||||||
|
this.settings = { |
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
...game.settings, |
||||||
|
}; |
||||||
|
this.teams = game.teams; |
||||||
|
this.rounds = game.rounds; |
||||||
|
this.finalRound = game.finalRound || { |
||||||
|
category: "", |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
}; |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Reset to default state |
||||||
|
*/ |
||||||
|
reset(round1Name: string, round2Name: string) { |
||||||
|
this.settings = { |
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
defaultTimerSeconds: 5, |
||||||
|
answerRevealSeconds: 5, |
||||||
|
}; |
||||||
|
this.teams = [ |
||||||
|
{ id: generateId(), name: "Mängija 1", score: 0 }, |
||||||
|
{ id: generateId(), name: "Mängija 2", score: 0 }, |
||||||
|
]; |
||||||
|
this.rounds = [ |
||||||
|
createRound(round1Name, 1, DEFAULT_SETTINGS), |
||||||
|
createRound(round2Name, 2, DEFAULT_SETTINGS), |
||||||
|
]; |
||||||
|
this.finalRound = { category: "", question: "", answer: "" }; |
||||||
|
this.gameName = ""; |
||||||
|
this.clearSaved(); |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Team Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
addTeam() { |
||||||
|
if (this.teams.length >= 6) return; |
||||||
|
this.teams = [ |
||||||
|
...this.teams, |
||||||
|
{ id: generateId(), name: `Mängija ${this.teams.length + 1}`, score: 0 }, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
removeTeam(id: string) { |
||||||
|
if (this.teams.length <= 2) return; |
||||||
|
this.teams = this.teams.filter((t) => t.id !== id); |
||||||
|
} |
||||||
|
|
||||||
|
updateTeamName(id: string, name: string) { |
||||||
|
const team = this.teams.find((t) => t.id === id); |
||||||
|
if (team) { |
||||||
|
team.name = name; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Round Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
setRoundCount(count: 1 | 2, round2Name: string) { |
||||||
|
this.settings.numberOfRounds = count; |
||||||
|
if (count === 1 && this.rounds.length > 1) { |
||||||
|
this.rounds = [this.rounds[0]]; |
||||||
|
this.settings.dailyDoublesPerRound = [this.settings.dailyDoublesPerRound[0]]; |
||||||
|
} else if (count === 2 && this.rounds.length === 1) { |
||||||
|
this.rounds = [...this.rounds, createRound(round2Name, 2, this.settings)]; |
||||||
|
this.settings.dailyDoublesPerRound = [ |
||||||
|
this.settings.dailyDoublesPerRound[0], |
||||||
|
2, |
||||||
|
]; |
||||||
|
} |
||||||
|
this.updateQuestionPoints(); |
||||||
|
} |
||||||
|
|
||||||
|
getPointsForRound(preset: PointValuePreset, roundIndex: number): number[] { |
||||||
|
const base = [10, 20, 30, 40, 50]; |
||||||
|
const multiplier = roundIndex + 1; |
||||||
|
if (preset === "round1") return base.map((v) => v * multiplier); |
||||||
|
return this.settings.pointValues.map((v) => v * multiplier); |
||||||
|
} |
||||||
|
|
||||||
|
updatePreset(preset: PointValuePreset) { |
||||||
|
this.settings.pointValuePreset = preset; |
||||||
|
if (preset === "round1") { |
||||||
|
this.settings.pointValues = [10, 20, 30, 40, 50]; |
||||||
|
} |
||||||
|
this.updateQuestionPoints(); |
||||||
|
} |
||||||
|
|
||||||
|
updateQuestionPoints() { |
||||||
|
this.rounds.forEach((round, ri) => { |
||||||
|
const points = this.getPointsForRound(this.settings.pointValuePreset, ri); |
||||||
|
round.categories.forEach((cat) => |
||||||
|
cat.questions.forEach((q, i) => { |
||||||
|
q.points = points[i] ?? q.points; |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
this.rounds = [...this.rounds]; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Category Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
updateCategoryName(roundIndex: number, catIndex: number, name: string) { |
||||||
|
if (this.rounds[roundIndex]?.categories[catIndex]) { |
||||||
|
this.rounds[roundIndex].categories[catIndex].name = name; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Question Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
getQuestion(roundIndex: number, catIndex: number, qIndex: number): Question | null { |
||||||
|
return this.rounds[roundIndex]?.categories[catIndex]?.questions[qIndex] ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
updateQuestion( |
||||||
|
roundIndex: number, |
||||||
|
catIndex: number, |
||||||
|
qIndex: number, |
||||||
|
updates: Partial<Pick<Question, "question" | "answer" | "imageUrl" | "isDailyDouble">> |
||||||
|
) { |
||||||
|
const q = this.getQuestion(roundIndex, catIndex, qIndex); |
||||||
|
if (q) { |
||||||
|
Object.assign(q, updates); |
||||||
|
this.rounds = [...this.rounds]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
countDailyDoubles(roundIndex: number): number { |
||||||
|
return this.rounds[roundIndex]?.categories.reduce( |
||||||
|
(sum, cat) => sum + cat.questions.filter((q) => q.isDailyDouble).length, |
||||||
|
0 |
||||||
|
) ?? 0; |
||||||
|
} |
||||||
|
|
||||||
|
toggleDailyDouble(roundIndex: number, catIndex: number, qIndex: number): boolean { |
||||||
|
const q = this.getQuestion(roundIndex, catIndex, qIndex); |
||||||
|
if (!q) return false; |
||||||
|
|
||||||
|
const maxDD = this.settings.dailyDoublesPerRound[roundIndex] ?? 1; |
||||||
|
const currentCount = this.countDailyDoubles(roundIndex); |
||||||
|
|
||||||
|
if (q.isDailyDouble) { |
||||||
|
q.isDailyDouble = false; |
||||||
|
this.rounds = [...this.rounds]; |
||||||
|
return true; |
||||||
|
} else if (currentCount < maxDD) { |
||||||
|
q.isDailyDouble = true; |
||||||
|
this.rounds = [...this.rounds]; |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Final Round Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
updateFinalRound(updates: Partial<FinalRound>) { |
||||||
|
this.finalRound = { ...this.finalRound, ...updates }; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Derived State
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
get canAddTeam(): boolean { |
||||||
|
return this.teams.length < 6; |
||||||
|
} |
||||||
|
|
||||||
|
get canRemoveTeam(): boolean { |
||||||
|
return this.teams.length > 2; |
||||||
|
} |
||||||
|
|
||||||
|
get totalQuestions(): number { |
||||||
|
return this.rounds.reduce( |
||||||
|
(total, round) => |
||||||
|
total + round.categories.reduce( |
||||||
|
(catTotal, cat) => catTotal + cat.questions.length, |
||||||
|
0 |
||||||
|
), |
||||||
|
0 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
get filledQuestions(): number { |
||||||
|
return this.rounds.reduce( |
||||||
|
(total, round) => |
||||||
|
total + round.categories.reduce( |
||||||
|
(catTotal, cat) => |
||||||
|
catTotal + cat.questions.filter((q) => q.question.trim()).length, |
||||||
|
0 |
||||||
|
), |
||||||
|
0 |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const editorStore = new EditorStore(); |
||||||
@ -0,0 +1,591 @@ |
|||||||
|
/** |
||||||
|
* EditorStore Unit Tests |
||||||
|
*
|
||||||
|
* Testing approach: Since EditorStore uses Svelte 5 runes ($state) which require |
||||||
|
* Svelte compilation, we test the logic by: |
||||||
|
* 1. Extracting and testing pure validation/helper functions |
||||||
|
* 2. Mocking localStorage for persistence tests |
||||||
|
* 3. Testing the store's public API through a mock implementation |
||||||
|
*/ |
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
||||||
|
import type { GameSettings, Team, Round, FinalRound, Question, Category } from '$lib/types/kuldvillak'; |
||||||
|
import { DEFAULT_SETTINGS } from '$lib/types/kuldvillak'; |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Test Helpers - Replicate pure functions from store
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function generateId(): string { |
||||||
|
return crypto.randomUUID(); |
||||||
|
} |
||||||
|
|
||||||
|
function createQuestion(points: number): Question { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
question: "", |
||||||
|
answer: "", |
||||||
|
points, |
||||||
|
isDailyDouble: false, |
||||||
|
isRevealed: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createCategory(settings: GameSettings, multiplier: number = 1): Category { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
name: "", |
||||||
|
questions: settings.pointValues.map((p) => createQuestion(p * multiplier)), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createRound(name: string, multiplier: number, settings: GameSettings): Round { |
||||||
|
return { |
||||||
|
id: generateId(), |
||||||
|
name, |
||||||
|
categories: Array.from({ length: settings.categoriesPerRound }, () => |
||||||
|
createCategory(settings, multiplier) |
||||||
|
), |
||||||
|
pointMultiplier: multiplier, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Validation function extracted for testing
|
||||||
|
function validateGame( |
||||||
|
teams: Team[], |
||||||
|
rounds: Round[], |
||||||
|
settings: GameSettings, |
||||||
|
finalRound: FinalRound |
||||||
|
): string | null { |
||||||
|
if (teams.length < 2) { |
||||||
|
return "At least 2 players required"; |
||||||
|
} |
||||||
|
|
||||||
|
for (let i = 0; i < rounds.length; i++) { |
||||||
|
const hasQuestions = rounds[i].categories.some((cat) => |
||||||
|
cat.questions.some((q) => q.question.trim()) |
||||||
|
); |
||||||
|
if (!hasQuestions) { |
||||||
|
return `Round ${i + 1} needs at least one question`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (settings.enableFinalRound && !finalRound.question.trim()) { |
||||||
|
return "Final round question is required"; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Count daily doubles helper
|
||||||
|
function countDailyDoubles(rounds: Round[], roundIndex: number): number { |
||||||
|
return rounds[roundIndex]?.categories.reduce( |
||||||
|
(sum, cat) => sum + cat.questions.filter((q) => q.isDailyDouble).length, |
||||||
|
0 |
||||||
|
) ?? 0; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mock localStorage
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const AUTOSAVE_KEY = "kuldvillak-editor-autosave"; |
||||||
|
|
||||||
|
function createMockLocalStorage() { |
||||||
|
let store: Record<string, string> = {}; |
||||||
|
return { |
||||||
|
getItem: vi.fn((key: string) => store[key] ?? null), |
||||||
|
setItem: vi.fn((key: string, value: string) => { store[key] = value; }), |
||||||
|
removeItem: vi.fn((key: string) => { delete store[key]; }), |
||||||
|
clear: vi.fn(() => { store = {}; }), |
||||||
|
get _store() { return store; }, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('EditorStore Logic', () => { |
||||||
|
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
mockLocalStorage = createMockLocalStorage(); |
||||||
|
vi.stubGlobal('localStorage', mockLocalStorage); |
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(() => { |
||||||
|
vi.unstubAllGlobals(); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Helper Function Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('createQuestion', () => { |
||||||
|
it('should create a question with correct structure', () => { |
||||||
|
const q = createQuestion(100); |
||||||
|
expect(q.points).toBe(100); |
||||||
|
expect(q.question).toBe(""); |
||||||
|
expect(q.answer).toBe(""); |
||||||
|
expect(q.isDailyDouble).toBe(false); |
||||||
|
expect(q.isRevealed).toBe(false); |
||||||
|
expect(q.id).toBeDefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should generate unique IDs', () => { |
||||||
|
const q1 = createQuestion(100); |
||||||
|
const q2 = createQuestion(100); |
||||||
|
expect(q1.id).not.toBe(q2.id); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('createCategory', () => { |
||||||
|
it('should create category with correct number of questions', () => { |
||||||
|
const cat = createCategory(DEFAULT_SETTINGS, 1); |
||||||
|
expect(cat.questions.length).toBe(DEFAULT_SETTINGS.pointValues.length); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should apply multiplier to point values', () => { |
||||||
|
const cat = createCategory(DEFAULT_SETTINGS, 2); |
||||||
|
const expectedPoints = DEFAULT_SETTINGS.pointValues.map(p => p * 2); |
||||||
|
cat.questions.forEach((q, i) => { |
||||||
|
expect(q.points).toBe(expectedPoints[i]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('createRound', () => { |
||||||
|
it('should create round with correct structure', () => { |
||||||
|
const round = createRound("Test Round", 1, DEFAULT_SETTINGS); |
||||||
|
expect(round.name).toBe("Test Round"); |
||||||
|
expect(round.pointMultiplier).toBe(1); |
||||||
|
expect(round.categories.length).toBe(DEFAULT_SETTINGS.categoriesPerRound); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should apply multiplier to all questions', () => { |
||||||
|
const round = createRound("Double", 2, DEFAULT_SETTINGS); |
||||||
|
round.categories.forEach(cat => { |
||||||
|
cat.questions.forEach((q, i) => { |
||||||
|
expect(q.points).toBe(DEFAULT_SETTINGS.pointValues[i] * 2); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Validation Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('validateGame', () => { |
||||||
|
let validTeams: Team[]; |
||||||
|
let validRounds: Round[]; |
||||||
|
let validSettings: GameSettings; |
||||||
|
let validFinalRound: FinalRound; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
validTeams = [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
]; |
||||||
|
validRounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)]; |
||||||
|
// Fill at least one question
|
||||||
|
validRounds[0].categories[0].questions[0].question = "Test question?"; |
||||||
|
validSettings = { ...DEFAULT_SETTINGS, enableFinalRound: false }; |
||||||
|
validFinalRound = { category: "", question: "", answer: "" }; |
||||||
|
}); |
||||||
|
|
||||||
|
it('should pass with valid minimal configuration', () => { |
||||||
|
const result = validateGame(validTeams, validRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBeNull(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fail with fewer than 2 teams', () => { |
||||||
|
const result = validateGame([validTeams[0]], validRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBe("At least 2 players required"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fail with 0 teams', () => { |
||||||
|
const result = validateGame([], validRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBe("At least 2 players required"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fail when round has no questions', () => { |
||||||
|
const emptyRounds = [createRound("Empty", 1, DEFAULT_SETTINGS)]; |
||||||
|
const result = validateGame(validTeams, emptyRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBe("Round 1 needs at least one question"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fail when second round has no questions', () => { |
||||||
|
const twoRounds = [ |
||||||
|
validRounds[0], |
||||||
|
createRound("Round 2", 2, DEFAULT_SETTINGS), // No questions filled
|
||||||
|
]; |
||||||
|
const result = validateGame(validTeams, twoRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBe("Round 2 needs at least one question"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fail when final round enabled but no question', () => { |
||||||
|
const settingsWithFinal = { ...validSettings, enableFinalRound: true }; |
||||||
|
const result = validateGame(validTeams, validRounds, settingsWithFinal, validFinalRound); |
||||||
|
expect(result).toBe("Final round question is required"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should pass when final round enabled and has question', () => { |
||||||
|
const settingsWithFinal = { ...validSettings, enableFinalRound: true }; |
||||||
|
const finalWithQuestion = { category: "History", question: "What year?", answer: "1990" }; |
||||||
|
const result = validateGame(validTeams, validRounds, settingsWithFinal, finalWithQuestion); |
||||||
|
expect(result).toBeNull(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should pass with whitespace-only question as empty', () => { |
||||||
|
validRounds[0].categories[0].questions[0].question = " "; |
||||||
|
const result = validateGame(validTeams, validRounds, validSettings, validFinalRound); |
||||||
|
expect(result).toBe("Round 1 needs at least one question"); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Team Management Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Team Management Logic', () => { |
||||||
|
it('canAddTeam should be true when under 6 teams', () => { |
||||||
|
const teams: Team[] = [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
]; |
||||||
|
expect(teams.length < 6).toBe(true); |
||||||
|
}); |
||||||
|
|
||||||
|
it('canAddTeam should be false when at 6 teams', () => { |
||||||
|
const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ |
||||||
|
id: String(i), |
||||||
|
name: `Team ${i + 1}`, |
||||||
|
score: 0, |
||||||
|
})); |
||||||
|
expect(teams.length < 6).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('canRemoveTeam should be true when over 2 teams', () => { |
||||||
|
const teams: Team[] = [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
{ id: '3', name: 'Team 3', score: 0 }, |
||||||
|
]; |
||||||
|
expect(teams.length > 2).toBe(true); |
||||||
|
}); |
||||||
|
|
||||||
|
it('canRemoveTeam should be false when at 2 teams', () => { |
||||||
|
const teams: Team[] = [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
]; |
||||||
|
expect(teams.length > 2).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('addTeam should generate unique ID', () => { |
||||||
|
const teams: Team[] = []; |
||||||
|
const newTeam = { id: generateId(), name: 'New Team', score: 0 }; |
||||||
|
teams.push(newTeam); |
||||||
|
expect(newTeam.id).toBeDefined(); |
||||||
|
expect(typeof newTeam.id).toBe('string'); |
||||||
|
expect(newTeam.id.length).toBeGreaterThan(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('removeTeam should filter by ID', () => { |
||||||
|
const teams: Team[] = [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
{ id: '3', name: 'Team 3', score: 0 }, |
||||||
|
]; |
||||||
|
const filtered = teams.filter(t => t.id !== '2'); |
||||||
|
expect(filtered.length).toBe(2); |
||||||
|
expect(filtered.find(t => t.id === '2')).toBeUndefined(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Daily Double Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Daily Double Logic', () => { |
||||||
|
it('countDailyDoubles should return 0 for round with no DDs', () => { |
||||||
|
const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)]; |
||||||
|
expect(countDailyDoubles(rounds, 0)).toBe(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('countDailyDoubles should count correctly', () => { |
||||||
|
const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)]; |
||||||
|
rounds[0].categories[0].questions[0].isDailyDouble = true; |
||||||
|
rounds[0].categories[1].questions[2].isDailyDouble = true; |
||||||
|
expect(countDailyDoubles(rounds, 0)).toBe(2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should respect maxDD limit logic', () => { |
||||||
|
const maxDD = 2; |
||||||
|
const currentDD = 2; |
||||||
|
const canAddMore = currentDD < maxDD; |
||||||
|
expect(canAddMore).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should allow toggle when under limit', () => { |
||||||
|
const maxDD = 2; |
||||||
|
const currentDD = 1; |
||||||
|
const canAddMore = currentDD < maxDD; |
||||||
|
expect(canAddMore).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Persistence Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Persistence Logic', () => { |
||||||
|
it('save should serialize data to localStorage', () => { |
||||||
|
const data = { |
||||||
|
name: "Test Game", |
||||||
|
settings: DEFAULT_SETTINGS, |
||||||
|
teams: [{ id: '1', name: 'Team 1', score: 0 }], |
||||||
|
rounds: [], |
||||||
|
finalRound: { category: "", question: "", answer: "" }, |
||||||
|
}; |
||||||
|
|
||||||
|
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); |
||||||
|
|
||||||
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith( |
||||||
|
AUTOSAVE_KEY, |
||||||
|
expect.any(String) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('load should parse valid localStorage data', () => { |
||||||
|
const savedData = { |
||||||
|
name: "Saved Game", |
||||||
|
settings: { ...DEFAULT_SETTINGS, defaultTimerSeconds: 10 }, |
||||||
|
teams: [ |
||||||
|
{ id: '1', name: 'Player 1', score: 100 }, |
||||||
|
{ id: '2', name: 'Player 2', score: 200 }, |
||||||
|
], |
||||||
|
rounds: [], |
||||||
|
finalRound: { category: "History", question: "Q?", answer: "A" }, |
||||||
|
}; |
||||||
|
|
||||||
|
mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(savedData); |
||||||
|
const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); |
||||||
|
|
||||||
|
expect(loaded.name).toBe("Saved Game"); |
||||||
|
expect(loaded.settings.defaultTimerSeconds).toBe(10); |
||||||
|
expect(loaded.teams.length).toBe(2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('load should handle invalid JSON gracefully', () => { |
||||||
|
mockLocalStorage._store[AUTOSAVE_KEY] = "not valid json {{{"; |
||||||
|
|
||||||
|
let error: Error | null = null; |
||||||
|
try { |
||||||
|
JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); |
||||||
|
} catch (e) { |
||||||
|
error = e as Error; |
||||||
|
} |
||||||
|
|
||||||
|
expect(error).not.toBeNull(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('load should handle missing data gracefully', () => { |
||||||
|
const result = localStorage.getItem(AUTOSAVE_KEY); |
||||||
|
expect(result).toBeNull(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('clearSaved should remove from localStorage', () => { |
||||||
|
mockLocalStorage._store[AUTOSAVE_KEY] = "some data"; |
||||||
|
localStorage.removeItem(AUTOSAVE_KEY); |
||||||
|
|
||||||
|
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(AUTOSAVE_KEY); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle legacy teamColors property', () => { |
||||||
|
const legacyData = { |
||||||
|
name: "Legacy Game", |
||||||
|
settings: { |
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
teamColors: ['red', 'blue'], // Legacy property
|
||||||
|
}, |
||||||
|
teams: [], |
||||||
|
rounds: [], |
||||||
|
finalRound: { category: "", question: "", answer: "" }, |
||||||
|
}; |
||||||
|
|
||||||
|
mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(legacyData); |
||||||
|
const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); |
||||||
|
|
||||||
|
// Should be able to destructure and remove teamColors
|
||||||
|
const { teamColors, ...cleanSettings } = loaded.settings; |
||||||
|
expect(teamColors).toEqual(['red', 'blue']); |
||||||
|
expect(cleanSettings.teamColors).toBeUndefined(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Export/Import Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Export/Import Logic', () => { |
||||||
|
it('exportGame should create valid JSON structure', () => { |
||||||
|
const gameData = { |
||||||
|
name: "Export Test", |
||||||
|
settings: DEFAULT_SETTINGS, |
||||||
|
teams: [ |
||||||
|
{ id: '1', name: 'Team 1', score: 0 }, |
||||||
|
{ id: '2', name: 'Team 2', score: 0 }, |
||||||
|
], |
||||||
|
rounds: [createRound("Round 1", 1, DEFAULT_SETTINGS)], |
||||||
|
finalRound: { category: "Cat", question: "Q?", answer: "A" }, |
||||||
|
}; |
||||||
|
|
||||||
|
const json = JSON.stringify(gameData, null, 2); |
||||||
|
const parsed = JSON.parse(json); |
||||||
|
|
||||||
|
expect(parsed.name).toBe("Export Test"); |
||||||
|
expect(parsed.teams.length).toBe(2); |
||||||
|
expect(parsed.rounds.length).toBe(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('exportGame should create Blob with correct type', () => { |
||||||
|
const json = JSON.stringify({ test: true }); |
||||||
|
const blob = new Blob([json], { type: "application/json" }); |
||||||
|
|
||||||
|
expect(blob.type).toBe("application/json"); |
||||||
|
expect(blob.size).toBeGreaterThan(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('importGame should validate required fields', () => { |
||||||
|
const validData = { |
||||||
|
settings: DEFAULT_SETTINGS, |
||||||
|
teams: [], |
||||||
|
rounds: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const invalidData1 = { teams: [], rounds: [] }; // Missing settings
|
||||||
|
const invalidData2 = { settings: DEFAULT_SETTINGS, rounds: [] }; // Missing teams
|
||||||
|
const invalidData3 = { settings: DEFAULT_SETTINGS, teams: [] }; // Missing rounds
|
||||||
|
|
||||||
|
const hasRequired = (data: Record<string, unknown>) => |
||||||
|
!!(data.settings && data.teams && data.rounds); |
||||||
|
|
||||||
|
expect(hasRequired(validData)).toBe(true); |
||||||
|
expect(hasRequired(invalidData1)).toBe(false); |
||||||
|
expect(hasRequired(invalidData2)).toBe(false); |
||||||
|
expect(hasRequired(invalidData3)).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should generate sanitized filename', () => { |
||||||
|
const gameName = "My Test Game 2024"; |
||||||
|
const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`; |
||||||
|
expect(filename).toBe("My_Test_Game_2024.json"); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should use default filename when name is empty', () => { |
||||||
|
const gameName = ""; |
||||||
|
const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`; |
||||||
|
expect(filename).toBe("game.json"); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Round Management Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Round Management Logic', () => { |
||||||
|
it('setRoundCount should reduce to 1 round', () => { |
||||||
|
let rounds = [ |
||||||
|
createRound("Round 1", 1, DEFAULT_SETTINGS), |
||||||
|
createRound("Round 2", 2, DEFAULT_SETTINGS), |
||||||
|
]; |
||||||
|
|
||||||
|
if (rounds.length > 1) { |
||||||
|
rounds = [rounds[0]]; |
||||||
|
} |
||||||
|
|
||||||
|
expect(rounds.length).toBe(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('setRoundCount should add second round', () => { |
||||||
|
let rounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)]; |
||||||
|
|
||||||
|
if (rounds.length === 1) { |
||||||
|
rounds = [...rounds, createRound("Round 2", 2, DEFAULT_SETTINGS)]; |
||||||
|
} |
||||||
|
|
||||||
|
expect(rounds.length).toBe(2); |
||||||
|
expect(rounds[1].pointMultiplier).toBe(2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('getPointsForRound should apply multiplier', () => { |
||||||
|
const base = [10, 20, 30, 40, 50]; |
||||||
|
const round1Points = base.map(v => v * 1); |
||||||
|
const round2Points = base.map(v => v * 2); |
||||||
|
|
||||||
|
expect(round1Points).toEqual([10, 20, 30, 40, 50]); |
||||||
|
expect(round2Points).toEqual([20, 40, 60, 80, 100]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('updatePreset should reset to default values', () => { |
||||||
|
let settings = { ...DEFAULT_SETTINGS, pointValues: [5, 10, 15, 20, 25] }; |
||||||
|
|
||||||
|
if (settings.pointValuePreset === "round1") { |
||||||
|
settings.pointValues = [10, 20, 30, 40, 50]; |
||||||
|
} |
||||||
|
|
||||||
|
// Simulate preset change
|
||||||
|
settings.pointValuePreset = "round1"; |
||||||
|
settings.pointValues = [10, 20, 30, 40, 50]; |
||||||
|
|
||||||
|
expect(settings.pointValues).toEqual([10, 20, 30, 40, 50]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Derived State Tests
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
describe('Derived State Logic', () => { |
||||||
|
it('totalQuestions should count all questions', () => { |
||||||
|
const rounds = [ |
||||||
|
createRound("R1", 1, DEFAULT_SETTINGS), |
||||||
|
createRound("R2", 2, DEFAULT_SETTINGS), |
||||||
|
]; |
||||||
|
|
||||||
|
const total = rounds.reduce( |
||||||
|
(total, round) => |
||||||
|
total + round.categories.reduce( |
||||||
|
(catTotal, cat) => catTotal + cat.questions.length, |
||||||
|
0 |
||||||
|
), |
||||||
|
0 |
||||||
|
); |
||||||
|
|
||||||
|
const expectedPerRound = DEFAULT_SETTINGS.categoriesPerRound * DEFAULT_SETTINGS.questionsPerCategory; |
||||||
|
expect(total).toBe(expectedPerRound * 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('filledQuestions should count only non-empty', () => { |
||||||
|
const rounds = [createRound("R1", 1, DEFAULT_SETTINGS)]; |
||||||
|
rounds[0].categories[0].questions[0].question = "Filled"; |
||||||
|
rounds[0].categories[0].questions[1].question = "Also filled"; |
||||||
|
rounds[0].categories[1].questions[0].question = "Third"; |
||||||
|
|
||||||
|
const filled = rounds.reduce( |
||||||
|
(total, round) => |
||||||
|
total + round.categories.reduce( |
||||||
|
(catTotal, cat) => |
||||||
|
catTotal + cat.questions.filter((q) => q.question.trim()).length, |
||||||
|
0 |
||||||
|
), |
||||||
|
0 |
||||||
|
); |
||||||
|
|
||||||
|
expect(filled).toBe(3); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
/** |
||||||
|
* GameSessionStore Tests |
||||||
|
*
|
||||||
|
* NOTE: Full store testing requires Svelte 5 compilation support in Vitest. |
||||||
|
* The GameSessionStore uses Svelte 5 runes ($state) which aren't available |
||||||
|
* in plain TypeScript test environments. |
||||||
|
*
|
||||||
|
* To enable full testing, you would need to: |
||||||
|
* 1. Add @sveltejs/vite-plugin-svelte to vitest.config.ts |
||||||
|
* 2. Configure proper Svelte preprocessing |
||||||
|
*
|
||||||
|
* For now, the store is tested indirectly through: |
||||||
|
* - Integration tests via the browser |
||||||
|
* - The persistence layer tests (which test save/load functionality) |
||||||
|
*
|
||||||
|
* The constants and types exported from this module are tested in kuldvillak.test.ts |
||||||
|
*/ |
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
|
||||||
|
describe('GameSessionStore Constants', () => { |
||||||
|
it('should document that full store tests require Svelte compilation', () => { |
||||||
|
// This is a placeholder test documenting the limitation
|
||||||
|
expect(true).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,178 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { |
||||||
|
hexToRgb, |
||||||
|
getLuminance, |
||||||
|
getContrast, |
||||||
|
hexToHsl, |
||||||
|
hslToHex, |
||||||
|
generateHslColor, |
||||||
|
DEFAULT_THEME, |
||||||
|
} from '$lib/utils/color'; |
||||||
|
|
||||||
|
describe('ColorUtils', () => { |
||||||
|
describe('hexToRgb', () => { |
||||||
|
it('should convert white hex to RGB', () => { |
||||||
|
expect(hexToRgb('#FFFFFF')).toEqual([255, 255, 255]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert black hex to RGB', () => { |
||||||
|
expect(hexToRgb('#000000')).toEqual([0, 0, 0]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert red hex to RGB', () => { |
||||||
|
expect(hexToRgb('#FF0000')).toEqual([255, 0, 0]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert green hex to RGB', () => { |
||||||
|
expect(hexToRgb('#00FF00')).toEqual([0, 255, 0]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert blue hex to RGB', () => { |
||||||
|
expect(hexToRgb('#0000FF')).toEqual([0, 0, 255]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle hex without hash', () => { |
||||||
|
expect(hexToRgb('FF0000')).toEqual([255, 0, 0]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('getLuminance', () => { |
||||||
|
it('should return 1 for white', () => { |
||||||
|
expect(getLuminance(255, 255, 255)).toBeCloseTo(1, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return 0 for black', () => { |
||||||
|
expect(getLuminance(0, 0, 0)).toBeCloseTo(0, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return ~0.2126 for pure red', () => { |
||||||
|
expect(getLuminance(255, 0, 0)).toBeCloseTo(0.2126, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return ~0.7152 for pure green', () => { |
||||||
|
expect(getLuminance(0, 255, 0)).toBeCloseTo(0.7152, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return ~0.0722 for pure blue', () => { |
||||||
|
expect(getLuminance(0, 0, 255)).toBeCloseTo(0.0722, 2); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('getContrast', () => { |
||||||
|
it('should return 21 for black on white (max contrast)', () => { |
||||||
|
const contrast = getContrast('#FFFFFF', '#000000'); |
||||||
|
expect(contrast).toBeCloseTo(21, 0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return 1 for same colors (no contrast)', () => { |
||||||
|
const contrast = getContrast('#FF0000', '#FF0000'); |
||||||
|
expect(contrast).toBeCloseTo(1, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return same value regardless of order', () => { |
||||||
|
const contrast1 = getContrast('#FFFFFF', '#003B9B'); |
||||||
|
const contrast2 = getContrast('#003B9B', '#FFFFFF'); |
||||||
|
expect(contrast1).toBeCloseTo(contrast2, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should meet WCAG AA (4.5:1) for default theme text on primary', () => { |
||||||
|
const contrast = getContrast(DEFAULT_THEME.text, DEFAULT_THEME.primary); |
||||||
|
expect(contrast).toBeGreaterThanOrEqual(4.5); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('hexToHsl', () => { |
||||||
|
it('should convert red to HSL', () => { |
||||||
|
const [h, s, l] = hexToHsl('#FF0000'); |
||||||
|
expect(h).toBeCloseTo(0, 0); |
||||||
|
expect(s).toBeCloseTo(1, 2); |
||||||
|
expect(l).toBeCloseTo(0.5, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert green to HSL', () => { |
||||||
|
const [h, s, l] = hexToHsl('#00FF00'); |
||||||
|
expect(h).toBeCloseTo(120, 0); |
||||||
|
expect(s).toBeCloseTo(1, 2); |
||||||
|
expect(l).toBeCloseTo(0.5, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert blue to HSL', () => { |
||||||
|
const [h, s, l] = hexToHsl('#0000FF'); |
||||||
|
expect(h).toBeCloseTo(240, 0); |
||||||
|
expect(s).toBeCloseTo(1, 2); |
||||||
|
expect(l).toBeCloseTo(0.5, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert white to HSL with 0 saturation', () => { |
||||||
|
const [h, s, l] = hexToHsl('#FFFFFF'); |
||||||
|
expect(s).toBeCloseTo(0, 2); |
||||||
|
expect(l).toBeCloseTo(1, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert black to HSL with 0 saturation and lightness', () => { |
||||||
|
const [h, s, l] = hexToHsl('#000000'); |
||||||
|
expect(s).toBeCloseTo(0, 2); |
||||||
|
expect(l).toBeCloseTo(0, 2); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('hslToHex', () => { |
||||||
|
it('should convert red HSL to hex', () => { |
||||||
|
expect(hslToHex(0, 100, 50).toUpperCase()).toBe('#FF0000'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert green HSL to hex', () => { |
||||||
|
expect(hslToHex(120, 100, 50).toUpperCase()).toBe('#00FF00'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert blue HSL to hex', () => { |
||||||
|
expect(hslToHex(240, 100, 50).toUpperCase()).toBe('#0000FF'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert white HSL to hex', () => { |
||||||
|
expect(hslToHex(0, 0, 100).toUpperCase()).toBe('#FFFFFF'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should convert black HSL to hex', () => { |
||||||
|
expect(hslToHex(0, 0, 0).toUpperCase()).toBe('#000000'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('generateHslColor', () => { |
||||||
|
it('should generate valid hex color', () => { |
||||||
|
const color = generateHslColor(null, [50, 100], [30, 70]); |
||||||
|
expect(color).toMatch(/^#[0-9A-Fa-f]{6}$/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should generate color within specified hue range', () => { |
||||||
|
for (let i = 0; i < 10; i++) { |
||||||
|
const color = generateHslColor([0, 60], [50, 100], [30, 70]); |
||||||
|
const [h] = hexToHsl(color); |
||||||
|
expect(h).toBeGreaterThanOrEqual(-5); |
||||||
|
expect(h).toBeLessThanOrEqual(65); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('DEFAULT_THEME', () => { |
||||||
|
it('should have all required color properties', () => { |
||||||
|
expect(DEFAULT_THEME).toHaveProperty('primary'); |
||||||
|
expect(DEFAULT_THEME).toHaveProperty('secondary'); |
||||||
|
expect(DEFAULT_THEME).toHaveProperty('text'); |
||||||
|
expect(DEFAULT_THEME).toHaveProperty('background'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should have valid hex colors', () => { |
||||||
|
const hexRegex = /^#[0-9A-Fa-f]{6}$/; |
||||||
|
expect(DEFAULT_THEME.primary).toMatch(hexRegex); |
||||||
|
expect(DEFAULT_THEME.secondary).toMatch(hexRegex); |
||||||
|
expect(DEFAULT_THEME.text).toMatch(hexRegex); |
||||||
|
expect(DEFAULT_THEME.background).toMatch(hexRegex); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should have good contrast between text and background', () => { |
||||||
|
const contrast = getContrast(DEFAULT_THEME.text, DEFAULT_THEME.background); |
||||||
|
expect(contrast).toBeGreaterThanOrEqual(7); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
// Global toast notification store
|
||||||
|
import { browser } from '$app/environment'; |
||||||
|
|
||||||
|
export interface ToastNotification { |
||||||
|
id: string; |
||||||
|
message: string; |
||||||
|
type: 'error' | 'success' | 'info'; |
||||||
|
duration: number; |
||||||
|
} |
||||||
|
|
||||||
|
const DEFAULT_DURATION = 3000; |
||||||
|
|
||||||
|
class ToastStore { |
||||||
|
notifications = $state<ToastNotification[]>([]); |
||||||
|
|
||||||
|
private generateId(): string { |
||||||
|
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; |
||||||
|
} |
||||||
|
|
||||||
|
show(message: string, type: ToastNotification['type'] = 'info', duration = DEFAULT_DURATION) { |
||||||
|
if (!browser) return; |
||||||
|
|
||||||
|
const notification: ToastNotification = { |
||||||
|
id: this.generateId(), |
||||||
|
message, |
||||||
|
type, |
||||||
|
duration, |
||||||
|
}; |
||||||
|
|
||||||
|
this.notifications.push(notification); |
||||||
|
|
||||||
|
if (duration > 0) { |
||||||
|
setTimeout(() => { |
||||||
|
this.dismiss(notification.id); |
||||||
|
}, duration); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
error(message: string, duration = DEFAULT_DURATION) { |
||||||
|
this.show(message, 'error', duration); |
||||||
|
} |
||||||
|
|
||||||
|
success(message: string, duration = DEFAULT_DURATION) { |
||||||
|
this.show(message, 'success', duration); |
||||||
|
} |
||||||
|
|
||||||
|
info(message: string, duration = DEFAULT_DURATION) { |
||||||
|
this.show(message, 'info', duration); |
||||||
|
} |
||||||
|
|
||||||
|
dismiss(id: string) { |
||||||
|
this.notifications = this.notifications.filter(n => n.id !== id); |
||||||
|
} |
||||||
|
|
||||||
|
clear() { |
||||||
|
this.notifications = []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const toastStore = new ToastStore(); |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
// ============================================
|
||||||
|
// Buzzer System Type Definitions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type { GamePhase } from './kuldvillak'; |
||||||
|
|
||||||
|
/** Connected player in a buzzer session */ |
||||||
|
export interface BuzzerPlayer { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
score: number; |
||||||
|
connected: boolean; |
||||||
|
lockedOut: boolean; // True if player buzzed too early
|
||||||
|
} |
||||||
|
|
||||||
|
/** Current state of the buzzer */ |
||||||
|
export type BuzzerState = |
||||||
|
| 'disabled' // Question not active, can't buzz
|
||||||
|
| 'locked' // Player buzzed too early, locked out
|
||||||
|
| 'ready' // Timer active, can buzz
|
||||||
|
| 'buzzed'; // Someone has buzzed
|
||||||
|
|
||||||
|
/** Buzzer session state */ |
||||||
|
export interface BuzzerSessionState { |
||||||
|
roomCode: string; |
||||||
|
players: BuzzerPlayer[]; |
||||||
|
buzzerState: BuzzerState; |
||||||
|
currentAnswerer: string | null; // Player ID who buzzed first
|
||||||
|
buzzQueue: string[]; // First-come-first-served order
|
||||||
|
gamePhase: GamePhase; |
||||||
|
timerActive: boolean; |
||||||
|
|
||||||
|
// Question data (synced from main game)
|
||||||
|
currentQuestion: { |
||||||
|
text: string; |
||||||
|
answer: string; |
||||||
|
points: number; |
||||||
|
isDailyDouble: boolean; |
||||||
|
} | null; |
||||||
|
showAnswer: boolean; |
||||||
|
|
||||||
|
// Final round
|
||||||
|
finalCategory: string | null; |
||||||
|
finalWagers: Record<string, number>; |
||||||
|
finalAnswers: Record<string, string>; |
||||||
|
|
||||||
|
// Result feedback
|
||||||
|
lastResult: { |
||||||
|
playerId: string; |
||||||
|
correct: boolean; |
||||||
|
points: number; |
||||||
|
} | null; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WebSocket Event Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** Events sent from client to server */ |
||||||
|
export type ClientEvent = |
||||||
|
| { type: 'join'; roomCode: string; playerName: string } |
||||||
|
| { type: 'buzz'; timestamp: number } |
||||||
|
| { type: 'submit-wager'; wager: number } |
||||||
|
| { type: 'submit-answer'; answer: string } |
||||||
|
| { type: 'disconnect' }; |
||||||
|
|
||||||
|
/** Events sent from server to client */ |
||||||
|
export type ServerEvent = |
||||||
|
| { type: 'joined'; playerId: string; player: BuzzerPlayer; players: BuzzerPlayer[] } |
||||||
|
| { type: 'game-state'; state: Partial<BuzzerSessionState> } |
||||||
|
| { type: 'player-joined'; player: BuzzerPlayer } |
||||||
|
| { type: 'player-left'; playerId: string } |
||||||
|
| { type: 'buzzer-pressed'; playerId: string; playerName: string } |
||||||
|
| { type: 'buzzer-state-change'; state: BuzzerState; currentAnswerer: string | null } |
||||||
|
| { type: 'answer-result'; playerId: string; correct: boolean; points: number } |
||||||
|
| { type: 'phase-change'; phase: GamePhase } |
||||||
|
| { type: 'question-update'; question: BuzzerSessionState['currentQuestion']; showAnswer: boolean } |
||||||
|
| { type: 'timer-update'; active: boolean } |
||||||
|
| { type: 'score-update'; playerId: string; score: number } |
||||||
|
| { type: 'final-category'; category: string } |
||||||
|
| { type: 'wager-accepted'; playerId: string } |
||||||
|
| { type: 'answer-accepted'; playerId: string } |
||||||
|
| { type: 'game-over'; rankings: { playerId: string; name: string; score: number; rank: number }[] } |
||||||
|
| { type: 'game-ended' } |
||||||
|
| { type: 'error'; message: string }; |
||||||
|
|
||||||
|
/** Events sent from moderator to buzzer server */ |
||||||
|
export type ModeratorEvent = |
||||||
|
| { type: 'create-room'; roomCode: string } |
||||||
|
| { type: 'start-game' } |
||||||
|
| { type: 'phase-change'; phase: GamePhase } |
||||||
|
| { type: 'question-selected'; question: BuzzerSessionState['currentQuestion'] } |
||||||
|
| { type: 'timer-start' } |
||||||
|
| { type: 'timer-stop' } |
||||||
|
| { type: 'mark-correct'; playerId: string; points: number } |
||||||
|
| { type: 'mark-wrong'; playerId: string; points: number } |
||||||
|
| { type: 'show-answer' } |
||||||
|
| { type: 'return-to-board' } |
||||||
|
| { type: 'final-category'; category: string } |
||||||
|
| { type: 'reveal-final-question'; question: string } |
||||||
|
| { type: 'final-timer-start' } |
||||||
|
| { type: 'game-over'; rankings: { playerId: string; name: string; score: number; rank: number }[] } |
||||||
|
| { type: 'end-game' } |
||||||
|
| { type: 'kick-player'; playerId: string }; |
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Room Code Generation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** Generate a random 4-character room code */ |
||||||
|
export function generateRoomCode(): string { |
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // Removed I and O to avoid confusion
|
||||||
|
let code = ''; |
||||||
|
for (let i = 0; i < 4; i++) { |
||||||
|
code += chars.charAt(Math.floor(Math.random() * chars.length)); |
||||||
|
} |
||||||
|
return code; |
||||||
|
} |
||||||
|
|
||||||
|
/** Validate a room code format */ |
||||||
|
export function isValidRoomCode(code: string): boolean { |
||||||
|
return /^[A-Z]{4}$/.test(code.toUpperCase()); |
||||||
|
} |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
// ============================================
|
||||||
|
// WCAG & Color Utilities
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Convert Hex color to RGB array |
||||||
|
*/ |
||||||
|
export function hexToRgb(hex: string): [number, number, number] { |
||||||
|
const bigint = parseInt(hex.replace('#', ''), 16); |
||||||
|
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate Relative Luminance (WCAG 2.0 formula) |
||||||
|
*/ |
||||||
|
export function getLuminance(r: number, g: number, b: number): number { |
||||||
|
const a = [r, g, b].map((v) => { |
||||||
|
v /= 255; |
||||||
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); |
||||||
|
}); |
||||||
|
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate Contrast Ratio between two colors |
||||||
|
*/ |
||||||
|
export function getContrast(hex1: string, hex2: string): number { |
||||||
|
const rgb1 = hexToRgb(hex1); |
||||||
|
const rgb2 = hexToRgb(hex2); |
||||||
|
const l1 = getLuminance(rgb1[0], rgb1[1], rgb1[2]); |
||||||
|
const l2 = getLuminance(rgb2[0], rgb2[1], rgb2[2]); |
||||||
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert Hex to HSL |
||||||
|
* Returns [H in degrees, S as 0-1, L as 0-1] |
||||||
|
*/ |
||||||
|
export function hexToHsl(hex: string): [number, number, number] { |
||||||
|
const rgb = hexToRgb(hex); |
||||||
|
const r = rgb[0] / 255; |
||||||
|
const g = rgb[1] / 255; |
||||||
|
const b = rgb[2] / 255; |
||||||
|
const max = Math.max(r, g, b); |
||||||
|
const min = Math.min(r, g, b); |
||||||
|
let h = 0; |
||||||
|
let s = 0; |
||||||
|
const l = (max + min) / 2; |
||||||
|
|
||||||
|
if (max !== min) { |
||||||
|
const d = max - min; |
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
||||||
|
switch (max) { |
||||||
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break; |
||||||
|
case g: h = (b - r) / d + 2; break; |
||||||
|
case b: h = (r - g) / d + 4; break; |
||||||
|
} |
||||||
|
h /= 6; |
||||||
|
} |
||||||
|
return [h * 360, s, l]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert HSL to Hex |
||||||
|
* @param h Hue in degrees (0-360) |
||||||
|
* @param s Saturation as percentage (0-100) |
||||||
|
* @param l Lightness as percentage (0-100) |
||||||
|
*/ |
||||||
|
export function hslToHex(h: number, s: number, l: number): string { |
||||||
|
l /= 100; |
||||||
|
const a = s * Math.min(l, 1 - l) / 100; |
||||||
|
const f = (n: number) => { |
||||||
|
const k = (n + h / 30) % 12; |
||||||
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); |
||||||
|
return Math.round(255 * color).toString(16).padStart(2, '0'); |
||||||
|
}; |
||||||
|
return `#${f(0)}${f(8)}${f(4)}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a random color with controlled HSL values |
||||||
|
*/ |
||||||
|
export function generateHslColor( |
||||||
|
hueRange: [number, number] | null, |
||||||
|
satRange: [number, number], |
||||||
|
lumRange: [number, number] |
||||||
|
): string { |
||||||
|
const h = hueRange |
||||||
|
? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0] |
||||||
|
: Math.floor(Math.random() * 360); |
||||||
|
|
||||||
|
const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0]; |
||||||
|
const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0]; |
||||||
|
|
||||||
|
return hslToHex(h, s, l); |
||||||
|
} |
||||||
|
|
||||||
|
// Default theme colors
|
||||||
|
export const DEFAULT_THEME = { |
||||||
|
primary: "#003B9B", |
||||||
|
secondary: "#FFAB00", |
||||||
|
text: "#FFFFFF", |
||||||
|
background: "#000000", |
||||||
|
}; |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
// Focus trap utility for modals
|
||||||
|
export function trapFocus(node: HTMLElement) { |
||||||
|
const focusableSelectors = [ |
||||||
|
'button:not([disabled])', |
||||||
|
'input:not([disabled])', |
||||||
|
'select:not([disabled])', |
||||||
|
'textarea:not([disabled])', |
||||||
|
'a[href]', |
||||||
|
'[tabindex]:not([tabindex="-1"])', |
||||||
|
].join(', '); |
||||||
|
|
||||||
|
function getFocusableElements() { |
||||||
|
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors)); |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) { |
||||||
|
if (e.key !== 'Tab') return; |
||||||
|
|
||||||
|
const focusable = getFocusableElements(); |
||||||
|
if (focusable.length === 0) return; |
||||||
|
|
||||||
|
const first = focusable[0]; |
||||||
|
const last = focusable[focusable.length - 1]; |
||||||
|
|
||||||
|
if (e.shiftKey && document.activeElement === first) { |
||||||
|
e.preventDefault(); |
||||||
|
last.focus(); |
||||||
|
} else if (!e.shiftKey && document.activeElement === last) { |
||||||
|
e.preventDefault(); |
||||||
|
first.focus(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Focus first element on mount
|
||||||
|
const focusable = getFocusableElements(); |
||||||
|
if (focusable.length > 0) { |
||||||
|
focusable[0].focus(); |
||||||
|
} |
||||||
|
|
||||||
|
node.addEventListener('keydown', handleKeydown); |
||||||
|
|
||||||
|
return { |
||||||
|
destroy() { |
||||||
|
node.removeEventListener('keydown', handleKeydown); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,155 @@ |
|||||||
|
// ============================================
|
||||||
|
// JSON Validation Utilities
|
||||||
|
// Type-safe parsing with structural validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type { GameSettings, Team, Round, FinalRound } from '$lib/types/kuldvillak'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Result type for validation operations |
||||||
|
*/ |
||||||
|
export type ValidationResult<T> = |
||||||
|
| { success: true; data: T } |
||||||
|
| { success: false; error: string }; |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate that a value is a non-null object |
||||||
|
*/ |
||||||
|
function isObject(value: unknown): value is Record<string, unknown> { |
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate that a value is an array |
||||||
|
*/ |
||||||
|
function isArray(value: unknown): value is unknown[] { |
||||||
|
return Array.isArray(value); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate that a value is a string |
||||||
|
*/ |
||||||
|
function isString(value: unknown): value is string { |
||||||
|
return typeof value === 'string'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate that a value is a number |
||||||
|
*/ |
||||||
|
function isNumber(value: unknown): value is number { |
||||||
|
return typeof value === 'number' && !isNaN(value); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Game data structure for import/export |
||||||
|
*/ |
||||||
|
export interface GameData { |
||||||
|
name?: string; |
||||||
|
settings: GameSettings; |
||||||
|
teams: Team[]; |
||||||
|
rounds: Round[]; |
||||||
|
finalRound?: FinalRound | null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate and parse game data from unknown input |
||||||
|
* @param data Unknown data to validate |
||||||
|
* @returns Validation result with typed data or error message |
||||||
|
*/ |
||||||
|
export function validateGameData(data: unknown): ValidationResult<GameData> { |
||||||
|
if (!isObject(data)) { |
||||||
|
return { success: false, error: 'Invalid data format: expected object' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate required fields exist
|
||||||
|
if (!isObject(data.settings)) { |
||||||
|
return { success: false, error: 'Missing or invalid settings' }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!isArray(data.teams)) { |
||||||
|
return { success: false, error: 'Missing or invalid teams array' }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!isArray(data.rounds)) { |
||||||
|
return { success: false, error: 'Missing or invalid rounds array' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate teams structure
|
||||||
|
for (let i = 0; i < data.teams.length; i++) { |
||||||
|
const team = data.teams[i]; |
||||||
|
if (!isObject(team)) { |
||||||
|
return { success: false, error: `Invalid team at index ${i}` }; |
||||||
|
} |
||||||
|
if (!isString(team.id)) { |
||||||
|
return { success: false, error: `Team ${i} missing id` }; |
||||||
|
} |
||||||
|
if (!isString(team.name)) { |
||||||
|
return { success: false, error: `Team ${i} missing name` }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate rounds structure
|
||||||
|
for (let i = 0; i < data.rounds.length; i++) { |
||||||
|
const round = data.rounds[i]; |
||||||
|
if (!isObject(round)) { |
||||||
|
return { success: false, error: `Invalid round at index ${i}` }; |
||||||
|
} |
||||||
|
if (!isArray(round.categories)) { |
||||||
|
return { success: false, error: `Round ${i} missing categories` }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Clean up legacy properties from settings
|
||||||
|
const settingsObj = data.settings as Record<string, unknown>; |
||||||
|
const { teamColors, ...cleanSettings } = settingsObj; |
||||||
|
|
||||||
|
// Build validated game data
|
||||||
|
const gameData: GameData = { |
||||||
|
name: isString(data.name) ? data.name : undefined, |
||||||
|
settings: cleanSettings as unknown as GameSettings, |
||||||
|
teams: data.teams.map((t) => { |
||||||
|
const team = t as Record<string, unknown>; |
||||||
|
return { |
||||||
|
id: team.id as string, |
||||||
|
name: team.name as string, |
||||||
|
score: isNumber(team.score) ? team.score : 0, |
||||||
|
}; |
||||||
|
}), |
||||||
|
rounds: data.rounds as Round[], |
||||||
|
finalRound: isObject(data.finalRound) |
||||||
|
? (data.finalRound as unknown as FinalRound) |
||||||
|
: null, |
||||||
|
}; |
||||||
|
|
||||||
|
return { success: true, data: gameData }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Safely parse JSON string with validation |
||||||
|
* @param jsonString JSON string to parse |
||||||
|
* @returns Validation result with parsed data or error |
||||||
|
*/ |
||||||
|
export function parseJSON<T>(jsonString: string): ValidationResult<T> { |
||||||
|
try { |
||||||
|
const data = JSON.parse(jsonString) as T; |
||||||
|
return { success: true, data }; |
||||||
|
} catch (error) { |
||||||
|
return { |
||||||
|
success: false, |
||||||
|
error: error instanceof Error ? error.message : 'Failed to parse JSON' |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse and validate game data from JSON string |
||||||
|
* @param jsonString JSON string containing game data |
||||||
|
* @returns Validation result with typed game data or error |
||||||
|
*/ |
||||||
|
export function parseGameData(jsonString: string): ValidationResult<GameData> { |
||||||
|
const parseResult = parseJSON<unknown>(jsonString); |
||||||
|
if (!parseResult.success) { |
||||||
|
return parseResult; |
||||||
|
} |
||||||
|
return validateGameData(parseResult.data); |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,5 @@ |
|||||||
|
// Mock for $app/environment used in tests
|
||||||
|
export const browser = true; |
||||||
|
export const building = false; |
||||||
|
export const dev = true; |
||||||
|
export const version = 'test'; |
||||||
Loading…
Reference in new issue