parent
facb36a07f
commit
0955d6ca65
31 changed files with 3386 additions and 1391 deletions
@ -0,0 +1,541 @@ |
|||||||
|
# Kuldvillak/Jeopardy Game Platform - Technical Documentation |
||||||
|
|
||||||
|
## Table of Contents |
||||||
|
- [Overview](#overview) |
||||||
|
- [Tech Stack](#tech-stack) |
||||||
|
- [Architecture](#architecture) |
||||||
|
- [Directory Structure](#directory-structure) |
||||||
|
- [Game Flow](#game-flow) |
||||||
|
- [State Management](#state-management) |
||||||
|
- [Components](#components) |
||||||
|
- [Data Types](#data-types) |
||||||
|
- [Features](#features) |
||||||
|
- [Multi-Window Sync](#multi-window-sync) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Overview |
||||||
|
|
||||||
|
**Ultimate Gaming** is a web-based platform for hosting trivia game shows, currently featuring **Kuldvillak** - a Jeopardy-style quiz game. The platform supports: |
||||||
|
|
||||||
|
- Full game editor with customizable settings |
||||||
|
- Dual-screen setup (Moderator + Projector views) |
||||||
|
- Real-time cross-tab synchronization |
||||||
|
- Theme customization |
||||||
|
- Internationalization (Estonian/English) |
||||||
|
- Audio management |
||||||
|
- Persistent game storage |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Tech Stack |
||||||
|
|
||||||
|
| Technology | Version | Purpose | |
||||||
|
|------------|---------|---------| |
||||||
|
| **SvelteKit** | 2.48.5 | Full-stack framework | |
||||||
|
| **Svelte 5** | 5.43.8 | UI with Runes reactivity | |
||||||
|
| **TailwindCSS** | 4.1.17 | Utility-first styling | |
||||||
|
| **TypeScript** | 5.9.3 | Type safety | |
||||||
|
| **Paraglide** | 2.5.0 | i18n (internationalization) | |
||||||
|
| **Vite** | 7.2.2 | Build tool | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Architecture |
||||||
|
|
||||||
|
``` |
||||||
|
┌─────────────────────────────────────────────────────────────┐ |
||||||
|
│ Ultimate Gaming │ |
||||||
|
├─────────────────────────────────────────────────────────────┤ |
||||||
|
│ Routes │ |
||||||
|
│ ├── / (Home - Game Selection) │ |
||||||
|
│ ├── /kuldvillak (Game Menu) │ |
||||||
|
│ ├── /kuldvillak/edit (Game Editor) │ |
||||||
|
│ └── /kuldvillak/play (Game Play) │ |
||||||
|
│ ├── ModeratorView (Controls) │ |
||||||
|
│ └── ProjectorView (Display) │ |
||||||
|
├─────────────────────────────────────────────────────────────┤ |
||||||
|
│ State Management (Svelte 5 Runes) │ |
||||||
|
│ ├── gameSession.svelte.ts (Live game state + BroadcastAPI) │ |
||||||
|
│ ├── kuldvillak.svelte.ts (Game store - editor) │ |
||||||
|
│ ├── theme.svelte.ts (Color theming) │ |
||||||
|
│ ├── audio.svelte.ts (Music/SFX volumes) │ |
||||||
|
│ └── persistence.ts (localStorage utilities) │ |
||||||
|
├─────────────────────────────────────────────────────────────┤ |
||||||
|
│ Components │ |
||||||
|
│ ├── Shared (Settings, ColorPicker, Toast, etc.) │ |
||||||
|
│ └── Kuldvillak UI (Buttons, Cards, Logo, etc.) │ |
||||||
|
└─────────────────────────────────────────────────────────────┘ |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Directory Structure |
||||||
|
|
||||||
|
``` |
||||||
|
src/ |
||||||
|
├── lib/ |
||||||
|
│ ├── assets/ # Static assets (favicon) |
||||||
|
│ ├── components/ |
||||||
|
│ │ ├── kuldvillak/ |
||||||
|
│ │ │ └── ui/ # Game-specific UI components |
||||||
|
│ │ │ ├── KvButtonPrimary.svelte |
||||||
|
│ │ │ ├── KvButtonSecondary.svelte |
||||||
|
│ │ │ ├── KvEditCard.svelte |
||||||
|
│ │ │ ├── KvGameLogo.svelte |
||||||
|
│ │ │ ├── KvNumberInput.svelte |
||||||
|
│ │ │ ├── KvPlayerCard.svelte |
||||||
|
│ │ │ └── KvProjectorCard.svelte |
||||||
|
│ │ ├── ColorPicker.svelte |
||||||
|
│ │ ├── ConfirmDialog.svelte |
||||||
|
│ │ ├── LanguageSwitcher.svelte |
||||||
|
│ │ ├── Settings.svelte |
||||||
|
│ │ ├── Slider.svelte |
||||||
|
│ │ └── Toast.svelte |
||||||
|
│ ├── paraglide/ # Generated i18n code |
||||||
|
│ ├── stores/ |
||||||
|
│ │ ├── audio.svelte.ts |
||||||
|
│ │ ├── gameSession.svelte.ts |
||||||
|
│ │ ├── kuldvillak.svelte.ts |
||||||
|
│ │ ├── persistence.ts |
||||||
|
│ │ └── theme.svelte.ts |
||||||
|
│ ├── types/ |
||||||
|
│ │ └── kuldvillak.ts # TypeScript interfaces |
||||||
|
│ └── index.ts # Library exports |
||||||
|
├── routes/ |
||||||
|
│ ├── kuldvillak/ |
||||||
|
│ │ ├── edit/ |
||||||
|
│ │ │ └── +page.svelte # Game editor |
||||||
|
│ │ ├── play/ |
||||||
|
│ │ │ ├── +page.svelte # Route handler |
||||||
|
│ │ │ ├── ModeratorView.svelte |
||||||
|
│ │ │ └── ProjectorView.svelte |
||||||
|
│ │ ├── +layout.svelte |
||||||
|
│ │ └── +page.svelte # Game menu |
||||||
|
│ ├── +error.svelte |
||||||
|
│ ├── +layout.svelte # Root layout |
||||||
|
│ ├── +page.svelte # Home page |
||||||
|
│ └── layout.css # Global styles + Tailwind |
||||||
|
├── app.html |
||||||
|
├── app.d.ts |
||||||
|
├── hooks.server.ts |
||||||
|
└── hooks.ts |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Game Flow |
||||||
|
|
||||||
|
### 1. Game Creation (Editor) |
||||||
|
``` |
||||||
|
/kuldvillak/edit |
||||||
|
│ |
||||||
|
├── Configure Settings |
||||||
|
│ ├── Number of rounds (1-2) |
||||||
|
│ ├── Timer durations |
||||||
|
│ ├── Point values (preset or custom) |
||||||
|
│ ├── Daily doubles per round |
||||||
|
│ └── Enable/disable final round |
||||||
|
│ |
||||||
|
├── Add Teams (2-6 players) |
||||||
|
│ |
||||||
|
├── Fill Categories & Questions |
||||||
|
│ ├── 6 categories per round |
||||||
|
│ ├── 5 questions per category |
||||||
|
│ └── Mark daily doubles |
||||||
|
│ |
||||||
|
├── Final Round Question (optional) |
||||||
|
│ |
||||||
|
└── Start Game → Opens Moderator + Projector views |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Game Phases |
||||||
|
``` |
||||||
|
┌─────────────┐ |
||||||
|
│ intro │ ← Kuldvillak logo screen |
||||||
|
└──────┬──────┘ |
||||||
|
│ |
||||||
|
┌──────▼──────────────┐ |
||||||
|
│ intro-categories │ ← Category reveal animation |
||||||
|
└──────┬──────────────┘ |
||||||
|
│ |
||||||
|
┌──────▼──────┐ |
||||||
|
│ board │ ← Question selection grid |
||||||
|
└──────┬──────┘ |
||||||
|
│ |
||||||
|
┌──────▼──────────────┐ |
||||||
|
│ daily-double (if) │ ← Wager selection |
||||||
|
└──────┬──────────────┘ |
||||||
|
│ |
||||||
|
┌──────▼──────┐ |
||||||
|
│ question │ ← Display question, teams answer |
||||||
|
└──────┬──────┘ |
||||||
|
│ |
||||||
|
├── Correct → Award points |
||||||
|
├── Wrong → Deduct points, next team |
||||||
|
└── Skip → Reveal answer |
||||||
|
│ |
||||||
|
└──────► Back to board |
||||||
|
│ |
||||||
|
│ (After all questions) |
||||||
|
│ |
||||||
|
┌──────▼────────────────┐ |
||||||
|
│ final-intro │ ← Final round logo |
||||||
|
├───────────────────────┤ |
||||||
|
│ final-category │ ← Reveal final category |
||||||
|
├───────────────────────┤ |
||||||
|
│ final-question │ ← Teams write answers + wagers |
||||||
|
├───────────────────────┤ |
||||||
|
│ final-scores │ ← Final standings |
||||||
|
└───────────────────────┘ |
||||||
|
│ |
||||||
|
┌──────▼──────┐ |
||||||
|
│ finished │ |
||||||
|
└─────────────┘ |
||||||
|
``` |
||||||
|
|
||||||
|
### 3. Question Flow |
||||||
|
``` |
||||||
|
Question Selected |
||||||
|
│ |
||||||
|
├── Is Daily Double? |
||||||
|
│ │ |
||||||
|
│ ├── YES → Select team → Enter wager → Show question |
||||||
|
│ └── NO → Show question directly |
||||||
|
│ |
||||||
|
▼ |
||||||
|
Display Question |
||||||
|
│ |
||||||
|
├── Timer starts |
||||||
|
│ |
||||||
|
├── Team clicks to answer |
||||||
|
│ │ |
||||||
|
│ ├── Timer pauses |
||||||
|
│ │ |
||||||
|
│ ├── Moderator marks Correct/Wrong |
||||||
|
│ │ │ |
||||||
|
│ │ ├── Correct → +points → Show answer → Return to board |
||||||
|
│ │ └── Wrong → -points → Continue (others can try) |
||||||
|
│ │ |
||||||
|
│ └── All teams wrong → Auto-reveal answer |
||||||
|
│ |
||||||
|
└── Timer expires → Auto-reveal answer |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## State Management |
||||||
|
|
||||||
|
### GameSessionStore (`gameSession.svelte.ts`) |
||||||
|
|
||||||
|
The heart of the game - manages live game state with cross-tab synchronization. |
||||||
|
|
||||||
|
**Key Features:** |
||||||
|
- Uses `BroadcastChannel` API for real-time sync between Moderator and Projector |
||||||
|
- Persists to `localStorage` for session recovery |
||||||
|
- Single internal timer managed by moderator view |
||||||
|
|
||||||
|
**State Interface:** |
||||||
|
```typescript |
||||||
|
interface GameSessionState { |
||||||
|
// Game data |
||||||
|
name: string; |
||||||
|
settings: GameSettings; |
||||||
|
teams: Team[]; |
||||||
|
rounds: Round[]; |
||||||
|
finalRound: FinalRound | null; |
||||||
|
|
||||||
|
// Game phase |
||||||
|
phase: GamePhase; |
||||||
|
currentRoundIndex: number; |
||||||
|
activeTeamId: string | null; |
||||||
|
|
||||||
|
// Animation state |
||||||
|
introCategoryIndex: number; |
||||||
|
categoriesIntroduced: boolean; |
||||||
|
boardRevealed: boolean; |
||||||
|
|
||||||
|
// Question state |
||||||
|
currentQuestion: { roundIndex, categoryIndex, questionIndex } | null; |
||||||
|
showAnswer: boolean; |
||||||
|
wrongTeamIds: string[]; |
||||||
|
lastAnsweredTeamId: string | null; |
||||||
|
lastAnswerCorrect: boolean | null; |
||||||
|
|
||||||
|
// Daily Double |
||||||
|
dailyDoubleWager: number | null; |
||||||
|
|
||||||
|
// Final Round |
||||||
|
finalCategoryRevealed: boolean; |
||||||
|
finalWagers: Record<string, number>; |
||||||
|
finalAnswers: Record<string, string>; |
||||||
|
finalRevealed: string[]; |
||||||
|
|
||||||
|
// Timer |
||||||
|
timerRunning: boolean; |
||||||
|
timerSeconds: number; |
||||||
|
timerMax: number; |
||||||
|
timeoutCountdown: number | null; |
||||||
|
revealCountdown: number | null; |
||||||
|
skippingQuestion: boolean; |
||||||
|
|
||||||
|
// Tracking |
||||||
|
questionsAnswered: number; |
||||||
|
currentQuestionNumber: number; |
||||||
|
questionResults: QuestionResult[]; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### ThemeStore (`theme.svelte.ts`) |
||||||
|
|
||||||
|
Manages color theming with cross-window sync. |
||||||
|
|
||||||
|
**CSS Variables controlled:** |
||||||
|
- `--kv-blue` (primary) |
||||||
|
- `--kv-yellow` (secondary) |
||||||
|
- `--kv-text` |
||||||
|
- `--kv-background` |
||||||
|
|
||||||
|
### AudioStore (`audio.svelte.ts`) |
||||||
|
|
||||||
|
Manages background music and sound effects volumes. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Components |
||||||
|
|
||||||
|
### Shared Components |
||||||
|
|
||||||
|
| Component | Purpose | |
||||||
|
|-----------|---------| |
||||||
|
| `Settings.svelte` | Modal with audio, language, and color settings | |
||||||
|
| `ColorPicker.svelte` | Full HSL color picker with swatches | |
||||||
|
| `ConfirmDialog.svelte` | Confirmation modal | |
||||||
|
| `Toast.svelte` | Notification messages | |
||||||
|
| `Slider.svelte` | Volume control slider | |
||||||
|
| `LanguageSwitcher.svelte` | ET/EN language toggle | |
||||||
|
|
||||||
|
### Kuldvillak UI Components |
||||||
|
|
||||||
|
| Component | Purpose | |
||||||
|
|-----------|---------| |
||||||
|
| `KvButtonPrimary.svelte` | Yellow primary action button | |
||||||
|
| `KvButtonSecondary.svelte` | Blue secondary action button | |
||||||
|
| `KvEditCard.svelte` | Team card for moderator (score adjust, correct/wrong) | |
||||||
|
| `KvPlayerCard.svelte` | Basic player display card | |
||||||
|
| `KvProjectorCard.svelte` | Player card for projector view | |
||||||
|
| `KvNumberInput.svelte` | Styled number input with +/- buttons | |
||||||
|
| `KvGameLogo.svelte` | SVG logo component with variants | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Data Types |
||||||
|
|
||||||
|
### Core Types (`types/kuldvillak.ts`) |
||||||
|
|
||||||
|
```typescript |
||||||
|
interface Question { |
||||||
|
id: string; |
||||||
|
question: string; |
||||||
|
answer: string; |
||||||
|
points: number; |
||||||
|
isDailyDouble: boolean; |
||||||
|
isRevealed: boolean; |
||||||
|
imageUrl?: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Category { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
questions: Question[]; |
||||||
|
} |
||||||
|
|
||||||
|
interface Round { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
categories: Category[]; |
||||||
|
pointMultiplier: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface Team { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
score: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface GameSettings { |
||||||
|
numberOfRounds: 1 | 2; |
||||||
|
pointValuePreset: 'round1' | 'round2' | 'custom' | 'multiplier'; |
||||||
|
pointValues: number[]; |
||||||
|
basePointValue: number; |
||||||
|
categoriesPerRound: number; // Default: 6 |
||||||
|
questionsPerCategory: number; // Default: 5 |
||||||
|
dailyDoublesPerRound: number[]; |
||||||
|
enableFinalRound: boolean; |
||||||
|
enableSoundEffects: boolean; |
||||||
|
allowNegativeScores: boolean; |
||||||
|
maxTeams: number; // Max: 6 |
||||||
|
defaultTimerSeconds: number; |
||||||
|
answerRevealSeconds: number; |
||||||
|
} |
||||||
|
|
||||||
|
type GamePhase = |
||||||
|
| 'intro' // Logo screen |
||||||
|
| 'intro-categories' // Category reveal |
||||||
|
| 'lobby' |
||||||
|
| 'board' // Question grid |
||||||
|
| 'question' |
||||||
|
| 'answer' |
||||||
|
| 'daily-double' |
||||||
|
| 'final-intro' |
||||||
|
| 'final-category' |
||||||
|
| 'final-wagers' |
||||||
|
| 'final-question' |
||||||
|
| 'final-reveal' |
||||||
|
| 'final-scores' |
||||||
|
| 'finished'; |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
### 1. Game Editor |
||||||
|
- Auto-save to localStorage |
||||||
|
- Import/Export JSON game files |
||||||
|
- Visual daily double indicators |
||||||
|
- Point value presets or custom |
||||||
|
- Final round configuration |
||||||
|
|
||||||
|
### 2. Moderator View |
||||||
|
- Full game control |
||||||
|
- Score adjustments |
||||||
|
- Timer control (start/stop/reset) |
||||||
|
- Question skip functionality |
||||||
|
- Open projector window button |
||||||
|
|
||||||
|
### 3. Projector View |
||||||
|
- Full-screen presentation |
||||||
|
- Animated category introductions |
||||||
|
- Staggered board reveal animation |
||||||
|
- Question expand animation |
||||||
|
- Team score display during questions |
||||||
|
- Final scores display |
||||||
|
|
||||||
|
### 4. Animations (ProjectorView) |
||||||
|
- **Category Intro**: 500ms fade-in → 1500ms shown → 500ms push-out |
||||||
|
- **Board Reveal**: Staggered 50ms per cell |
||||||
|
- **Question Expand**: From grid cell position to fullscreen |
||||||
|
|
||||||
|
### 5. Cross-Tab Sync |
||||||
|
- Real-time state sync via BroadcastChannel |
||||||
|
- localStorage persistence for recovery |
||||||
|
- Single timer owner (moderator) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Multi-Window Sync |
||||||
|
|
||||||
|
``` |
||||||
|
┌─────────────────┐ BroadcastChannel ┌─────────────────┐ |
||||||
|
│ Moderator Tab │ ◄─────────────────────────────► │ Projector Tab │ |
||||||
|
│ │ │ │ |
||||||
|
│ - Controls │ STATE_UPDATE │ - Display │ |
||||||
|
│ - Timer owner │ ─────────────────────────────────►│ - Animations │ |
||||||
|
│ - Score adjust │ │ - Read-only │ |
||||||
|
└────────┬────────┘ └─────────────────┘ |
||||||
|
│ |
||||||
|
│ localStorage |
||||||
|
▼ |
||||||
|
┌─────────────────┐ |
||||||
|
│ Session Store │ |
||||||
|
│ (persistence) │ |
||||||
|
└─────────────────┘ |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Styling |
||||||
|
|
||||||
|
### Design Tokens (CSS Variables) |
||||||
|
|
||||||
|
```css |
||||||
|
:root { |
||||||
|
/* Colors */ |
||||||
|
--kv-blue: #003B9B; /* Primary */ |
||||||
|
--kv-yellow: #FFAB00; /* Secondary/Accent */ |
||||||
|
--kv-text: #FFFFFF; |
||||||
|
--kv-background: #000000; |
||||||
|
--kv-green: #009900; /* Correct */ |
||||||
|
--kv-red: #FF3333; /* Wrong */ |
||||||
|
|
||||||
|
/* Fonts */ |
||||||
|
--kv-font-title: 'Swiss 921'; |
||||||
|
--kv-font-body: 'Swiss 921'; |
||||||
|
--kv-font-price: 'Bebas Neue'; |
||||||
|
--kv-font-question: 'ITC Korinna'; |
||||||
|
|
||||||
|
/* Shadows */ |
||||||
|
--kv-shadow-text: 6px 6px 4px rgba(0,0,0,0.5); |
||||||
|
--kv-shadow-price: 8px 8px 4px rgba(0,0,0,0.5); |
||||||
|
--kv-shadow-button: 0 4px 4px rgba(0,0,0,0.25), 8px 8px 4px rgba(0,0,0,0.5); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Tailwind Theme Extensions |
||||||
|
|
||||||
|
Custom Tailwind classes prefixed with `kv-`: |
||||||
|
- Colors: `bg-kv-blue`, `text-kv-yellow`, etc. |
||||||
|
- Fonts: `font-kv-body`, `font-kv-price`, etc. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## i18n (Internationalization) |
||||||
|
|
||||||
|
Using **Paraglide** for type-safe translations. |
||||||
|
|
||||||
|
**Supported Languages:** |
||||||
|
- Estonian (et) - Primary |
||||||
|
- English (en) |
||||||
|
|
||||||
|
**Message Files:** |
||||||
|
- `messages/et.json` |
||||||
|
- `messages/en.json` |
||||||
|
|
||||||
|
**Usage:** |
||||||
|
```svelte |
||||||
|
<script> |
||||||
|
import * as m from "$lib/paraglide/messages"; |
||||||
|
</script> |
||||||
|
|
||||||
|
{m.kv_play_daily_double()} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Development |
||||||
|
|
||||||
|
```bash |
||||||
|
# Install dependencies |
||||||
|
npm install |
||||||
|
|
||||||
|
# Start dev server |
||||||
|
npm run dev |
||||||
|
|
||||||
|
# Build for production |
||||||
|
npm run build |
||||||
|
|
||||||
|
# Type check |
||||||
|
npm run check |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Future Considerations |
||||||
|
|
||||||
|
1. **Database Backend**: Replace localStorage with proper database for persistence |
||||||
|
2. **WebSocket**: Real-time sync without BroadcastChannel limitations |
||||||
|
3. **Sound Effects**: Implement actual SFX playback |
||||||
|
4. **More Game Modes**: "Rooside Sõda" (Family Feud) placeholder exists |
||||||
|
5. **User Accounts**: Save games to cloud |
||||||
|
6. **Mobile Buzzer**: Player buzzer app for team selection |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2024 |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
||||||
@ -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} |
||||||
@ -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} |
||||||
@ -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} |
|
||||||
@ -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(); |
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue