Jeopardy MVP is ready
This commit is contained in:
821
src/lib/components/ColorPicker.svelte
Normal file
821
src/lib/components/ColorPicker.svelte
Normal file
@@ -0,0 +1,821 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string;
|
||||
onchange?: (color: string) => void;
|
||||
}
|
||||
|
||||
let { value = $bindable("#ffffff"), onchange }: ColorPickerProps = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let hexInput = $state(value);
|
||||
|
||||
// RGB values
|
||||
let r = $state(255);
|
||||
let g = $state(255);
|
||||
let b = $state(255);
|
||||
|
||||
// Alpha/Opacity (0-100%)
|
||||
let alpha = $state(100);
|
||||
|
||||
// HSV values
|
||||
let hsvH = $state(0);
|
||||
let hsvS = $state(0);
|
||||
let hsvV = $state(100);
|
||||
|
||||
// HSL values
|
||||
let hslH = $state(0);
|
||||
let hslS = $state(0);
|
||||
let hslL = $state(100);
|
||||
|
||||
// Grayscale
|
||||
let gray = $state(255);
|
||||
|
||||
// Parse hex input - supports 2, 3, 6, and 8 character formats
|
||||
function parseHexInput(input: string): string | null {
|
||||
// Remove # and whitespace
|
||||
let hex = input.replace(/^#/, "").trim().toLowerCase();
|
||||
|
||||
// 2 characters: repeat 3 times (e.g., "AB" → "ABABAB")
|
||||
if (/^[a-f\d]{2}$/i.test(hex)) {
|
||||
hex = hex + hex + hex;
|
||||
}
|
||||
// 3 characters: double each (e.g., "ABC" → "AABBCC")
|
||||
else if (/^[a-f\d]{3}$/i.test(hex)) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
// 4 characters: double each with alpha (e.g., "ABCD" → "AABBCCDD")
|
||||
else if (/^[a-f\d]{4}$/i.test(hex)) {
|
||||
hex =
|
||||
hex[0] +
|
||||
hex[0] +
|
||||
hex[1] +
|
||||
hex[1] +
|
||||
hex[2] +
|
||||
hex[2] +
|
||||
hex[3] +
|
||||
hex[3];
|
||||
}
|
||||
// 6 characters: full hex
|
||||
else if (/^[a-f\d]{6}$/i.test(hex)) {
|
||||
// Already valid
|
||||
}
|
||||
// 8 characters: full hex with alpha
|
||||
else if (/^[a-f\d]{8}$/i.test(hex)) {
|
||||
// Valid with alpha
|
||||
} else {
|
||||
return null; // Invalid
|
||||
}
|
||||
|
||||
return "#" + hex;
|
||||
}
|
||||
|
||||
// Convert hex to RGB (supports 6 and 8 character hex)
|
||||
function hexToRgb(hex: string): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
} {
|
||||
const parsed = parseHexInput(hex);
|
||||
if (!parsed) return { r: 255, g: 255, b: 255, a: 100 };
|
||||
|
||||
const clean = parsed.replace("#", "");
|
||||
const r = parseInt(clean.substring(0, 2), 16);
|
||||
const g = parseInt(clean.substring(2, 4), 16);
|
||||
const b = parseInt(clean.substring(4, 6), 16);
|
||||
// Alpha from hex (8 chars) or default to 100%
|
||||
const a =
|
||||
clean.length === 8
|
||||
? Math.round((parseInt(clean.substring(6, 8), 16) / 255) * 100)
|
||||
: 100;
|
||||
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
// Convert RGB to hex (always outputs 6 characters, alpha stored separately)
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Convert RGB to HSV
|
||||
function rgbToHsv(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { h: number; s: number; v: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
const v = max;
|
||||
const d = max - min;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
let h = 0;
|
||||
if (max !== min) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
v: Math.round(v * 100),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert HSV to RGB
|
||||
function hsvToRgb(
|
||||
h: number,
|
||||
s: number,
|
||||
v: number,
|
||||
): { r: number; g: number; b: number } {
|
||||
s /= 100;
|
||||
v /= 100;
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r1 = 0,
|
||||
g1 = 0,
|
||||
b1 = 0;
|
||||
if (h < 60) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
} else if (h < 120) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
} else if (h < 180) {
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (h < 240) {
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (h < 300) {
|
||||
r1 = x;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
b1 = x;
|
||||
}
|
||||
return {
|
||||
r: Math.round((r1 + m) * 255),
|
||||
g: Math.round((g1 + m) * 255),
|
||||
b: Math.round((b1 + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert RGB to HSL
|
||||
function rgbToHsl(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0,
|
||||
s = 0;
|
||||
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)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert HSL to RGB
|
||||
function hslToRgb(
|
||||
h: number,
|
||||
s: number,
|
||||
l: number,
|
||||
): { r: number; g: number; b: number } {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r1 = 0,
|
||||
g1 = 0,
|
||||
b1 = 0;
|
||||
if (h < 60) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
} else if (h < 120) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
} else if (h < 180) {
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (h < 240) {
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (h < 300) {
|
||||
r1 = x;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
b1 = x;
|
||||
}
|
||||
return {
|
||||
r: Math.round((r1 + m) * 255),
|
||||
g: Math.round((g1 + m) * 255),
|
||||
b: Math.round((b1 + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
// Sync all values from hex
|
||||
function syncFromHex(hex: string) {
|
||||
const rgba = hexToRgb(hex);
|
||||
r = rgba.r;
|
||||
g = rgba.g;
|
||||
b = rgba.b;
|
||||
alpha = rgba.a;
|
||||
const hsv = rgbToHsv(r, g, b);
|
||||
hsvH = hsv.h;
|
||||
hsvS = hsv.s;
|
||||
hsvV = hsv.v;
|
||||
const hsl = rgbToHsl(r, g, b);
|
||||
hslH = hsl.h;
|
||||
hslS = hsl.s;
|
||||
hslL = hsl.l;
|
||||
gray = Math.round((r + g + b) / 3);
|
||||
hexInput = rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// Initialize from value
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
syncFromHex(value);
|
||||
}
|
||||
});
|
||||
|
||||
// Get output color with alpha if not 100%
|
||||
function getOutputColor(): string {
|
||||
const hex = rgbToHex(r, g, b);
|
||||
if (alpha < 100) {
|
||||
const alphaHex = Math.round((alpha / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return hex + alphaHex;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function applyColor(hex: string, updateAlphaFromHex = true) {
|
||||
const rgba = hexToRgb(hex);
|
||||
r = rgba.r;
|
||||
g = rgba.g;
|
||||
b = rgba.b;
|
||||
// Update alpha from hex if it's 8 chars
|
||||
if (updateAlphaFromHex && hex.replace("#", "").length === 8) {
|
||||
alpha = rgba.a;
|
||||
}
|
||||
const hsv = rgbToHsv(r, g, b);
|
||||
hsvH = hsv.h;
|
||||
hsvS = hsv.s;
|
||||
hsvV = hsv.v;
|
||||
const hsl = rgbToHsl(r, g, b);
|
||||
hslH = hsl.h;
|
||||
hslS = hsl.s;
|
||||
hslL = hsl.l;
|
||||
gray = Math.round((r + g + b) / 3);
|
||||
// Update output value with alpha
|
||||
value = getOutputColor();
|
||||
hexInput = rgbToHex(r, g, b);
|
||||
onchange?.(value);
|
||||
}
|
||||
|
||||
function updateFromHex() {
|
||||
const parsed = parseHexInput(hexInput);
|
||||
if (parsed) {
|
||||
applyColor(parsed, true);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFromRgb() {
|
||||
r = Math.max(0, Math.min(255, r));
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
b = Math.max(0, Math.min(255, b));
|
||||
applyColor(rgbToHex(r, g, b), false);
|
||||
}
|
||||
|
||||
function updateFromHsv() {
|
||||
const rgb = hsvToRgb(hsvH, hsvS, hsvV);
|
||||
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false);
|
||||
}
|
||||
|
||||
function updateFromHsl() {
|
||||
const rgb = hslToRgb(hslH, hslS, hslL);
|
||||
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false);
|
||||
}
|
||||
|
||||
function updateFromAlpha() {
|
||||
value = getOutputColor();
|
||||
onchange?.(value);
|
||||
}
|
||||
|
||||
function updateFromGray() {
|
||||
const v = Math.max(0, Math.min(255, gray));
|
||||
applyColor(rgbToHex(v, v, v));
|
||||
}
|
||||
|
||||
// Handle saturation/value picker click (HSV-based)
|
||||
function handleSVPick(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
hsvS = Math.round(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100),
|
||||
),
|
||||
);
|
||||
hsvV = Math.round(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
100 - ((e.clientY - rect.top) / rect.height) * 100,
|
||||
),
|
||||
),
|
||||
);
|
||||
updateFromHsv();
|
||||
}
|
||||
|
||||
// Slider refs for global drag tracking
|
||||
let hueSliderRect: DOMRect | null = null;
|
||||
let opacitySliderRect: DOMRect | null = null;
|
||||
let svPickerRect: DOMRect | null = null;
|
||||
let dragging: "hue" | "opacity" | "sv" | null = null;
|
||||
|
||||
// Calculate hue from mouse position
|
||||
function calcHue(clientX: number, rect: DOMRect) {
|
||||
const padding = rect.width * 0.02;
|
||||
const usableWidth = rect.width * 0.96;
|
||||
const relativeX = clientX - rect.left - padding;
|
||||
return Math.round(
|
||||
Math.max(0, Math.min(359, (relativeX / usableWidth) * 359)),
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate opacity from mouse position
|
||||
function calcOpacity(clientX: number, rect: DOMRect) {
|
||||
const padding = rect.width * 0.02;
|
||||
const usableWidth = rect.width * 0.96;
|
||||
const relativeX = clientX - rect.left - padding;
|
||||
return Math.round(
|
||||
Math.max(0, Math.min(100, (relativeX / usableWidth) * 100)),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle hue slider
|
||||
function handleHuePick(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
hueSliderRect = rect;
|
||||
dragging = "hue";
|
||||
hsvH = calcHue(e.clientX, rect);
|
||||
updateFromHsv();
|
||||
}
|
||||
|
||||
// Handle opacity slider
|
||||
function handleOpacityPick(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
opacitySliderRect = rect;
|
||||
dragging = "opacity";
|
||||
alpha = calcOpacity(e.clientX, rect);
|
||||
updateFromAlpha();
|
||||
}
|
||||
|
||||
// Handle SV picker
|
||||
function handleSVPickStart(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
svPickerRect = rect;
|
||||
dragging = "sv";
|
||||
handleSVPick(e);
|
||||
}
|
||||
|
||||
// Global mouse move handler
|
||||
function handleGlobalMouseMove(e: MouseEvent) {
|
||||
if (!dragging) return;
|
||||
|
||||
if (dragging === "hue" && hueSliderRect) {
|
||||
hsvH = calcHue(e.clientX, hueSliderRect);
|
||||
updateFromHsv();
|
||||
} else if (dragging === "opacity" && opacitySliderRect) {
|
||||
alpha = calcOpacity(e.clientX, opacitySliderRect);
|
||||
updateFromAlpha();
|
||||
} else if (dragging === "sv" && svPickerRect) {
|
||||
const rect = svPickerRect;
|
||||
hsvS = Math.round(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100),
|
||||
),
|
||||
);
|
||||
hsvV = Math.round(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
100 - ((e.clientY - rect.top) / rect.height) * 100,
|
||||
),
|
||||
),
|
||||
);
|
||||
updateFromHsv();
|
||||
}
|
||||
}
|
||||
|
||||
// Global mouse up handler
|
||||
function handleGlobalMouseUp() {
|
||||
dragging = null;
|
||||
hueSliderRect = null;
|
||||
opacitySliderRect = null;
|
||||
svPickerRect = null;
|
||||
}
|
||||
|
||||
// Add/remove global listeners when modal opens/closes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousemove", handleGlobalMouseMove);
|
||||
document.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleGlobalMouseMove);
|
||||
document.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
// Confirmation dialog state
|
||||
let showConfirmClose = $state(false);
|
||||
let originalValue = $state(value);
|
||||
|
||||
function togglePicker() {
|
||||
if (!isOpen) {
|
||||
originalValue = value; // Store original when opening
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function handleCloseClick() {
|
||||
// Only show confirmation if value changed
|
||||
if (value !== originalValue) {
|
||||
showConfirmClose = true;
|
||||
} else {
|
||||
closePicker();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClose() {
|
||||
showConfirmClose = false;
|
||||
// Revert to original value
|
||||
value = originalValue;
|
||||
syncFromHex(originalValue);
|
||||
onchange?.(originalValue);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function cancelClose() {
|
||||
showConfirmClose = false;
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
// Portal action - moves element to body
|
||||
function portal(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Color Swatch Button -->
|
||||
<button
|
||||
class="w-10 h-10 border-4 border-black cursor-pointer"
|
||||
style="background-color: {value};"
|
||||
onclick={togglePicker}
|
||||
type="button"
|
||||
aria-label="Pick color"
|
||||
></button>
|
||||
|
||||
<!-- Picker Modal - rendered via portal to body -->
|
||||
{#if isOpen}
|
||||
<div use:portal>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-kv-background/50 z-[60]"
|
||||
onclick={closePicker}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => e.key === "Escape" && closePicker()}
|
||||
></div>
|
||||
|
||||
<!-- Modal centered on screen -->
|
||||
<div
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[70]
|
||||
bg-kv-blue border-4 md:border-8 lg:border-[16px] border-kv-black
|
||||
p-3 md:p-6 lg:p-8 w-[95vw] max-w-[320px] md:max-w-[400px] lg:max-w-[450px]
|
||||
flex flex-col gap-3 md:gap-4 lg:gap-6
|
||||
max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<!-- Header with Title and Close Button -->
|
||||
<div class="flex items-start justify-between w-full">
|
||||
<h2
|
||||
class="text-xl md:text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>
|
||||
{m.kv_color_picker()}
|
||||
</h2>
|
||||
<button
|
||||
onclick={handleCloseClick}
|
||||
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="w-full h-full text-kv-yellow"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saturation/Value Picker (HSV-based) -->
|
||||
<div
|
||||
class="relative w-full h-32 md:h-48 lg:h-56 cursor-crosshair border-2 md:border-4 border-black shrink-0 select-none"
|
||||
style="background: linear-gradient(to top, black, transparent),
|
||||
linear-gradient(to right, white, hsl({hsvH}, 100%, 50%));"
|
||||
onmousedown={handleSVPickStart}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Saturation and Value"
|
||||
aria-valuenow={hsvS}
|
||||
>
|
||||
<!-- Picker Indicator -->
|
||||
<div
|
||||
class="absolute w-5 h-5 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style="left: {hsvS}%; top: {100 -
|
||||
hsvV}%; box-shadow: 0 0 3px black;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hue Slider -->
|
||||
<div
|
||||
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none"
|
||||
style="background: linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(359,100%,50%));"
|
||||
onmousedown={handleHuePick}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Hue"
|
||||
aria-valuenow={hsvH}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none"
|
||||
style="left: calc(2% + {(hsvH / 359) *
|
||||
96}%); box-shadow: 0 0 3px black;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Opacity Slider -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>
|
||||
{m.kv_opacity()}
|
||||
</span>
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white font-kv-body"
|
||||
>
|
||||
{alpha}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none"
|
||||
style="background: linear-gradient(to right, transparent, {rgbToHex(
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
)}),
|
||||
repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%) 50% / 16px 16px;"
|
||||
onmousedown={handleOpacityPick}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Opacity"
|
||||
aria-valuenow={alpha}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none"
|
||||
style="left: calc(2% + {alpha *
|
||||
0.96}%); box-shadow: 0 0 3px black;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview & HEX -->
|
||||
<div class="flex gap-2 md:gap-4 items-center">
|
||||
<div
|
||||
class="w-12 h-12 md:w-16 md:h-16 border-2 md:border-4 border-black shrink-0"
|
||||
style="background-color: rgba({r}, {g}, {b}, {alpha /
|
||||
100});
|
||||
background-image: repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%);
|
||||
background-size: 8px 8px;
|
||||
background-blend-mode: normal;"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full"
|
||||
style="background-color: rgba({r}, {g}, {b}, {alpha /
|
||||
100});"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>HEX</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={hexInput}
|
||||
oninput={updateFromHex}
|
||||
onblur={updateFromHex}
|
||||
onkeydown={(e) => e.key === "Enter" && updateFromHex()}
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-2 md:px-3 py-1.5 md:py-2 text-kv-white font-kv-body text-base md:text-lg uppercase"
|
||||
maxlength="9"
|
||||
placeholder="#RRGGBB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RGB -->
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>RGB</span
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1 md:gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={r}
|
||||
oninput={updateFromRgb}
|
||||
min="0"
|
||||
max="255"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={g}
|
||||
oninput={updateFromRgb}
|
||||
min="0"
|
||||
max="255"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={b}
|
||||
oninput={updateFromRgb}
|
||||
min="0"
|
||||
max="255"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HSV -->
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>HSV</span
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1 md:gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hsvH}
|
||||
oninput={updateFromHsv}
|
||||
min="0"
|
||||
max="360"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hsvS}
|
||||
oninput={updateFromHsv}
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hsvV}
|
||||
oninput={updateFromHsv}
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HSL -->
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>HSL</span
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1 md:gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hslH}
|
||||
oninput={updateFromHsl}
|
||||
min="0"
|
||||
max="360"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hslS}
|
||||
oninput={updateFromHsl}
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={hslL}
|
||||
oninput={updateFromHsl}
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale -->
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>Grayscale</span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={gray}
|
||||
oninput={updateFromGray}
|
||||
min="0"
|
||||
max="255"
|
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Done Button -->
|
||||
<button
|
||||
onclick={closePicker}
|
||||
class="bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-6 py-3 cursor-pointer hover:opacity-80 text-xl kv-shadow-button"
|
||||
>
|
||||
{m.kv_done()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={showConfirmClose}
|
||||
title={m.kv_confirm_close_title()}
|
||||
message={m.kv_confirm_close_message()}
|
||||
confirmText={m.kv_confirm_discard()}
|
||||
cancelText={m.kv_confirm_cancel()}
|
||||
onconfirm={confirmClose}
|
||||
oncancel={cancelClose}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
81
src/lib/components/ConfirmDialog.svelte
Normal file
81
src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onconfirm?: () => void;
|
||||
oncancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = m.kv_confirm_close_title(),
|
||||
message = m.kv_confirm_close_message(),
|
||||
confirmText = m.kv_confirm_discard(),
|
||||
cancelText = m.kv_confirm_cancel(),
|
||||
onconfirm,
|
||||
oncancel,
|
||||
}: ConfirmDialogProps = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
open = false;
|
||||
onconfirm?.();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
oncancel?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") handleCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-kv-background/70 z-[100]"
|
||||
onclick={handleCancel}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => e.key === "Enter" && handleCancel()}
|
||||
></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[110]
|
||||
bg-kv-blue border-4 md:border-8 border-kv-black
|
||||
p-4 md:p-6 w-[90vw] max-w-[380px]
|
||||
flex flex-col gap-4 items-center text-center"
|
||||
>
|
||||
<h3
|
||||
class="text-xl md:text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p class="text-sm md:text-base text-kv-white font-kv-body">
|
||||
{message}
|
||||
</p>
|
||||
<div class="flex gap-3 w-full">
|
||||
<button
|
||||
onclick={handleCancel}
|
||||
class="flex-1 bg-kv-blue border-4 border-black font-kv-body text-kv-white uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button"
|
||||
>
|
||||
<span class="kv-shadow-text">{cancelText}</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleConfirm}
|
||||
class="flex-1 bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Slider from "./Slider.svelte";
|
||||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||
import {
|
||||
KvButtonPrimary,
|
||||
KvButtonSecondary,
|
||||
@@ -16,16 +18,37 @@
|
||||
|
||||
let { open = $bindable(false), onclose }: SettingsProps = $props();
|
||||
|
||||
// Close without saving (revert colors)
|
||||
function handleCancel() {
|
||||
// Confirmation dialog state
|
||||
let showConfirmClose = $state(false);
|
||||
|
||||
// Show confirmation before closing
|
||||
function handleCloseClick() {
|
||||
showConfirmClose = true;
|
||||
}
|
||||
|
||||
// Confirm close - revert and close
|
||||
function confirmClose() {
|
||||
showConfirmClose = false;
|
||||
themeStore.revert();
|
||||
audioStore.revert();
|
||||
open = false;
|
||||
onclose?.();
|
||||
}
|
||||
|
||||
// Cancel close - go back to settings
|
||||
function cancelClose() {
|
||||
showConfirmClose = false;
|
||||
}
|
||||
|
||||
// Close without saving (revert colors) - used by backdrop
|
||||
function handleCancel() {
|
||||
showConfirmClose = true;
|
||||
}
|
||||
|
||||
// Save and close
|
||||
function handleSaveAndExit() {
|
||||
themeStore.save();
|
||||
audioStore.save();
|
||||
open = false;
|
||||
onclose?.();
|
||||
}
|
||||
@@ -36,7 +59,13 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") handleCancel();
|
||||
if (e.key === "Escape") {
|
||||
if (showConfirmClose) {
|
||||
cancelClose();
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMusicChange(value: number) {
|
||||
@@ -83,7 +112,7 @@
|
||||
{m.kv_settings_title()}
|
||||
</h2>
|
||||
<button
|
||||
onclick={handleCancel}
|
||||
onclick={handleCloseClick}
|
||||
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -167,12 +196,9 @@
|
||||
>
|
||||
{m.kv_settings_primary()}
|
||||
</span>
|
||||
<input
|
||||
type="color"
|
||||
value={themeStore.primary}
|
||||
oninput={(e) =>
|
||||
(themeStore.primary = e.currentTarget.value)}
|
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
||||
<ColorPicker
|
||||
bind:value={themeStore.primary}
|
||||
onchange={(c) => (themeStore.primary = c)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Secondary Color -->
|
||||
@@ -182,12 +208,9 @@
|
||||
>
|
||||
{m.kv_settings_secondary()}
|
||||
</span>
|
||||
<input
|
||||
type="color"
|
||||
value={themeStore.secondary}
|
||||
oninput={(e) =>
|
||||
(themeStore.secondary = e.currentTarget.value)}
|
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
||||
<ColorPicker
|
||||
bind:value={themeStore.secondary}
|
||||
onchange={(c) => (themeStore.secondary = c)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Text Color -->
|
||||
@@ -197,11 +220,9 @@
|
||||
>
|
||||
{m.kv_settings_text_color()}
|
||||
</span>
|
||||
<input
|
||||
type="color"
|
||||
value={themeStore.text}
|
||||
oninput={(e) => (themeStore.text = e.currentTarget.value)}
|
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
||||
<ColorPicker
|
||||
bind:value={themeStore.text}
|
||||
onchange={(c) => (themeStore.text = c)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Background Color -->
|
||||
@@ -211,12 +232,9 @@
|
||||
>
|
||||
{m.kv_settings_background()}
|
||||
</span>
|
||||
<input
|
||||
type="color"
|
||||
value={themeStore.background}
|
||||
oninput={(e) =>
|
||||
(themeStore.background = e.currentTarget.value)}
|
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
||||
<ColorPicker
|
||||
bind:value={themeStore.background}
|
||||
onchange={(c) => (themeStore.background = c)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,4 +255,15 @@
|
||||
{m.kv_settings_save_exit()}
|
||||
</KvButtonSecondary>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={showConfirmClose}
|
||||
title={m.kv_confirm_close_title()}
|
||||
message={m.kv_confirm_close_message()}
|
||||
confirmText={m.kv_confirm_discard()}
|
||||
cancelText={m.kv_confirm_cancel()}
|
||||
onconfirm={confirmClose}
|
||||
oncancel={cancelClose}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,8 @@ export { default as Slider } from './Slider.svelte';
|
||||
export { default as Settings } from './Settings.svelte';
|
||||
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
|
||||
export { default as Toast } from './Toast.svelte';
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
|
||||
export { default as ColorPicker } from './ColorPicker.svelte';
|
||||
|
||||
// Kuldvillak Components
|
||||
export * from './kuldvillak';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue font-kv-body text-kv-white text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button";
|
||||
"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";
|
||||
</script>
|
||||
|
||||
{#if href && !disabled}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow font-kv-body text-black text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button";
|
||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-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";
|
||||
</script>
|
||||
|
||||
{#if href && !disabled}
|
||||
|
||||
208
src/lib/components/kuldvillak/ui/KvEditCard.svelte
Normal file
208
src/lib/components/kuldvillak/ui/KvEditCard.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import KvNumberInput from "./KvNumberInput.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
score: number;
|
||||
scoreAdjustment?: number;
|
||||
answering?: boolean;
|
||||
selectable?: boolean;
|
||||
// Final round mode
|
||||
finalMode?: boolean;
|
||||
finalJudged?: boolean;
|
||||
finalActive?: boolean;
|
||||
onFinalJudge?: (correct: boolean, wager: number) => void;
|
||||
// Event handlers
|
||||
onSelect?: () => void;
|
||||
onAdd?: () => void;
|
||||
onRemove?: () => void;
|
||||
onCorrect?: () => void;
|
||||
onWrong?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
score,
|
||||
scoreAdjustment = 100,
|
||||
answering = false,
|
||||
selectable = false,
|
||||
finalMode = false,
|
||||
finalJudged = false,
|
||||
finalActive = false,
|
||||
onFinalJudge,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onCorrect,
|
||||
onWrong,
|
||||
}: Props = $props();
|
||||
|
||||
// Final round state
|
||||
let finalAnswerCorrect = $state<boolean | null>(null);
|
||||
let finalWagerInput = $state(0);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative bg-kv-blue flex flex-col gap-4 items-center justify-center p-4 flex-1 min-w-0 box-border
|
||||
{answering || finalActive
|
||||
? 'border-8 border-kv-yellow'
|
||||
: 'border-8 border-transparent'}
|
||||
{selectable && !finalJudged ? 'cursor-pointer' : ''}
|
||||
{finalJudged ? 'opacity-60' : ''}"
|
||||
onclick={selectable && !finalJudged ? onSelect : undefined}
|
||||
onkeydown={selectable && !finalJudged
|
||||
? (e) => e.key === "Enter" && onSelect?.()
|
||||
: undefined}
|
||||
role={selectable && !finalJudged ? "button" : undefined}
|
||||
tabindex={selectable && !finalJudged ? 0 : undefined}
|
||||
>
|
||||
<!-- Hover overlay - darkens background only, extends to cover border -->
|
||||
{#if selectable && !finalJudged}
|
||||
<div
|
||||
class="absolute -inset-2 bg-black opacity-0 group-hover:opacity-20 transition-opacity pointer-events-none z-0"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Name and Score -->
|
||||
<div
|
||||
class="relative z-10 flex flex-col gap-4 items-center font-kv-body text-kv-white text-center uppercase w-full
|
||||
{selectable && !finalJudged
|
||||
? 'group-hover:text-kv-yellow transition-colors'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-2xl md:text-4xl kv-shadow-text">{name}</span>
|
||||
<span
|
||||
class="text-2xl md:text-4xl kv-shadow-text {score < 0
|
||||
? 'text-kv-red'
|
||||
: ''}">{score}€</span
|
||||
>
|
||||
{#if finalJudged}
|
||||
<span class="text-sm text-kv-green">✓ {m.kv_play_judged()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="relative z-10 flex flex-col gap-2 items-center justify-center">
|
||||
{#if finalMode && finalActive}
|
||||
<!-- Final round judging UI -->
|
||||
{#if finalAnswerCorrect === null}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (finalAnswerCorrect = true)}
|
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
{m.kv_play_correct()}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (finalAnswerCorrect = false)}
|
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
{m.kv_play_wrong()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Wager input and confirm -->
|
||||
<div class="flex items-center gap-2">
|
||||
<KvNumberInput
|
||||
bind:value={finalWagerInput}
|
||||
min={0}
|
||||
max={Math.max(score, 0)}
|
||||
step={100}
|
||||
/>
|
||||
<span class="font-kv-body text-lg text-kv-white">€</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
onFinalJudge?.(finalAnswerCorrect!, finalWagerInput);
|
||||
finalAnswerCorrect = null;
|
||||
finalWagerInput = 0;
|
||||
}}
|
||||
class="bg-kv-yellow border-4 border-black box-border font-kv-body text-lg text-black uppercase px-4 py-1 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
{m.kv_play_confirm()}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if finalMode && !finalJudged && !finalActive}
|
||||
<!-- Final mode but not active - show score adjustment buttons -->
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onclick={onAdd}
|
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
+{scoreAdjustment}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={onRemove}
|
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
-{scoreAdjustment}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if answering}
|
||||
<!-- Correct/Wrong buttons when answering -->
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onclick={onCorrect}
|
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
{m.kv_play_correct()}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={onWrong}
|
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
{m.kv_play_wrong()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if !finalMode}
|
||||
<!-- Add/Remove score buttons in normal mode -->
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onclick={onAdd}
|
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
+{scoreAdjustment}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={onRemove}
|
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
|
||||
>
|
||||
-{scoreAdjustment}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Team } from "$lib/types/kuldvillak";
|
||||
|
||||
interface Props {
|
||||
team: Team;
|
||||
isActive?: boolean;
|
||||
isAnswering?: boolean;
|
||||
scoreAdjustment?: number;
|
||||
onadjust?: (delta: number) => void;
|
||||
onclick?: () => void;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
team,
|
||||
isActive = false,
|
||||
isAnswering = false,
|
||||
scoreAdjustment = 100,
|
||||
onadjust,
|
||||
onclick,
|
||||
disabled = false,
|
||||
class: className = "",
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 items-center justify-center p-4 bg-kv-board transition-all
|
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''}
|
||||
{isAnswering ? 'ring-4 ring-kv-green' : ''}
|
||||
{disabled ? 'opacity-50' : ''}
|
||||
{className}"
|
||||
role={onclick ? "button" : undefined}
|
||||
tabindex={onclick ? 0 : undefined}
|
||||
{onclick}
|
||||
onkeydown={(e) => e.key === "Enter" && onclick?.()}
|
||||
>
|
||||
<!-- Name and Score -->
|
||||
<button
|
||||
class="flex flex-col gap-2 items-center font-kv-body text-kv-white uppercase text-center w-full cursor-pointer hover:brightness-110 transition-all
|
||||
{disabled ? 'cursor-not-allowed' : ''}"
|
||||
{onclick}
|
||||
{disabled}
|
||||
>
|
||||
<span class="text-3xl kv-shadow-text">{team.name}</span>
|
||||
<span
|
||||
class="text-3xl kv-shadow-text {team.score < 0
|
||||
? 'text-kv-red'
|
||||
: ''}">{team.score}€</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<!-- Score Adjustment Buttons -->
|
||||
{#if onadjust}
|
||||
<div class="flex gap-4 items-center justify-center">
|
||||
<button
|
||||
class="flex-1 min-w-[70px] h-10 bg-kv-green border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
|
||||
onclick={() => onadjust(scoreAdjustment)}
|
||||
>
|
||||
<span class="kv-shadow-text">+{scoreAdjustment}</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 min-w-[70px] h-10 bg-kv-red border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
|
||||
onclick={() => onadjust(-scoreAdjustment)}
|
||||
>
|
||||
<span class="kv-shadow-text">-{scoreAdjustment}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
variant: "category" | "price" | "player";
|
||||
text?: string;
|
||||
score?: number;
|
||||
isRevealed?: boolean;
|
||||
isActive?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
variant,
|
||||
text = "",
|
||||
score = 0,
|
||||
isRevealed = false,
|
||||
isActive = false,
|
||||
class: className = "",
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === "category"}
|
||||
<!-- Category Card for Projector -->
|
||||
<div
|
||||
class="bg-kv-board px-4 py-8 flex items-center justify-center overflow-hidden {className}"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-kv-white text-5xl text-center uppercase leading-tight kv-shadow-text"
|
||||
>
|
||||
{text || "Kategooria"}
|
||||
</span>
|
||||
</div>
|
||||
{:else if variant === "price"}
|
||||
<!-- Price Card for Projector -->
|
||||
<div
|
||||
class="bg-kv-board flex items-center justify-center overflow-hidden {className}"
|
||||
>
|
||||
{#if isRevealed}
|
||||
<span class="opacity-0">{text}</span>
|
||||
{:else}
|
||||
<span
|
||||
class="font-kv-price text-kv-yellow text-7xl text-center kv-shadow-price"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if variant === "player"}
|
||||
<!-- Player Score Card for Projector -->
|
||||
<div
|
||||
class="bg-kv-board px-4 py-4 flex flex-col items-center justify-center gap-1 overflow-hidden
|
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''}
|
||||
{className}"
|
||||
>
|
||||
<span
|
||||
class="font-kv-body text-kv-white text-3xl text-center uppercase leading-tight kv-shadow-text"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<span
|
||||
class="font-kv-body text-3xl text-center kv-shadow-text {score < 0
|
||||
? 'text-red-500'
|
||||
: 'text-kv-white'}"
|
||||
>
|
||||
{score}€
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -8,8 +8,7 @@ export { default as KvButtonSecondary } from './KvButtonSecondary.svelte';
|
||||
export { default as KvNumberInput } from './KvNumberInput.svelte';
|
||||
|
||||
// Cards
|
||||
export { default as KvProjectorCard } from './KvProjectorCard.svelte';
|
||||
export { default as KvPlayerCard } from './KvPlayerCard.svelte';
|
||||
export { default as KvEditCard } from './KvEditCard.svelte';
|
||||
|
||||
// Branding
|
||||
export { default as KvLogo } from './KvGameLogo.svelte';
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
// Kuldvillak (Jeopardy) Types
|
||||
export * from './types/kuldvillak';
|
||||
|
||||
// Kuldvillak Store
|
||||
export { kuldvillakStore } from './stores/kuldvillak.svelte';
|
||||
// Game Session Store (live game state)
|
||||
export { gameSession } from './stores/gameSession.svelte';
|
||||
|
||||
// Theme Store
|
||||
export { themeStore } from './stores/theme.svelte';
|
||||
|
||||
// Audio Store
|
||||
export { audioStore } from './stores/audio.svelte';
|
||||
|
||||
// Persistence (Save/Load)
|
||||
export * from './stores/persistence';
|
||||
|
||||
@@ -4,16 +4,27 @@ class AudioStore {
|
||||
private audio: HTMLAudioElement | null = null;
|
||||
private initialized = false;
|
||||
|
||||
// Current values (live preview)
|
||||
musicVolume = $state(50);
|
||||
sfxVolume = $state(100);
|
||||
|
||||
// Saved values (persisted)
|
||||
private savedMusicVolume = 50;
|
||||
private savedSfxVolume = 100;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
// Load saved volumes
|
||||
const savedMusic = localStorage.getItem('kv_music_volume');
|
||||
const savedSfx = localStorage.getItem('kv_sfx_volume');
|
||||
if (savedMusic) this.musicVolume = parseInt(savedMusic);
|
||||
if (savedSfx) this.sfxVolume = parseInt(savedSfx);
|
||||
if (savedMusic) {
|
||||
this.musicVolume = parseInt(savedMusic);
|
||||
this.savedMusicVolume = this.musicVolume;
|
||||
}
|
||||
if (savedSfx) {
|
||||
this.sfxVolume = parseInt(savedSfx);
|
||||
this.savedSfxVolume = this.sfxVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,20 +46,34 @@ class AudioStore {
|
||||
});
|
||||
}
|
||||
|
||||
// Preview changes (not saved yet)
|
||||
setMusicVolume(value: number) {
|
||||
this.musicVolume = value;
|
||||
if (this.audio) {
|
||||
this.audio.volume = value / 100;
|
||||
}
|
||||
if (browser) {
|
||||
localStorage.setItem('kv_music_volume', String(value));
|
||||
}
|
||||
}
|
||||
|
||||
setSfxVolume(value: number) {
|
||||
this.sfxVolume = value;
|
||||
}
|
||||
|
||||
// Save current values to localStorage
|
||||
save() {
|
||||
if (browser) {
|
||||
localStorage.setItem('kv_sfx_volume', String(value));
|
||||
localStorage.setItem('kv_music_volume', String(this.musicVolume));
|
||||
localStorage.setItem('kv_sfx_volume', String(this.sfxVolume));
|
||||
}
|
||||
this.savedMusicVolume = this.musicVolume;
|
||||
this.savedSfxVolume = this.sfxVolume;
|
||||
}
|
||||
|
||||
// Revert to last saved values
|
||||
revert() {
|
||||
this.musicVolume = this.savedMusicVolume;
|
||||
this.sfxVolume = this.savedSfxVolume;
|
||||
if (this.audio) {
|
||||
this.audio.volume = this.musicVolume / 100;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ export interface GameSessionState {
|
||||
timerSeconds: number;
|
||||
timerMax: number;
|
||||
|
||||
// Timeout countdowns (for displaying messages)
|
||||
timeoutCountdown: number | null; // "Revealing answer in X seconds"
|
||||
revealCountdown: number | null; // "Returning to board in X seconds"
|
||||
skippingQuestion: boolean; // True when moderator clicked skip
|
||||
|
||||
// Question tracking
|
||||
questionsAnswered: number; // How many questions have been answered
|
||||
currentQuestionNumber: number; // Which question number is this (1-30)
|
||||
@@ -148,6 +153,9 @@ class GameSessionStore {
|
||||
timerRunning: false,
|
||||
timerSeconds: 0,
|
||||
timerMax: plainData.settings.defaultTimerSeconds ?? 10,
|
||||
timeoutCountdown: null,
|
||||
revealCountdown: null,
|
||||
skippingQuestion: false,
|
||||
questionsAnswered: 0,
|
||||
currentQuestionNumber: 0,
|
||||
questionResults: [],
|
||||
@@ -195,8 +203,15 @@ class GameSessionStore {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// End the game session
|
||||
// Transition to finished phase (show Kuldvillak screen)
|
||||
endGame() {
|
||||
if (!this.state) return;
|
||||
this.state.phase = "finished";
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// Fully clear the game session
|
||||
clearSession() {
|
||||
this.stopInternalTimer();
|
||||
this.state = null;
|
||||
if (browser) {
|
||||
@@ -218,6 +233,8 @@ class GameSessionStore {
|
||||
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex };
|
||||
this.state.wrongTeamIds = [];
|
||||
this.state.activeTeamId = null;
|
||||
this.state.lastAnswerCorrect = null;
|
||||
this.state.lastAnsweredTeamId = null;
|
||||
this.state.currentQuestionNumber = this.state.questionsAnswered + 1;
|
||||
|
||||
if (question.isDailyDouble) {
|
||||
@@ -274,12 +291,10 @@ class GameSessionStore {
|
||||
this.state.lastAnsweredTeamId = teamId;
|
||||
this.state.lastAnswerCorrect = true;
|
||||
|
||||
// Show answer and close after configured delay
|
||||
// Show answer and start reveal countdown
|
||||
this.state.showAnswer = true;
|
||||
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
|
||||
this.persist();
|
||||
|
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
||||
}
|
||||
|
||||
// Mark answer wrong - deducts points, adds to wrong list
|
||||
@@ -311,24 +326,25 @@ class GameSessionStore {
|
||||
// Check if all teams have answered wrong
|
||||
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id));
|
||||
if (allTeamsWrong) {
|
||||
// Everyone wrong - show answer and close
|
||||
this.state.showAnswer = true;
|
||||
// Everyone wrong - start reveal countdown
|
||||
this.state.timeoutCountdown = 5; // 5 seconds before showing answer
|
||||
this.persist();
|
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
||||
} else {
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
// Skip question - shows answer, closes after delay
|
||||
// Skip question - 5 second delay, then shows answer
|
||||
skipQuestion() {
|
||||
if (!this.state || !this.state.currentQuestion) return;
|
||||
|
||||
this.state.showAnswer = true;
|
||||
// Stop timer if running
|
||||
this.state.timerRunning = false;
|
||||
this.state.activeTeamId = null;
|
||||
// Mark as skipping and start countdown
|
||||
this.state.skippingQuestion = true;
|
||||
this.state.timeoutCountdown = 5;
|
||||
this.persist();
|
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
||||
}
|
||||
|
||||
// Actually close the question and return to board
|
||||
@@ -369,6 +385,9 @@ class GameSessionStore {
|
||||
this.state.wrongTeamIds = [];
|
||||
this.state.dailyDoubleWager = null;
|
||||
this.state.activeTeamId = null;
|
||||
this.state.timeoutCountdown = null;
|
||||
this.state.revealCountdown = null;
|
||||
this.state.skippingQuestion = false;
|
||||
this.state.phase = "board";
|
||||
|
||||
// Check if round is complete
|
||||
@@ -376,17 +395,6 @@ class GameSessionStore {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// Legacy method for compatibility
|
||||
closeQuestion(correct: boolean | null, teamId?: string | null) {
|
||||
if (correct === true && teamId) {
|
||||
this.markCorrect(teamId);
|
||||
} else if (correct === false && teamId) {
|
||||
this.markWrong(teamId);
|
||||
} else {
|
||||
this.skipQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
private checkRoundComplete() {
|
||||
if (!this.state) return;
|
||||
|
||||
@@ -414,6 +422,12 @@ class GameSessionStore {
|
||||
setActiveTeam(teamId: string | null) {
|
||||
if (!this.state) return;
|
||||
this.state.activeTeamId = teamId;
|
||||
|
||||
// Pause timer when a team is selected to answer
|
||||
if (teamId !== null && this.state.timerRunning) {
|
||||
this.state.timerRunning = false;
|
||||
}
|
||||
|
||||
this.persist();
|
||||
}
|
||||
|
||||
@@ -476,17 +490,14 @@ class GameSessionStore {
|
||||
// Final Round
|
||||
// ============================================
|
||||
|
||||
setFinalWager(teamId: string, wager: number) {
|
||||
if (!this.state) return;
|
||||
this.state.finalWagers[teamId] = wager;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
showFinalQuestion() {
|
||||
if (!this.state) return;
|
||||
this.state.phase = "final-question";
|
||||
this.state.timerMax = 30; // Set 30 second timer for final round
|
||||
this.state.timerSeconds = 30;
|
||||
this.state.timerRunning = false;
|
||||
this.state.activeTeamId = null;
|
||||
this.state.finalRevealed = [];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
@@ -496,17 +507,26 @@ class GameSessionStore {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
setFinalAnswer(teamId: string, answer: string) {
|
||||
// Reveal the final answer (after all teams judged)
|
||||
revealFinalAnswer() {
|
||||
if (!this.state) return;
|
||||
this.state.finalAnswers[teamId] = answer;
|
||||
this.state.showAnswer = true;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
revealFinalAnswer(teamId: string, correct: boolean) {
|
||||
// Select a team to judge their final answer
|
||||
selectFinalTeam(teamId: string) {
|
||||
if (!this.state) return;
|
||||
if (this.state.finalRevealed.includes(teamId)) return; // Already judged
|
||||
this.state.activeTeamId = teamId;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// Judge final answer - applies wager to score
|
||||
judgeFinalAnswer(teamId: string, correct: boolean, wager: number) {
|
||||
if (!this.state) return;
|
||||
|
||||
const team = this.state.teams.find(t => t.id === teamId);
|
||||
const wager = this.state.finalWagers[teamId] ?? 0;
|
||||
|
||||
if (team) {
|
||||
if (correct) {
|
||||
@@ -516,12 +536,10 @@ class GameSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Store the wager for display purposes
|
||||
this.state.finalWagers[teamId] = wager;
|
||||
this.state.finalRevealed.push(teamId);
|
||||
|
||||
// Check if all revealed
|
||||
if (this.state.finalRevealed.length === this.state.teams.length) {
|
||||
this.state.phase = "finished";
|
||||
}
|
||||
this.state.activeTeamId = null;
|
||||
|
||||
this.persist();
|
||||
}
|
||||
@@ -535,12 +553,71 @@ class GameSessionStore {
|
||||
if (this.timerInterval) return;
|
||||
|
||||
this.timerInterval = setInterval(() => {
|
||||
if (this.state?.timerRunning) {
|
||||
if (this.state.timerSeconds > 0) {
|
||||
this.state.timerSeconds--;
|
||||
if (!this.state) return;
|
||||
|
||||
// Handle timeout countdown (revealing answer)
|
||||
if (this.state.timeoutCountdown !== null && this.state.timeoutCountdown > 0) {
|
||||
this.state.timeoutCountdown--;
|
||||
this.persist();
|
||||
|
||||
if (this.state.timeoutCountdown === 0) {
|
||||
// Show answer and start reveal countdown
|
||||
this.state.showAnswer = true;
|
||||
this.state.timeoutCountdown = null;
|
||||
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
|
||||
this.persist();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle reveal countdown (returning to board)
|
||||
if (this.state.revealCountdown !== null && this.state.revealCountdown > 0) {
|
||||
this.state.revealCountdown--;
|
||||
this.persist();
|
||||
|
||||
if (this.state.revealCountdown === 0) {
|
||||
// Return to board
|
||||
if (this.state.currentQuestion) {
|
||||
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
|
||||
const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex];
|
||||
if (question) {
|
||||
question.isRevealed = true;
|
||||
this.state.questionsAnswered++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state and return to board
|
||||
this.state.currentQuestion = null;
|
||||
this.state.showAnswer = false;
|
||||
this.state.wrongTeamIds = [];
|
||||
this.state.dailyDoubleWager = null;
|
||||
this.state.activeTeamId = null;
|
||||
this.state.timeoutCountdown = null;
|
||||
this.state.revealCountdown = null;
|
||||
this.state.skippingQuestion = false;
|
||||
this.state.phase = "board";
|
||||
this.checkRoundComplete();
|
||||
this.persist();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal timer countdown
|
||||
if (this.state.timerRunning && this.state.timerSeconds > 0) {
|
||||
this.state.timerSeconds--;
|
||||
this.persist();
|
||||
} else if (this.state.timerRunning && this.state.timerSeconds === 0) {
|
||||
// Timer expired - start timeout countdown
|
||||
this.state.timerRunning = false;
|
||||
|
||||
// In question phase, handle timeout
|
||||
if (this.state.phase === "question") {
|
||||
// Clear active team (no one can answer now)
|
||||
this.state.activeTeamId = null;
|
||||
// Start 5 second countdown to answer reveal
|
||||
this.state.timeoutCountdown = 5;
|
||||
this.persist();
|
||||
} else {
|
||||
this.state.timerRunning = false;
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
@@ -578,15 +655,13 @@ class GameSessionStore {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// Called externally - no longer needed but kept for compatibility
|
||||
tickTimer() {
|
||||
// Timer now runs internally, this is a no-op
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
if (!this.state) return;
|
||||
this.state.timerSeconds = this.state.timerMax;
|
||||
this.state.timerRunning = false;
|
||||
// Also clear any timeout countdowns
|
||||
this.state.timeoutCountdown = null;
|
||||
this.state.revealCountdown = null;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
// ============================================
|
||||
// Kuldvillak Game State Store (Svelte 5 Runes)
|
||||
// ============================================
|
||||
|
||||
import {
|
||||
type KuldvillakGame,
|
||||
type Team,
|
||||
type Question,
|
||||
type GamePhase,
|
||||
type Round,
|
||||
type Category,
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_STATE
|
||||
} from '$lib/types/kuldvillak';
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function createEmptyGame(name: string = 'New Game'): KuldvillakGame {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
teams: [],
|
||||
rounds: [],
|
||||
finalRound: null,
|
||||
state: { ...DEFAULT_STATE }
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyRound(name: string, multiplier: number, settings: typeof DEFAULT_SETTINGS): Round {
|
||||
const categories: Category[] = [];
|
||||
for (let i = 0; i < settings.categoriesPerRound; i++) {
|
||||
const questions: Question[] = settings.pointValues.map((points) => ({
|
||||
id: generateId(),
|
||||
question: '',
|
||||
answer: '',
|
||||
points: points * multiplier,
|
||||
isDailyDouble: false,
|
||||
isRevealed: false
|
||||
}));
|
||||
categories.push({
|
||||
id: generateId(),
|
||||
name: `Category ${i + 1}`,
|
||||
questions
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
categories,
|
||||
pointMultiplier: multiplier
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Game Store Class
|
||||
// ============================================
|
||||
|
||||
class KuldvillakStore {
|
||||
// Reactive state using Svelte 5 runes
|
||||
game = $state<KuldvillakGame | null>(null);
|
||||
savedGames = $state<KuldvillakGame[]>([]);
|
||||
|
||||
// Derived values
|
||||
get currentRound(): Round | null {
|
||||
if (!this.game) return null;
|
||||
return this.game.rounds[this.game.state.currentRoundIndex] ?? null;
|
||||
}
|
||||
|
||||
get currentQuestion(): Question | null {
|
||||
if (!this.game || !this.currentRound || !this.game.state.currentQuestionId) return null;
|
||||
for (const category of this.currentRound.categories) {
|
||||
const question = category.questions.find((q) => q.id === this.game!.state.currentQuestionId);
|
||||
if (question) return question;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get currentCategory(): Category | null {
|
||||
if (!this.game || !this.currentRound || !this.game.state.currentCategoryId) return null;
|
||||
return this.currentRound.categories.find((c) => c.id === this.game!.state.currentCategoryId) ?? null;
|
||||
}
|
||||
|
||||
get activeTeam(): Team | null {
|
||||
if (!this.game || !this.game.state.activeTeamId) return null;
|
||||
return this.game.teams.find((t) => t.id === this.game!.state.activeTeamId) ?? null;
|
||||
}
|
||||
|
||||
get isRoundComplete(): boolean {
|
||||
if (!this.currentRound) return false;
|
||||
return this.currentRound.categories.every((c) => c.questions.every((q) => q.isRevealed));
|
||||
}
|
||||
|
||||
get isGameComplete(): boolean {
|
||||
if (!this.game) return false;
|
||||
const allRoundsComplete = this.game.rounds.every((round) =>
|
||||
round.categories.every((c) => c.questions.every((q) => q.isRevealed))
|
||||
);
|
||||
if (!this.game.settings.enableFinalRound) return allRoundsComplete;
|
||||
return allRoundsComplete && this.game.state.phase === 'finished';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Game Lifecycle
|
||||
// ============================================
|
||||
|
||||
newGame(name: string = 'New Game'): void {
|
||||
this.game = createEmptyGame(name);
|
||||
// Create default 2 rounds (Jeopardy + Double Jeopardy)
|
||||
this.game.rounds = [
|
||||
createEmptyRound('Round 1', 1, this.game.settings),
|
||||
createEmptyRound('Round 2', 2, this.game.settings)
|
||||
];
|
||||
}
|
||||
|
||||
loadGame(gameData: KuldvillakGame): void {
|
||||
this.game = gameData;
|
||||
}
|
||||
|
||||
resetGame(): void {
|
||||
if (!this.game) return;
|
||||
// Reset all questions to unrevealed
|
||||
for (const round of this.game.rounds) {
|
||||
for (const category of round.categories) {
|
||||
for (const question of category.questions) {
|
||||
question.isRevealed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset teams scores
|
||||
for (const team of this.game.teams) {
|
||||
team.score = 0;
|
||||
}
|
||||
// Reset state
|
||||
this.game.state = { ...DEFAULT_STATE };
|
||||
}
|
||||
|
||||
closeGame(): void {
|
||||
this.game = null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Team Management
|
||||
// ============================================
|
||||
|
||||
addTeam(name: string): void {
|
||||
if (!this.game) return;
|
||||
this.game.teams.push({
|
||||
id: generateId(),
|
||||
name,
|
||||
score: 0
|
||||
});
|
||||
}
|
||||
|
||||
removeTeam(teamId: string): void {
|
||||
if (!this.game) return;
|
||||
this.game.teams = this.game.teams.filter((t) => t.id !== teamId);
|
||||
}
|
||||
|
||||
updateTeamName(teamId: string, name: string): void {
|
||||
if (!this.game) return;
|
||||
const team = this.game.teams.find((t) => t.id === teamId);
|
||||
if (team) team.name = name;
|
||||
}
|
||||
|
||||
updateTeamScore(teamId: string, score: number): void {
|
||||
if (!this.game) return;
|
||||
const team = this.game.teams.find((t) => t.id === teamId);
|
||||
if (team) team.score = score;
|
||||
}
|
||||
|
||||
adjustTeamScore(teamId: string, delta: number): void {
|
||||
if (!this.game) return;
|
||||
const team = this.game.teams.find((t) => t.id === teamId);
|
||||
if (team) team.score += delta;
|
||||
}
|
||||
|
||||
setActiveTeam(teamId: string | null): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.activeTeamId = teamId;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Game Flow Control
|
||||
// ============================================
|
||||
|
||||
setPhase(phase: GamePhase): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.phase = phase;
|
||||
}
|
||||
|
||||
startGame(): void {
|
||||
if (!this.game || this.game.teams.length === 0) return;
|
||||
this.game.state.phase = 'board';
|
||||
this.game.state.currentRoundIndex = 0;
|
||||
}
|
||||
|
||||
selectQuestion(categoryId: string, questionId: string): void {
|
||||
if (!this.game) return;
|
||||
const question = this.findQuestion(questionId);
|
||||
if (!question || question.isRevealed) return;
|
||||
|
||||
this.game.state.currentCategoryId = categoryId;
|
||||
this.game.state.currentQuestionId = questionId;
|
||||
|
||||
if (question.isDailyDouble) {
|
||||
this.game.state.phase = 'daily-double';
|
||||
} else {
|
||||
this.game.state.phase = 'question';
|
||||
}
|
||||
}
|
||||
|
||||
setDailyDoubleWager(wager: number): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.dailyDoubleWager = wager;
|
||||
this.game.state.phase = 'question';
|
||||
}
|
||||
|
||||
revealAnswer(): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.phase = 'answer';
|
||||
}
|
||||
|
||||
markCorrect(teamId: string): void {
|
||||
if (!this.game || !this.currentQuestion) return;
|
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
|
||||
this.adjustTeamScore(teamId, points);
|
||||
this.finishQuestion();
|
||||
}
|
||||
|
||||
markIncorrect(teamId: string): void {
|
||||
if (!this.game || !this.currentQuestion) return;
|
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
|
||||
this.adjustTeamScore(teamId, -points);
|
||||
}
|
||||
|
||||
finishQuestion(): void {
|
||||
if (!this.game || !this.game.state.currentQuestionId) return;
|
||||
const question = this.findQuestion(this.game.state.currentQuestionId);
|
||||
if (question) question.isRevealed = true;
|
||||
|
||||
this.game.state.currentQuestionId = null;
|
||||
this.game.state.currentCategoryId = null;
|
||||
this.game.state.dailyDoubleWager = null;
|
||||
this.game.state.activeTeamId = null;
|
||||
|
||||
// Check if round is complete
|
||||
if (this.isRoundComplete) {
|
||||
this.advanceRound();
|
||||
} else {
|
||||
this.game.state.phase = 'board';
|
||||
}
|
||||
}
|
||||
|
||||
advanceRound(): void {
|
||||
if (!this.game) return;
|
||||
const nextIndex = this.game.state.currentRoundIndex + 1;
|
||||
if (nextIndex < this.game.rounds.length) {
|
||||
this.game.state.currentRoundIndex = nextIndex;
|
||||
this.game.state.phase = 'board';
|
||||
} else if (this.game.settings.enableFinalRound && this.game.finalRound) {
|
||||
this.game.state.phase = 'final-category';
|
||||
} else {
|
||||
this.game.state.phase = 'finished';
|
||||
}
|
||||
}
|
||||
|
||||
// Final Round
|
||||
startFinalRound(): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.phase = 'final-question';
|
||||
}
|
||||
|
||||
setFinalWager(teamId: string, wager: number): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.finalWagers[teamId] = wager;
|
||||
}
|
||||
|
||||
setFinalAnswer(teamId: string, answer: string): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.finalAnswers[teamId] = answer;
|
||||
}
|
||||
|
||||
scoreFinalAnswer(teamId: string, correct: boolean): void {
|
||||
if (!this.game) return;
|
||||
const wager = this.game.state.finalWagers[teamId] ?? 0;
|
||||
this.adjustTeamScore(teamId, correct ? wager : -wager);
|
||||
}
|
||||
|
||||
finishGame(): void {
|
||||
if (!this.game) return;
|
||||
this.game.state.phase = 'finished';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Editor Functions
|
||||
// ============================================
|
||||
|
||||
updateGameName(name: string): void {
|
||||
if (!this.game) return;
|
||||
this.game.name = name;
|
||||
this.game.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
updateCategoryName(roundIndex: number, categoryId: string, name: string): void {
|
||||
if (!this.game) return;
|
||||
const category = this.game.rounds[roundIndex]?.categories.find((c) => c.id === categoryId);
|
||||
if (category) category.name = name;
|
||||
}
|
||||
|
||||
updateQuestion(questionId: string, updates: Partial<Pick<Question, 'question' | 'answer' | 'isDailyDouble'>>): void {
|
||||
if (!this.game) return;
|
||||
const question = this.findQuestion(questionId);
|
||||
if (question) {
|
||||
if (updates.question !== undefined) question.question = updates.question;
|
||||
if (updates.answer !== undefined) question.answer = updates.answer;
|
||||
if (updates.isDailyDouble !== undefined) question.isDailyDouble = updates.isDailyDouble;
|
||||
}
|
||||
}
|
||||
|
||||
updateFinalRound(category: string, question: string, answer: string): void {
|
||||
if (!this.game) return;
|
||||
this.game.finalRound = { category, question, answer };
|
||||
}
|
||||
|
||||
addRound(): void {
|
||||
if (!this.game) return;
|
||||
const multiplier = this.game.rounds.length + 1;
|
||||
this.game.rounds.push(createEmptyRound(`Round ${multiplier}`, multiplier, this.game.settings));
|
||||
}
|
||||
|
||||
removeRound(roundIndex: number): void {
|
||||
if (!this.game || this.game.rounds.length <= 1) return;
|
||||
this.game.rounds.splice(roundIndex, 1);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
private findQuestion(questionId: string): Question | null {
|
||||
if (!this.game) return null;
|
||||
for (const round of this.game.rounds) {
|
||||
for (const category of round.categories) {
|
||||
const question = category.questions.find((q) => q.id === questionId);
|
||||
if (question) return question;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const kuldvillakStore = new KuldvillakStore();
|
||||
@@ -1,6 +1,13 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const THEME_STORAGE_KEY = "kuldvillak-theme";
|
||||
const THEME_CHANNEL_NAME = "kuldvillak-theme-sync";
|
||||
|
||||
// BroadcastChannel for syncing theme across windows
|
||||
let channel: BroadcastChannel | null = null;
|
||||
if (browser) {
|
||||
channel = new BroadcastChannel(THEME_CHANNEL_NAME);
|
||||
}
|
||||
|
||||
// Default theme colors
|
||||
export const DEFAULT_THEME = {
|
||||
@@ -45,15 +52,32 @@ let savedSecondary = $state(initialTheme.secondary);
|
||||
let savedText = $state(initialTheme.text);
|
||||
let savedBackground = $state(initialTheme.background);
|
||||
|
||||
function applyTheme() {
|
||||
function applyTheme(broadcast = true) {
|
||||
if (browser) {
|
||||
document.documentElement.style.setProperty("--kv-blue", primary);
|
||||
document.documentElement.style.setProperty("--kv-yellow", secondary);
|
||||
document.documentElement.style.setProperty("--kv-text", text);
|
||||
document.documentElement.style.setProperty("--kv-background", background);
|
||||
|
||||
// Broadcast to other windows
|
||||
if (broadcast && channel) {
|
||||
channel.postMessage({ primary, secondary, text, background });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for theme changes from other windows
|
||||
if (browser && channel) {
|
||||
channel.onmessage = (event) => {
|
||||
const { primary: p, secondary: s, text: t, background: b } = event.data;
|
||||
if (p) primary = p;
|
||||
if (s) secondary = s;
|
||||
if (t) text = t;
|
||||
if (b) background = b;
|
||||
applyTheme(false); // Don't re-broadcast
|
||||
};
|
||||
}
|
||||
|
||||
// Save current values to localStorage
|
||||
function save() {
|
||||
if (browser) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export type GamePhase =
|
||||
| 'daily-double'
|
||||
| 'final-intro' // Final round intro (Kuldvillak screen)
|
||||
| 'final-category' // Reveal final round category
|
||||
| 'final-wagers' // Collect wagers from each team
|
||||
| 'final-question'
|
||||
| 'final-reveal'
|
||||
| 'final-scores'
|
||||
|
||||
Reference in New Issue
Block a user