Started working on adding mobile buzzers, a lot of rewriting

This commit is contained in:
AlacrisDevs
2025-12-12 01:47:51 +02:00
parent a3fa056c1f
commit d4a25746b2
47 changed files with 4712 additions and 1656 deletions

104
src/lib/utils/color.ts Normal file
View File

@@ -0,0 +1,104 @@
// ============================================
// WCAG & Color Utilities
// ============================================
/**
* Convert Hex color to RGB array
*/
export function hexToRgb(hex: string): [number, number, number] {
const bigint = parseInt(hex.replace('#', ''), 16);
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
}
/**
* Calculate Relative Luminance (WCAG 2.0 formula)
*/
export function getLuminance(r: number, g: number, b: number): number {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
/**
* Calculate Contrast Ratio between two colors
*/
export function getContrast(hex1: string, hex2: string): number {
const rgb1 = hexToRgb(hex1);
const rgb2 = hexToRgb(hex2);
const l1 = getLuminance(rgb1[0], rgb1[1], rgb1[2]);
const l2 = getLuminance(rgb2[0], rgb2[1], rgb2[2]);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/**
* Convert Hex to HSL
* Returns [H in degrees, S as 0-1, L as 0-1]
*/
export function hexToHsl(hex: string): [number, number, number] {
const rgb = hexToRgb(hex);
const r = rgb[0] / 255;
const g = rgb[1] / 255;
const b = rgb[2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s, l];
}
/**
* Convert HSL to Hex
* @param h Hue in degrees (0-360)
* @param s Saturation as percentage (0-100)
* @param l Lightness as percentage (0-100)
*/
export function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
/**
* Generate a random color with controlled HSL values
*/
export function generateHslColor(
hueRange: [number, number] | null,
satRange: [number, number],
lumRange: [number, number]
): string {
const h = hueRange
? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0]
: Math.floor(Math.random() * 360);
const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0];
const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0];
return hslToHex(h, s, l);
}
// Default theme colors
export const DEFAULT_THEME = {
primary: "#003B9B",
secondary: "#FFAB00",
text: "#FFFFFF",
background: "#000000",
};

View File

@@ -0,0 +1,47 @@
// Focus trap utility for modals
export function trapFocus(node: HTMLElement) {
const focusableSelectors = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
function getFocusableElements() {
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// Focus first element on mount
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}

155
src/lib/utils/validation.ts Normal file
View File

@@ -0,0 +1,155 @@
// ============================================
// JSON Validation Utilities
// Type-safe parsing with structural validation
// ============================================
import type { GameSettings, Team, Round, FinalRound } from '$lib/types/kuldvillak';
/**
* Result type for validation operations
*/
export type ValidationResult<T> =
| { success: true; data: T }
| { success: false; error: string };
/**
* Validate that a value is a non-null object
*/
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Validate that a value is an array
*/
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
/**
* Validate that a value is a string
*/
function isString(value: unknown): value is string {
return typeof value === 'string';
}
/**
* Validate that a value is a number
*/
function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}
/**
* Game data structure for import/export
*/
export interface GameData {
name?: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound?: FinalRound | null;
}
/**
* Validate and parse game data from unknown input
* @param data Unknown data to validate
* @returns Validation result with typed data or error message
*/
export function validateGameData(data: unknown): ValidationResult<GameData> {
if (!isObject(data)) {
return { success: false, error: 'Invalid data format: expected object' };
}
// Validate required fields exist
if (!isObject(data.settings)) {
return { success: false, error: 'Missing or invalid settings' };
}
if (!isArray(data.teams)) {
return { success: false, error: 'Missing or invalid teams array' };
}
if (!isArray(data.rounds)) {
return { success: false, error: 'Missing or invalid rounds array' };
}
// Validate teams structure
for (let i = 0; i < data.teams.length; i++) {
const team = data.teams[i];
if (!isObject(team)) {
return { success: false, error: `Invalid team at index ${i}` };
}
if (!isString(team.id)) {
return { success: false, error: `Team ${i} missing id` };
}
if (!isString(team.name)) {
return { success: false, error: `Team ${i} missing name` };
}
}
// Validate rounds structure
for (let i = 0; i < data.rounds.length; i++) {
const round = data.rounds[i];
if (!isObject(round)) {
return { success: false, error: `Invalid round at index ${i}` };
}
if (!isArray(round.categories)) {
return { success: false, error: `Round ${i} missing categories` };
}
}
// Clean up legacy properties from settings
const settingsObj = data.settings as Record<string, unknown>;
const { teamColors, ...cleanSettings } = settingsObj;
// Build validated game data
const gameData: GameData = {
name: isString(data.name) ? data.name : undefined,
settings: cleanSettings as unknown as GameSettings,
teams: data.teams.map((t) => {
const team = t as Record<string, unknown>;
return {
id: team.id as string,
name: team.name as string,
score: isNumber(team.score) ? team.score : 0,
};
}),
rounds: data.rounds as Round[],
finalRound: isObject(data.finalRound)
? (data.finalRound as unknown as FinalRound)
: null,
};
return { success: true, data: gameData };
}
/**
* Safely parse JSON string with validation
* @param jsonString JSON string to parse
* @returns Validation result with parsed data or error
*/
export function parseJSON<T>(jsonString: string): ValidationResult<T> {
try {
const data = JSON.parse(jsonString) as T;
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to parse JSON'
};
}
}
/**
* Parse and validate game data from JSON string
* @param jsonString JSON string containing game data
* @returns Validation result with typed game data or error
*/
export function parseGameData(jsonString: string): ValidationResult<GameData> {
const parseResult = parseJSON<unknown>(jsonString);
if (!parseResult.success) {
return parseResult;
}
return validateGameData(parseResult.data);
}