You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1146 lines
35 KiB
1146 lines
35 KiB
<script lang="ts"> |
|
import { goto } from "$app/navigation"; |
|
import { onMount } from "svelte"; |
|
import { browser } from "$app/environment"; |
|
import { Toast, Settings, ConfirmDialog } from "$lib/components"; |
|
import { |
|
KvButtonSecondary, |
|
KvCheckbox, |
|
TutorialModal, |
|
type TutorialSlide, |
|
} from "$lib/components/kuldvillak/ui"; |
|
import * as m from "$lib/paraglide/messages"; |
|
import { getLocale } from "$lib/paraglide/runtime"; |
|
import { gameSession } from "$lib/stores/gameSession.svelte"; |
|
import type { |
|
GameSettings, |
|
Team, |
|
Round, |
|
Category, |
|
Question, |
|
FinalRound, |
|
PointValuePreset, |
|
} from "$lib/types/kuldvillak"; |
|
import { DEFAULT_SETTINGS } from "$lib/types/kuldvillak"; |
|
|
|
const AUTOSAVE_KEY = "kuldvillak-editor-autosave"; |
|
|
|
// State |
|
let settings = $state<GameSettings>({ |
|
...DEFAULT_SETTINGS, |
|
defaultTimerSeconds: 5, |
|
answerRevealSeconds: 5, |
|
}); |
|
let teams = $state<Team[]>([ |
|
{ id: generateId(), name: "Mängija 1", score: 0 }, |
|
{ id: generateId(), name: "Mängija 2", score: 0 }, |
|
]); |
|
let rounds = $state<Round[]>([ |
|
createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS), |
|
createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS), |
|
]); |
|
let finalRound = $state<FinalRound>({ |
|
category: "", |
|
question: "", |
|
answer: "", |
|
}); |
|
let gameName = $state(""); |
|
let editingQuestion = $state<{ |
|
roundIndex: number; |
|
catIndex: number; |
|
qIndex: number; |
|
} | null>(null); |
|
let editingFinalQuestion = $state(false); |
|
let settingsOpen = $state(false); |
|
|
|
// Toast state |
|
let toastMessage = $state(""); |
|
let toastType = $state<"error" | "success">("error"); |
|
let toastVisible = $state(false); |
|
|
|
// File input ref |
|
let fileInput: HTMLInputElement; |
|
|
|
// Confirm dialog states |
|
let showResetConfirm = $state(false); |
|
let showQuestionCloseConfirm = $state(false); |
|
let showFinalCloseConfirm = $state(false); |
|
|
|
// Tutorial modal states |
|
let showRulesModal = $state(false); |
|
let showHowToModal = $state(false); |
|
|
|
// Tutorial slides - using paraglide translations with localized images |
|
const rulesSlides: TutorialSlide[] = $derived([ |
|
{ |
|
image: `/tutorials/${getLocale()}/rules-1.png`, |
|
text: m.kv_tutorial_rules_placeholder(), |
|
}, |
|
]); |
|
|
|
const howToSlides: TutorialSlide[] = $derived([ |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-1.png`, |
|
text: m.kv_tutorial_howto_1(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-2.png`, |
|
text: m.kv_tutorial_howto_2(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-3.png`, |
|
text: m.kv_tutorial_howto_3(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-4.png`, |
|
text: m.kv_tutorial_howto_4(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-5.png`, |
|
text: m.kv_tutorial_howto_5(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-6.png`, |
|
text: m.kv_tutorial_howto_6(), |
|
}, |
|
{ |
|
image: `/tutorials/${getLocale()}/howto-7.png`, |
|
text: m.kv_tutorial_howto_7(), |
|
}, |
|
]); |
|
|
|
// Original values for reverting |
|
let originalQuestion = $state<{ |
|
question: string; |
|
answer: string; |
|
imageUrl?: string; |
|
isDailyDouble: boolean; |
|
} | null>(null); |
|
let originalFinal = $state<{ |
|
category: string; |
|
question: string; |
|
answer: string; |
|
} | null>(null); |
|
|
|
// Autosave to localStorage |
|
function autoSave() { |
|
if (!browser) return; |
|
const data = { name: gameName, settings, teams, rounds, finalRound }; |
|
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); |
|
} |
|
|
|
// Load from localStorage on mount |
|
onMount(() => { |
|
const saved = localStorage.getItem(AUTOSAVE_KEY); |
|
if (saved) { |
|
try { |
|
const data = JSON.parse(saved); |
|
if (data.settings && data.teams && data.rounds) { |
|
gameName = data.name || ""; |
|
const { teamColors, ...cleanSettings } = |
|
data.settings as Record<string, unknown>; |
|
settings = { |
|
...DEFAULT_SETTINGS, |
|
...cleanSettings, |
|
} as GameSettings; |
|
teams = (data.teams as Team[]).map((t) => ({ |
|
id: t.id, |
|
name: t.name, |
|
score: t.score ?? 0, |
|
})); |
|
rounds = data.rounds as Round[]; |
|
finalRound = (data.finalRound as FinalRound) || { |
|
category: "", |
|
question: "", |
|
answer: "", |
|
}; |
|
} |
|
} catch { |
|
/* ignore parse errors */ |
|
} |
|
} |
|
}); |
|
|
|
// Auto-save on any state change |
|
$effect(() => { |
|
const _ = [gameName, settings, teams, rounds, finalRound]; |
|
autoSave(); |
|
}); |
|
|
|
function showToast(message: string, type: "error" | "success" = "error") { |
|
toastMessage = message; |
|
toastType = type; |
|
toastVisible = true; |
|
} |
|
|
|
function generateId(): string { |
|
return crypto.randomUUID(); |
|
} |
|
|
|
function createQuestion(points: number): Question { |
|
return { |
|
id: generateId(), |
|
question: "", |
|
answer: "", |
|
points, |
|
isDailyDouble: false, |
|
isRevealed: false, |
|
}; |
|
} |
|
|
|
function createCategory(s: GameSettings, multiplier: number = 1): Category { |
|
return { |
|
id: generateId(), |
|
name: "", |
|
questions: s.pointValues.map((p) => createQuestion(p * multiplier)), |
|
}; |
|
} |
|
|
|
function createRound( |
|
name: string, |
|
multiplier: number, |
|
s: GameSettings, |
|
): Round { |
|
return { |
|
id: generateId(), |
|
name, |
|
categories: Array.from({ length: s.categoriesPerRound }, () => |
|
createCategory(s, multiplier), |
|
), |
|
pointMultiplier: multiplier, |
|
}; |
|
} |
|
|
|
function 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); |
|
// Custom preset uses user-defined point values |
|
return settings.pointValues.map((v) => v * multiplier); |
|
} |
|
|
|
function updatePreset(preset: PointValuePreset) { |
|
settings.pointValuePreset = preset; |
|
if (preset === "round1") { |
|
settings.pointValues = [10, 20, 30, 40, 50]; |
|
} |
|
updateQuestionPoints(); |
|
} |
|
|
|
function updateQuestionPoints() { |
|
rounds.forEach((round, ri) => { |
|
const points = getPointsForRound(settings.pointValuePreset, ri); |
|
round.categories.forEach((cat) => |
|
cat.questions.forEach((q, i) => { |
|
q.points = points[i] ?? q.points; |
|
}), |
|
); |
|
}); |
|
rounds = [...rounds]; |
|
} |
|
|
|
function setRoundCount(count: 1 | 2) { |
|
settings.numberOfRounds = count; |
|
if (count === 1 && rounds.length > 1) { |
|
rounds = [rounds[0]]; |
|
settings.dailyDoublesPerRound = [settings.dailyDoublesPerRound[0]]; |
|
} else if (count === 2 && rounds.length === 1) { |
|
rounds = [...rounds, createRound(m.kv_edit_r2(), 2, settings)]; |
|
settings.dailyDoublesPerRound = [ |
|
settings.dailyDoublesPerRound[0], |
|
2, |
|
]; |
|
} |
|
updateQuestionPoints(); |
|
} |
|
|
|
function addTeam() { |
|
if (teams.length >= 6) return; |
|
teams = [ |
|
...teams, |
|
{ id: generateId(), name: `Mängija ${teams.length + 1}`, score: 0 }, |
|
]; |
|
} |
|
|
|
function removeTeam(id: string) { |
|
if (teams.length <= 2) return; |
|
teams = teams.filter((t) => t.id !== id); |
|
} |
|
|
|
function countDailyDoubles(roundIndex: number): number { |
|
return rounds[roundIndex].categories.reduce( |
|
(sum, cat) => |
|
sum + cat.questions.filter((q) => q.isDailyDouble).length, |
|
0, |
|
); |
|
} |
|
|
|
function validateGame(): string | null { |
|
if (teams.length < 2) return m.kv_error_min_players(); |
|
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 m.kv_error_no_questions({ round: String(i + 1) }); |
|
} |
|
if (settings.enableFinalRound && !finalRound.question.trim()) |
|
return m.kv_error_no_final(); |
|
return null; |
|
} |
|
|
|
let isStarting = $state(false); |
|
|
|
async function startGame() { |
|
const error = validateGame(); |
|
if (error) { |
|
showToast(error, "error"); |
|
return; |
|
} |
|
isStarting = true; |
|
try { |
|
gameSession.startGame({ |
|
name: gameName, |
|
settings, |
|
teams, |
|
rounds, |
|
finalRound: settings.enableFinalRound ? finalRound : null, |
|
}); |
|
gameSession.openProjector(); |
|
await goto("/kuldvillak/play"); |
|
} catch (err) { |
|
showToast("Failed to start game: " + err, "error"); |
|
isStarting = false; |
|
} |
|
} |
|
|
|
function saveGame() { |
|
const game = { name: gameName, settings, teams, rounds, finalRound }; |
|
const blob = new Blob([JSON.stringify(game, null, 2)], { |
|
type: "application/json", |
|
}); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement("a"); |
|
a.href = url; |
|
a.download = `${gameName.replace(/\s+/g, "_") || "game"}.json`; |
|
a.click(); |
|
URL.revokeObjectURL(url); |
|
showToast(m.kv_toast_game_saved(), "success"); |
|
} |
|
|
|
function resetGame() { |
|
settings = { |
|
...DEFAULT_SETTINGS, |
|
defaultTimerSeconds: 5, |
|
answerRevealSeconds: 5, |
|
}; |
|
teams = [ |
|
{ id: generateId(), name: "Mängija 1", score: 0 }, |
|
{ id: generateId(), name: "Mängija 2", score: 0 }, |
|
]; |
|
rounds = [ |
|
createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS), |
|
createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS), |
|
]; |
|
finalRound = { category: "", question: "", answer: "" }; |
|
gameName = ""; |
|
localStorage.removeItem(AUTOSAVE_KEY); |
|
showToast(m.kv_edit_reset_success(), "success"); |
|
} |
|
|
|
function loadGame(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); |
|
if (data.settings && data.teams && data.rounds) { |
|
gameName = data.name || "Loaded Game"; |
|
const { teamColors, ...cleanSettings } = |
|
data.settings as Record<string, unknown>; |
|
settings = { |
|
...DEFAULT_SETTINGS, |
|
...cleanSettings, |
|
} as GameSettings; |
|
teams = (data.teams as Team[]).map((t) => ({ |
|
id: t.id, |
|
name: t.name, |
|
score: t.score ?? 0, |
|
})); |
|
rounds = data.rounds as Round[]; |
|
finalRound = (data.finalRound as FinalRound) || { |
|
category: "", |
|
question: "", |
|
answer: "", |
|
}; |
|
showToast(m.kv_toast_game_loaded(), "success"); |
|
} else { |
|
showToast(m.kv_toast_invalid_file(), "error"); |
|
} |
|
} catch { |
|
showToast(m.kv_toast_invalid_file(), "error"); |
|
} |
|
}; |
|
reader.readAsText(file); |
|
(event.target as HTMLInputElement).value = ""; |
|
} |
|
|
|
function toggleDailyDouble() { |
|
if (!editingQuestion) return; |
|
const { roundIndex, catIndex, qIndex } = editingQuestion; |
|
const q = rounds[roundIndex].categories[catIndex].questions[qIndex]; |
|
const maxDD = settings.dailyDoublesPerRound[roundIndex] ?? 1; |
|
const currentCount = countDailyDoubles(roundIndex); |
|
|
|
if (q.isDailyDouble) { |
|
q.isDailyDouble = false; |
|
} else if (currentCount < maxDD) { |
|
q.isDailyDouble = true; |
|
} |
|
rounds = [...rounds]; |
|
} |
|
|
|
function openQuestion( |
|
roundIndex: number, |
|
catIndex: number, |
|
qIndex: number, |
|
) { |
|
const q = rounds[roundIndex].categories[catIndex].questions[qIndex]; |
|
originalQuestion = { |
|
question: q.question, |
|
answer: q.answer, |
|
imageUrl: q.imageUrl, |
|
isDailyDouble: q.isDailyDouble, |
|
}; |
|
editingQuestion = { roundIndex, catIndex, qIndex }; |
|
} |
|
|
|
function saveQuestion() { |
|
originalQuestion = null; |
|
editingQuestion = null; |
|
} |
|
|
|
function handleQuestionCloseClick() { |
|
showQuestionCloseConfirm = true; |
|
} |
|
|
|
function discardQuestionChanges() { |
|
if (editingQuestion && originalQuestion) { |
|
const q = |
|
rounds[editingQuestion.roundIndex].categories[ |
|
editingQuestion.catIndex |
|
].questions[editingQuestion.qIndex]; |
|
q.question = originalQuestion.question; |
|
q.answer = originalQuestion.answer; |
|
q.imageUrl = originalQuestion.imageUrl; |
|
q.isDailyDouble = originalQuestion.isDailyDouble; |
|
rounds = [...rounds]; |
|
} |
|
showQuestionCloseConfirm = false; |
|
originalQuestion = null; |
|
editingQuestion = null; |
|
} |
|
|
|
function openFinalQuestion() { |
|
originalFinal = { ...finalRound }; |
|
editingFinalQuestion = true; |
|
} |
|
|
|
function saveFinalQuestion() { |
|
originalFinal = null; |
|
editingFinalQuestion = false; |
|
} |
|
|
|
function handleFinalCloseClick() { |
|
showFinalCloseConfirm = true; |
|
} |
|
|
|
function discardFinalChanges() { |
|
if (originalFinal) { |
|
finalRound = { ...originalFinal }; |
|
} |
|
showFinalCloseConfirm = false; |
|
originalFinal = null; |
|
editingFinalQuestion = false; |
|
} |
|
</script> |
|
|
|
<svelte:head> |
|
<title>{m.kv_edit_title()} - {m.game_kuldvillak()}</title> |
|
<link rel="icon" href="/kuldvillak_favicon.svg" type="image/svg+xml" /> |
|
</svelte:head> |
|
|
|
<!-- Main Layout --> |
|
<div class="min-h-screen bg-kv-black flex flex-col gap-2 md:gap-4 p-2 md:p-4"> |
|
<!-- Header --> |
|
<header |
|
class="bg-kv-blue flex flex-wrap items-center gap-4 lg:gap-8 p-2 md:p-4" |
|
> |
|
<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" |
|
> |
|
<path |
|
d="M29.5334 40L13.5334 24L29.5334 8L33.2668 11.7333L21.0001 24L33.2668 36.2667L29.5334 40Z" |
|
/> |
|
</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> |
|
|
|
<div class="flex items-center gap-8"> |
|
<button |
|
onclick={() => fileInput.click()} |
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow" |
|
aria-label={m.kv_edit_load()} |
|
> |
|
<svg |
|
viewBox="0 0 40 40" |
|
fill="currentColor" |
|
class="w-full h-full" |
|
> |
|
<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" |
|
/> |
|
</svg> |
|
</button> |
|
<button |
|
onclick={saveGame} |
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow" |
|
aria-label={m.kv_edit_save()} |
|
> |
|
<svg |
|
viewBox="0 0 40 40" |
|
fill="currentColor" |
|
class="w-full h-full" |
|
> |
|
<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" |
|
/> |
|
</svg> |
|
</button> |
|
<button |
|
onclick={() => (showResetConfirm = true)} |
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow" |
|
aria-label={m.kv_edit_reset()} |
|
> |
|
<svg |
|
viewBox="0 0 40 40" |
|
fill="currentColor" |
|
class="w-full h-full" |
|
> |
|
<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" |
|
/> |
|
</svg> |
|
</button> |
|
<button |
|
onclick={() => (settingsOpen = true)} |
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow" |
|
aria-label={m.kv_settings()} |
|
> |
|
<svg |
|
viewBox="0 0 30 30" |
|
fill="currentColor" |
|
class="w-full h-full" |
|
> |
|
<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" |
|
/> |
|
</svg> |
|
</button> |
|
</div> |
|
|
|
<input |
|
type="file" |
|
accept=".json" |
|
class="hidden" |
|
bind:this={fileInput} |
|
onchange={loadGame} |
|
/> |
|
|
|
<KvButtonSecondary onclick={startGame} disabled={isStarting}> |
|
{isStarting ? "⏳" : "▶"} |
|
{m.kv_edit_start()} |
|
</KvButtonSecondary> |
|
</header> |
|
|
|
<!-- Settings Panel --> |
|
<section class="bg-kv-blue p-2 md:p-4 overflow-x-auto"> |
|
<!-- Title Row with Buttons --> |
|
<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"> |
|
<KvButtonSecondary |
|
onclick={() => (showRulesModal = true)} |
|
size="md" |
|
> |
|
{m.kv_edit_rules()} |
|
</KvButtonSecondary> |
|
<KvButtonSecondary |
|
onclick={() => (showHowToModal = true)} |
|
size="md" |
|
> |
|
{m.kv_edit_how_to()} |
|
</KvButtonSecondary> |
|
</div> |
|
</div> |
|
|
|
<div class="flex flex-wrap gap-4 lg:gap-16 xl:gap-32"> |
|
<!-- Left Options --> |
|
<div class="flex gap-8"> |
|
<!-- Labels Column --> |
|
<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> |
|
</div> |
|
|
|
<!-- Values Column --> |
|
<div class="flex flex-col gap-4"> |
|
<!-- Round count --> |
|
<div class="h-12 flex items-center"> |
|
<button |
|
onclick={() => setRoundCount(1)} |
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center font-kv-body text-xl uppercase cursor-pointer bg-transparent transition-colors {settings.numberOfRounds === |
|
1 |
|
? 'text-kv-yellow' |
|
: 'text-kv-white'}">1</button |
|
> |
|
<button |
|
onclick={() => setRoundCount(2)} |
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center font-kv-body text-xl uppercase cursor-pointer bg-transparent transition-colors {settings.numberOfRounds === |
|
2 |
|
? 'text-kv-yellow' |
|
: 'text-kv-white'}">2</button |
|
> |
|
</div> |
|
<!-- Answer 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={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={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 --> |
|
<div class="h-12 flex items-center gap-1"> |
|
<KvCheckbox |
|
checked={settings.enableFinalRound} |
|
onclick={() => |
|
(settings.enableFinalRound = |
|
!settings.enableFinalRound)} |
|
/> |
|
{#if settings.enableFinalRound} |
|
<KvButtonSecondary |
|
onclick={openFinalQuestion} |
|
size="sm" |
|
> |
|
{m.kv_edit_question()} |
|
</KvButtonSecondary> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<!-- Middle Options --> |
|
<div class="flex gap-8"> |
|
<!-- Labels --> |
|
<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> |
|
|
|
<!-- Values --> |
|
<div class="flex flex-col gap-4"> |
|
<!-- Points preset toggle --> |
|
<div class="h-12 flex items-center"> |
|
<button |
|
onclick={() => updatePreset("round1")} |
|
class="px-4 h-12 border-4 border-kv-black flex items-center justify-center font-kv-body text-xl uppercase cursor-pointer bg-transparent transition-colors {settings.pointValuePreset === |
|
'round1' |
|
? 'text-kv-yellow bg-kv-blue' |
|
: 'text-kv-white'}" |
|
>{m.kv_edit_preset_normal()}</button |
|
> |
|
<button |
|
onclick={() => updatePreset("custom")} |
|
class="px-4 h-12 border-4 border-kv-black flex items-center justify-center font-kv-body text-xl uppercase cursor-pointer bg-transparent transition-colors {settings.pointValuePreset === |
|
'custom' |
|
? 'text-kv-yellow bg-kv-blue' |
|
: 'text-kv-white'}">{m.kv_edit_custom()}</button |
|
> |
|
</div> |
|
<!-- Point values --> |
|
<div class="h-12 flex items-center"> |
|
{#each settings.pointValues as val, i} |
|
<div |
|
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center" |
|
> |
|
{#if settings.pointValuePreset === "custom"} |
|
<input |
|
type="number" |
|
bind:value={settings.pointValues[i]} |
|
onchange={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={settings.allowNegativeScores} |
|
onclick={() => |
|
(settings.allowNegativeScores = |
|
!settings.allowNegativeScores)} |
|
/> |
|
</div> |
|
<!-- Players --> |
|
<div class="h-12 flex items-center gap-2 flex-wrap"> |
|
{#each teams as team (team.id)} |
|
<div |
|
class="flex items-center h-12 border-4 border-kv-black px-2" |
|
> |
|
<input |
|
type="text" |
|
bind:value={team.name} |
|
class="bg-transparent border-none outline-none font-kv-body text-xl text-kv-yellow uppercase min-w-[80px] max-w-[120px] kv-shadow-text" |
|
/> |
|
<button |
|
onclick={() => removeTeam(team.id)} |
|
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 flex items-center justify-center hover:opacity-70 flex-shrink-0 text-kv-yellow" |
|
aria-label={m.kv_edit_remove_team()} |
|
> |
|
<svg |
|
viewBox="0 0 24 24" |
|
fill="currentColor" |
|
class="w-6 h-6" |
|
> |
|
<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> |
|
</button> |
|
</div> |
|
{/each} |
|
{#if teams.length < 6} |
|
<button |
|
onclick={addTeam} |
|
disabled={teams.length >= 6} |
|
class="w-10 h-10 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 flex items-center justify-center text-kv-yellow" |
|
aria-label={m.kv_edit_add_team()} |
|
> |
|
<svg |
|
viewBox="0 0 48 48" |
|
fill="currentColor" |
|
class="w-8 h-8" |
|
> |
|
<path |
|
d="M22.2857 25.7143H12V22.2857H22.2857V12H25.7143V22.2857H36V25.7143H25.7143V36H22.2857V25.7143Z" |
|
/> |
|
</svg> |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</section> |
|
|
|
<!-- Game Boards --> |
|
<div class="flex flex-col lg:flex-row gap-2 md:gap-4 flex-1"> |
|
{#each rounds as round, ri} |
|
<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" |
|
>{ri === 0 ? m.kv_edit_r1() : m.kv_edit_r2()}</span |
|
> |
|
<span class="text-kv-yellow text-xl ml-2" |
|
>({m.kv_edit_dd_count()} |
|
{countDailyDoubles(ri)}/{settings |
|
.dailyDoublesPerRound[ri] ?? 1})</span |
|
> |
|
</h2> |
|
</div> |
|
|
|
<!-- Categories --> |
|
<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" |
|
bind:value={cat.name} |
|
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: settings.questionsPerCategory } as _, qi} |
|
{#each round.categories as cat, ci} |
|
{@const q = cat.questions[qi]} |
|
<button |
|
onclick={() => openQuestion(ri, ci, qi)} |
|
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative |
|
{q.question.trim() ? 'opacity-100' : 'opacity-50'} |
|
{q.isDailyDouble ? 'ring-2 ring-inset ring-kv-yellow' : ''} kv-shadow-text" |
|
> |
|
<span |
|
class="font-kv-price text-kv-yellow text-4xl kv-shadow-price kv-shadow-text" |
|
>{q.points}€</span |
|
> |
|
</button> |
|
{/each} |
|
{/each} |
|
</div> |
|
</section> |
|
{/each} |
|
</div> |
|
</div> |
|
|
|
<!-- Question Edit Modal --> |
|
{#if editingQuestion} |
|
{@const q = |
|
rounds[editingQuestion.roundIndex].categories[editingQuestion.catIndex] |
|
.questions[editingQuestion.qIndex]} |
|
{@const cat = |
|
rounds[editingQuestion.roundIndex].categories[editingQuestion.catIndex]} |
|
{@const maxDD = |
|
settings.dailyDoublesPerRound[editingQuestion.roundIndex] ?? 1} |
|
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)} |
|
<div |
|
class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8" |
|
onclick={(e) => |
|
e.target === e.currentTarget && handleQuestionCloseClick()} |
|
onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()} |
|
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" |
|
> |
|
<!-- 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" |
|
> |
|
{cat.name || m.kv_edit_category()} - {q.points}€ |
|
</h3> |
|
<button |
|
onclick={handleQuestionCloseClick} |
|
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" |
|
> |
|
<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> |
|
</button> |
|
</div> |
|
|
|
<!-- Inputs --> |
|
<div class="flex flex-col gap-4"> |
|
<div class="border-4 border-kv-black p-4 h-28"> |
|
<textarea |
|
bind:value={q.question} |
|
placeholder={m.kv_edit_question()} |
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase resize-none placeholder:text-kv-white/50" |
|
></textarea> |
|
</div> |
|
<div class="border-4 border-kv-black p-4"> |
|
<input |
|
type="text" |
|
bind:value={q.imageUrl} |
|
placeholder={m.kv_edit_image_link()} |
|
class="w-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase placeholder:text-kv-white/50" |
|
/> |
|
</div> |
|
<div class="border-4 border-kv-black p-4 h-28"> |
|
<textarea |
|
bind:value={q.answer} |
|
placeholder={m.kv_edit_answer()} |
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase resize-none placeholder:text-kv-white/50" |
|
></textarea> |
|
</div> |
|
|
|
<!-- Daily Double checkbox --> |
|
<div class="flex items-center gap-2"> |
|
<KvCheckbox |
|
checked={q.isDailyDouble} |
|
onclick={toggleDailyDouble} |
|
disabled={!q.isDailyDouble && currentDD >= maxDD} |
|
/> |
|
<span |
|
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
|
> |
|
{m.kv_edit_daily_double()} ({currentDD}/{maxDD}) |
|
</span> |
|
</div> |
|
</div> |
|
|
|
<!-- Save button --> |
|
<KvButtonSecondary |
|
onclick={saveQuestion} |
|
size="md" |
|
class="self-start" |
|
> |
|
{m.kv_edit_save_exit()} |
|
</KvButtonSecondary> |
|
</div> |
|
</div> |
|
|
|
<!-- Question Confirm Dialog --> |
|
<ConfirmDialog |
|
bind:open={showQuestionCloseConfirm} |
|
title={m.kv_confirm_close_title()} |
|
message={m.kv_confirm_close_message()} |
|
confirmText={m.kv_confirm_discard()} |
|
cancelText={m.kv_edit_save()} |
|
onconfirm={discardQuestionChanges} |
|
oncancel={() => { |
|
showQuestionCloseConfirm = false; |
|
saveQuestion(); |
|
}} |
|
/> |
|
{/if} |
|
|
|
<!-- Final Question Modal --> |
|
{#if editingFinalQuestion} |
|
<div |
|
class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8" |
|
onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()} |
|
onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()} |
|
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" |
|
> |
|
<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.kv_edit_final_round()} |
|
</h3> |
|
<button |
|
onclick={handleFinalCloseClick} |
|
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" |
|
> |
|
<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> |
|
</button> |
|
</div> |
|
|
|
<div class="flex flex-col gap-4"> |
|
<div class="border-4 border-kv-black p-4"> |
|
<input |
|
type="text" |
|
bind:value={finalRound.category} |
|
placeholder={m.kv_edit_category()} |
|
class="w-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase placeholder:text-kv-white/50" |
|
/> |
|
</div> |
|
<div class="border-4 border-kv-black p-4 h-28"> |
|
<textarea |
|
bind:value={finalRound.question} |
|
placeholder={m.kv_edit_question()} |
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase resize-none placeholder:text-kv-white/50" |
|
></textarea> |
|
</div> |
|
<div class="border-4 border-kv-black p-4 h-28"> |
|
<textarea |
|
bind:value={finalRound.answer} |
|
placeholder={m.kv_edit_answer()} |
|
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-base text-kv-white uppercase resize-none placeholder:text-kv-white/50" |
|
></textarea> |
|
</div> |
|
</div> |
|
|
|
<KvButtonSecondary |
|
onclick={saveFinalQuestion} |
|
size="md" |
|
class="self-start" |
|
> |
|
{m.kv_edit_save_exit()} |
|
</KvButtonSecondary> |
|
</div> |
|
</div> |
|
|
|
<!-- Final Confirm Dialog --> |
|
<ConfirmDialog |
|
bind:open={showFinalCloseConfirm} |
|
title={m.kv_confirm_close_title()} |
|
message={m.kv_confirm_close_message()} |
|
confirmText={m.kv_confirm_discard()} |
|
cancelText={m.kv_edit_save()} |
|
onconfirm={discardFinalChanges} |
|
oncancel={() => { |
|
showFinalCloseConfirm = false; |
|
saveFinalQuestion(); |
|
}} |
|
/> |
|
{/if} |
|
|
|
<!-- Settings Modal --> |
|
<Settings bind:open={settingsOpen} /> |
|
|
|
<!-- Toast --> |
|
<Toast bind:visible={toastVisible} message={toastMessage} type={toastType} /> |
|
|
|
<!-- Loading overlay --> |
|
{#if isStarting} |
|
<div |
|
class="fixed inset-0 bg-kv-black/90 flex flex-col items-center justify-center z-50 gap-4" |
|
> |
|
<div |
|
class="w-16 h-16 border-4 border-kv-yellow border-t-transparent rounded-full animate-spin" |
|
></div> |
|
<p class="font-kv-body text-2xl text-kv-white uppercase"> |
|
{m.kv_edit_starting_game()} |
|
</p> |
|
</div> |
|
{/if} |
|
|
|
<!-- Reset Confirmation --> |
|
<ConfirmDialog |
|
bind:open={showResetConfirm} |
|
title={m.kv_edit_reset()} |
|
message={m.kv_edit_reset_confirm()} |
|
confirmText={m.kv_edit_reset()} |
|
onconfirm={resetGame} |
|
/> |
|
|
|
<!-- Tutorial Modals --> |
|
<TutorialModal |
|
bind:open={showRulesModal} |
|
title={m.kv_edit_rules()} |
|
slides={rulesSlides} |
|
/> |
|
|
|
<TutorialModal |
|
bind:open={showHowToModal} |
|
title={m.kv_edit_how_to()} |
|
slides={howToSlides} |
|
/>
|
|
|