Jeopardy MVP is ready
This commit is contained in:
23
.gitignore
vendored
23
.gitignore
vendored
@@ -50,11 +50,34 @@ pnpm-debug.log*
|
|||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
|
# Security - never commit these
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.cert
|
||||||
|
*.p12
|
||||||
|
secrets.*
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# AI/Editor state
|
||||||
|
.windsurfrules
|
||||||
|
.cursor
|
||||||
|
.aider*
|
||||||
|
.continue
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.local
|
*.local
|
||||||
.history
|
.history
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
|||||||
541
DOCUMENTATION.md
Normal file
541
DOCUMENTATION.md
Normal file
@@ -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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||||
75
README.md
75
README.md
@@ -1,38 +1,73 @@
|
|||||||
# sv
|
# Ultimate Gaming 🎮
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy).
|
||||||
|
|
||||||
## Creating a project
|
## Features
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
- ✨ **Game Editor** - Create custom games with categories, questions, and daily doubles
|
||||||
|
- 🖥️ **Dual-Screen Setup** - Moderator controls + Projector display
|
||||||
|
- 🔄 **Real-time Sync** - Cross-tab synchronization via BroadcastChannel API
|
||||||
|
- 🎨 **Theme Customization** - Customize colors to match your brand
|
||||||
|
- 🌍 **Internationalization** - Estonian and English support
|
||||||
|
- 💾 **Persistent Storage** - Auto-save and load games from localStorage
|
||||||
|
|
||||||
```sh
|
## Tech Stack
|
||||||
# create a new project in the current directory
|
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
- **SvelteKit 2** + **Svelte 5** (Runes)
|
||||||
npx sv create my-app
|
- **TailwindCSS 4** for styling
|
||||||
```
|
- **TypeScript** for type safety
|
||||||
|
- **Paraglide** for i18n
|
||||||
|
|
||||||
## Developing
|
## Getting Started
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
```sh
|
# Start development server
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# Open in browser
|
||||||
npm run dev -- --open
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Usage
|
||||||
|
|
||||||
To create a production version of your app:
|
1. **Create a Game**: Go to `/kuldvillak/edit` to create a new game
|
||||||
|
2. **Add Teams**: Configure 2-6 players/teams
|
||||||
|
3. **Fill Content**: Add categories, questions, and answers
|
||||||
|
4. **Start Playing**: Click "Start" to launch the game
|
||||||
|
5. **Open Projector**: Use "Open Projector" button for display screen
|
||||||
|
|
||||||
```sh
|
## Project Structure
|
||||||
npm run build
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── stores/ # Svelte stores (gameSession, theme, audio)
|
||||||
|
│ └── types/ # TypeScript interfaces
|
||||||
|
├── routes/
|
||||||
|
│ ├── kuldvillak/
|
||||||
|
│ │ ├── edit/ # Game editor
|
||||||
|
│ │ └── play/ # Game play views
|
||||||
|
│ └── +page.svelte # Home page
|
||||||
|
└── app.html
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
## Documentation
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
See [DOCUMENTATION.md](./DOCUMENTATION.md) for detailed technical documentation.
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Feel free to use, modify, and distribute.
|
||||||
|
|
||||||
|
*This is a fan-made project inspired by Jeopardy! and its Estonian counterpart Kuldvillak. Not affiliated with or endorsed by the original shows.*
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"kv_error_404": "404",
|
"kv_error_404": "404",
|
||||||
"kv_error_not_found": "Not Found",
|
"kv_error_not_found": "Not Found",
|
||||||
"kv_error_hint": "(pssst... make sure you're on the right page)",
|
"kv_error_hint": "(pssst... make sure you're on the right page)",
|
||||||
|
"kv_edit_title": "Game Editor",
|
||||||
"kv_edit_back": "Back",
|
"kv_edit_back": "Back",
|
||||||
"kv_edit_game_name": "Game name...",
|
"kv_edit_game_name": "Game name...",
|
||||||
"kv_edit_save": "Save",
|
"kv_edit_save": "Save",
|
||||||
@@ -141,5 +142,29 @@
|
|||||||
"kv_settings_save_exit": "Save and Exit",
|
"kv_settings_save_exit": "Save and Exit",
|
||||||
"kv_edit_image_link": "Image Link",
|
"kv_edit_image_link": "Image Link",
|
||||||
"kv_edit_save_exit": "Save and Exit",
|
"kv_edit_save_exit": "Save and Exit",
|
||||||
"kv_edit_final_enabled": "Final Round Enabled"
|
"kv_edit_final_enabled": "Final Round Enabled",
|
||||||
|
"kv_play_adjust_score": "Adjust score",
|
||||||
|
"kv_play_click_team_to_answer": "Click a team to select answerer",
|
||||||
|
"kv_play_final_scores": "Final Scores",
|
||||||
|
"kv_edit_dd_short": "DD",
|
||||||
|
"kv_play_timeout_reveal": "Timer ran out, nobody can answer. Revealing answer in {seconds} seconds...",
|
||||||
|
"kv_play_answer_revealed": "Answer revealed. Returning to board in {seconds} seconds...",
|
||||||
|
"kv_play_timer_paused": "Timer paused. {name} is answering.",
|
||||||
|
"kv_play_correct_return": "{name} answered correctly! Returning to board in {seconds} seconds...",
|
||||||
|
"kv_play_wrong_waiting": "{name} answered incorrectly. Waiting for next player...",
|
||||||
|
"kv_play_wrong_reveal": "{name} answered incorrectly. Revealing answer in {seconds} seconds...",
|
||||||
|
"kv_play_skip_reveal": "Skipping question entirely. Revealing answer in {seconds} seconds...",
|
||||||
|
"kv_play_click_team_to_judge": "Click a team to judge their answer",
|
||||||
|
"kv_play_judging": "Judging",
|
||||||
|
"kv_play_enter_wager": "Enter wager",
|
||||||
|
"kv_play_judged": "Judged",
|
||||||
|
"kv_play_finish": "Finish",
|
||||||
|
"kv_color_picker": "Color Picker",
|
||||||
|
"kv_done": "Done",
|
||||||
|
"kv_opacity": "Opacity",
|
||||||
|
"kv_confirm_close_title": "Discard Changes?",
|
||||||
|
"kv_confirm_close_message": "Are you sure you want to close? Any unsaved changes will be lost.",
|
||||||
|
"kv_confirm_discard": "Discard",
|
||||||
|
"kv_confirm_cancel": "Cancel",
|
||||||
|
"kv_final_round": "Final Round"
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"kv_error_404": "404",
|
"kv_error_404": "404",
|
||||||
"kv_error_not_found": "Lehte ei leitud",
|
"kv_error_not_found": "Lehte ei leitud",
|
||||||
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)",
|
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)",
|
||||||
|
"kv_edit_title": "Mängu redaktor",
|
||||||
"kv_edit_back": "Tagasi",
|
"kv_edit_back": "Tagasi",
|
||||||
"kv_edit_game_name": "Mängu nimi...",
|
"kv_edit_game_name": "Mängu nimi...",
|
||||||
"kv_edit_save": "Salvesta",
|
"kv_edit_save": "Salvesta",
|
||||||
@@ -141,5 +142,29 @@
|
|||||||
"kv_settings_save_exit": "Salvesta ja välju",
|
"kv_settings_save_exit": "Salvesta ja välju",
|
||||||
"kv_edit_image_link": "Pildi link",
|
"kv_edit_image_link": "Pildi link",
|
||||||
"kv_edit_save_exit": "Salvesta ja Välju",
|
"kv_edit_save_exit": "Salvesta ja Välju",
|
||||||
"kv_edit_final_enabled": "Finaalvoor lubatud"
|
"kv_edit_final_enabled": "Finaalvoor lubatud",
|
||||||
|
"kv_play_adjust_score": "Muuda skoori",
|
||||||
|
"kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks",
|
||||||
|
"kv_play_final_scores": "Lõpptulemused",
|
||||||
|
"kv_edit_dd_short": "HV",
|
||||||
|
"kv_play_timeout_reveal": "Aeg sai läbi, keegi ei saa vastata. Vastuse näitamine {seconds} sekundi pärast...",
|
||||||
|
"kv_play_answer_revealed": "Vastus näidatud. Tagasi mängulaudale {seconds} sekundi pärast...",
|
||||||
|
"kv_play_timer_paused": "Taimer peatatud. {name} vastab.",
|
||||||
|
"kv_play_correct_return": "{name} vastas õigesti! Tagasi mängulaudale {seconds} sekundi pärast...",
|
||||||
|
"kv_play_wrong_waiting": "{name} vastas valesti. Ootame järgmist mängijat...",
|
||||||
|
"kv_play_wrong_reveal": "{name} vastas valesti. Vastuse näitamine {seconds} sekundi pärast...",
|
||||||
|
"kv_play_skip_reveal": "Küsimus vahele jäetud. Vastuse näitamine {seconds} sekundi pärast...",
|
||||||
|
"kv_play_click_team_to_judge": "Kliki meeskonnal vastuse hindamiseks",
|
||||||
|
"kv_play_judging": "Hindamine",
|
||||||
|
"kv_play_enter_wager": "Sisesta panus",
|
||||||
|
"kv_play_judged": "Hinnatud",
|
||||||
|
"kv_play_finish": "Lõpeta",
|
||||||
|
"kv_color_picker": "Värvivalija",
|
||||||
|
"kv_done": "Valmis",
|
||||||
|
"kv_opacity": "Läbipaistvus",
|
||||||
|
"kv_confirm_close_title": "Loobuda muudatustest?",
|
||||||
|
"kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.",
|
||||||
|
"kv_confirm_discard": "Loobu",
|
||||||
|
"kv_confirm_cancel": "Tühista",
|
||||||
|
"kv_final_round": "Kuldvillak"
|
||||||
}
|
}
|
||||||
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">
|
<script lang="ts">
|
||||||
import Slider from "./Slider.svelte";
|
import Slider from "./Slider.svelte";
|
||||||
|
import ColorPicker from "./ColorPicker.svelte";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||||
import {
|
import {
|
||||||
KvButtonPrimary,
|
KvButtonPrimary,
|
||||||
KvButtonSecondary,
|
KvButtonSecondary,
|
||||||
@@ -16,16 +18,37 @@
|
|||||||
|
|
||||||
let { open = $bindable(false), onclose }: SettingsProps = $props();
|
let { open = $bindable(false), onclose }: SettingsProps = $props();
|
||||||
|
|
||||||
// Close without saving (revert colors)
|
// Confirmation dialog state
|
||||||
function handleCancel() {
|
let showConfirmClose = $state(false);
|
||||||
|
|
||||||
|
// Show confirmation before closing
|
||||||
|
function handleCloseClick() {
|
||||||
|
showConfirmClose = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm close - revert and close
|
||||||
|
function confirmClose() {
|
||||||
|
showConfirmClose = false;
|
||||||
themeStore.revert();
|
themeStore.revert();
|
||||||
|
audioStore.revert();
|
||||||
open = false;
|
open = false;
|
||||||
onclose?.();
|
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
|
// Save and close
|
||||||
function handleSaveAndExit() {
|
function handleSaveAndExit() {
|
||||||
themeStore.save();
|
themeStore.save();
|
||||||
|
audioStore.save();
|
||||||
open = false;
|
open = false;
|
||||||
onclose?.();
|
onclose?.();
|
||||||
}
|
}
|
||||||
@@ -36,7 +59,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") handleCancel();
|
if (e.key === "Escape") {
|
||||||
|
if (showConfirmClose) {
|
||||||
|
cancelClose();
|
||||||
|
} else {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMusicChange(value: number) {
|
function handleMusicChange(value: number) {
|
||||||
@@ -83,7 +112,7 @@
|
|||||||
{m.kv_settings_title()}
|
{m.kv_settings_title()}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onclick={handleCancel}
|
onclick={handleCloseClick}
|
||||||
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
|
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
@@ -167,12 +196,9 @@
|
|||||||
>
|
>
|
||||||
{m.kv_settings_primary()}
|
{m.kv_settings_primary()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<ColorPicker
|
||||||
type="color"
|
bind:value={themeStore.primary}
|
||||||
value={themeStore.primary}
|
onchange={(c) => (themeStore.primary = c)}
|
||||||
oninput={(e) =>
|
|
||||||
(themeStore.primary = e.currentTarget.value)}
|
|
||||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Secondary Color -->
|
<!-- Secondary Color -->
|
||||||
@@ -182,12 +208,9 @@
|
|||||||
>
|
>
|
||||||
{m.kv_settings_secondary()}
|
{m.kv_settings_secondary()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<ColorPicker
|
||||||
type="color"
|
bind:value={themeStore.secondary}
|
||||||
value={themeStore.secondary}
|
onchange={(c) => (themeStore.secondary = c)}
|
||||||
oninput={(e) =>
|
|
||||||
(themeStore.secondary = e.currentTarget.value)}
|
|
||||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Text Color -->
|
<!-- Text Color -->
|
||||||
@@ -197,11 +220,9 @@
|
|||||||
>
|
>
|
||||||
{m.kv_settings_text_color()}
|
{m.kv_settings_text_color()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<ColorPicker
|
||||||
type="color"
|
bind:value={themeStore.text}
|
||||||
value={themeStore.text}
|
onchange={(c) => (themeStore.text = c)}
|
||||||
oninput={(e) => (themeStore.text = e.currentTarget.value)}
|
|
||||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Background Color -->
|
<!-- Background Color -->
|
||||||
@@ -211,12 +232,9 @@
|
|||||||
>
|
>
|
||||||
{m.kv_settings_background()}
|
{m.kv_settings_background()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<ColorPicker
|
||||||
type="color"
|
bind:value={themeStore.background}
|
||||||
value={themeStore.background}
|
onchange={(c) => (themeStore.background = c)}
|
||||||
oninput={(e) =>
|
|
||||||
(themeStore.background = e.currentTarget.value)}
|
|
||||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,4 +255,15 @@
|
|||||||
{m.kv_settings_save_exit()}
|
{m.kv_settings_save_exit()}
|
||||||
</KvButtonSecondary>
|
</KvButtonSecondary>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export { default as Slider } from './Slider.svelte';
|
|||||||
export { default as Settings } from './Settings.svelte';
|
export { default as Settings } from './Settings.svelte';
|
||||||
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
|
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
|
||||||
export { default as Toast } from './Toast.svelte';
|
export { default as Toast } from './Toast.svelte';
|
||||||
|
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
|
||||||
|
export { default as ColorPicker } from './ColorPicker.svelte';
|
||||||
|
|
||||||
// Kuldvillak Components
|
// Kuldvillak Components
|
||||||
export * from './kuldvillak';
|
export * from './kuldvillak';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const baseClasses =
|
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>
|
</script>
|
||||||
|
|
||||||
{#if href && !disabled}
|
{#if href && !disabled}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const baseClasses =
|
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>
|
</script>
|
||||||
|
|
||||||
{#if href && !disabled}
|
{#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';
|
export { default as KvNumberInput } from './KvNumberInput.svelte';
|
||||||
|
|
||||||
// Cards
|
// Cards
|
||||||
export { default as KvProjectorCard } from './KvProjectorCard.svelte';
|
export { default as KvEditCard } from './KvEditCard.svelte';
|
||||||
export { default as KvPlayerCard } from './KvPlayerCard.svelte';
|
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
export { default as KvLogo } from './KvGameLogo.svelte';
|
export { default as KvLogo } from './KvGameLogo.svelte';
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
// Kuldvillak (Jeopardy) Types
|
// Kuldvillak (Jeopardy) Types
|
||||||
export * from './types/kuldvillak';
|
export * from './types/kuldvillak';
|
||||||
|
|
||||||
// Kuldvillak Store
|
// Game Session Store (live game state)
|
||||||
export { kuldvillakStore } from './stores/kuldvillak.svelte';
|
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)
|
// Persistence (Save/Load)
|
||||||
export * from './stores/persistence';
|
export * from './stores/persistence';
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ class AudioStore {
|
|||||||
private audio: HTMLAudioElement | null = null;
|
private audio: HTMLAudioElement | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
|
// Current values (live preview)
|
||||||
musicVolume = $state(50);
|
musicVolume = $state(50);
|
||||||
sfxVolume = $state(100);
|
sfxVolume = $state(100);
|
||||||
|
|
||||||
|
// Saved values (persisted)
|
||||||
|
private savedMusicVolume = 50;
|
||||||
|
private savedSfxVolume = 100;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Load saved volumes
|
// Load saved volumes
|
||||||
const savedMusic = localStorage.getItem('kv_music_volume');
|
const savedMusic = localStorage.getItem('kv_music_volume');
|
||||||
const savedSfx = localStorage.getItem('kv_sfx_volume');
|
const savedSfx = localStorage.getItem('kv_sfx_volume');
|
||||||
if (savedMusic) this.musicVolume = parseInt(savedMusic);
|
if (savedMusic) {
|
||||||
if (savedSfx) this.sfxVolume = parseInt(savedSfx);
|
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) {
|
setMusicVolume(value: number) {
|
||||||
this.musicVolume = value;
|
this.musicVolume = value;
|
||||||
if (this.audio) {
|
if (this.audio) {
|
||||||
this.audio.volume = value / 100;
|
this.audio.volume = value / 100;
|
||||||
}
|
}
|
||||||
if (browser) {
|
|
||||||
localStorage.setItem('kv_music_volume', String(value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSfxVolume(value: number) {
|
setSfxVolume(value: number) {
|
||||||
this.sfxVolume = value;
|
this.sfxVolume = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current values to localStorage
|
||||||
|
save() {
|
||||||
if (browser) {
|
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;
|
timerSeconds: number;
|
||||||
timerMax: 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
|
// Question tracking
|
||||||
questionsAnswered: number; // How many questions have been answered
|
questionsAnswered: number; // How many questions have been answered
|
||||||
currentQuestionNumber: number; // Which question number is this (1-30)
|
currentQuestionNumber: number; // Which question number is this (1-30)
|
||||||
@@ -148,6 +153,9 @@ class GameSessionStore {
|
|||||||
timerRunning: false,
|
timerRunning: false,
|
||||||
timerSeconds: 0,
|
timerSeconds: 0,
|
||||||
timerMax: plainData.settings.defaultTimerSeconds ?? 10,
|
timerMax: plainData.settings.defaultTimerSeconds ?? 10,
|
||||||
|
timeoutCountdown: null,
|
||||||
|
revealCountdown: null,
|
||||||
|
skippingQuestion: false,
|
||||||
questionsAnswered: 0,
|
questionsAnswered: 0,
|
||||||
currentQuestionNumber: 0,
|
currentQuestionNumber: 0,
|
||||||
questionResults: [],
|
questionResults: [],
|
||||||
@@ -195,8 +203,15 @@ class GameSessionStore {
|
|||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// End the game session
|
// Transition to finished phase (show Kuldvillak screen)
|
||||||
endGame() {
|
endGame() {
|
||||||
|
if (!this.state) return;
|
||||||
|
this.state.phase = "finished";
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully clear the game session
|
||||||
|
clearSession() {
|
||||||
this.stopInternalTimer();
|
this.stopInternalTimer();
|
||||||
this.state = null;
|
this.state = null;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -218,6 +233,8 @@ class GameSessionStore {
|
|||||||
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex };
|
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex };
|
||||||
this.state.wrongTeamIds = [];
|
this.state.wrongTeamIds = [];
|
||||||
this.state.activeTeamId = null;
|
this.state.activeTeamId = null;
|
||||||
|
this.state.lastAnswerCorrect = null;
|
||||||
|
this.state.lastAnsweredTeamId = null;
|
||||||
this.state.currentQuestionNumber = this.state.questionsAnswered + 1;
|
this.state.currentQuestionNumber = this.state.questionsAnswered + 1;
|
||||||
|
|
||||||
if (question.isDailyDouble) {
|
if (question.isDailyDouble) {
|
||||||
@@ -274,12 +291,10 @@ class GameSessionStore {
|
|||||||
this.state.lastAnsweredTeamId = teamId;
|
this.state.lastAnsweredTeamId = teamId;
|
||||||
this.state.lastAnswerCorrect = true;
|
this.state.lastAnswerCorrect = true;
|
||||||
|
|
||||||
// Show answer and close after configured delay
|
// Show answer and start reveal countdown
|
||||||
this.state.showAnswer = true;
|
this.state.showAnswer = true;
|
||||||
|
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
|
||||||
this.persist();
|
this.persist();
|
||||||
|
|
||||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
|
||||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark answer wrong - deducts points, adds to wrong list
|
// Mark answer wrong - deducts points, adds to wrong list
|
||||||
@@ -311,24 +326,25 @@ class GameSessionStore {
|
|||||||
// Check if all teams have answered wrong
|
// Check if all teams have answered wrong
|
||||||
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id));
|
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id));
|
||||||
if (allTeamsWrong) {
|
if (allTeamsWrong) {
|
||||||
// Everyone wrong - show answer and close
|
// Everyone wrong - start reveal countdown
|
||||||
this.state.showAnswer = true;
|
this.state.timeoutCountdown = 5; // 5 seconds before showing answer
|
||||||
this.persist();
|
this.persist();
|
||||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
|
||||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
|
||||||
} else {
|
} else {
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip question - shows answer, closes after delay
|
// Skip question - 5 second delay, then shows answer
|
||||||
skipQuestion() {
|
skipQuestion() {
|
||||||
if (!this.state || !this.state.currentQuestion) return;
|
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();
|
this.persist();
|
||||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
|
|
||||||
setTimeout(() => this.finalizeQuestion(), revealMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actually close the question and return to board
|
// Actually close the question and return to board
|
||||||
@@ -369,6 +385,9 @@ class GameSessionStore {
|
|||||||
this.state.wrongTeamIds = [];
|
this.state.wrongTeamIds = [];
|
||||||
this.state.dailyDoubleWager = null;
|
this.state.dailyDoubleWager = null;
|
||||||
this.state.activeTeamId = null;
|
this.state.activeTeamId = null;
|
||||||
|
this.state.timeoutCountdown = null;
|
||||||
|
this.state.revealCountdown = null;
|
||||||
|
this.state.skippingQuestion = false;
|
||||||
this.state.phase = "board";
|
this.state.phase = "board";
|
||||||
|
|
||||||
// Check if round is complete
|
// Check if round is complete
|
||||||
@@ -376,17 +395,6 @@ class GameSessionStore {
|
|||||||
this.persist();
|
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() {
|
private checkRoundComplete() {
|
||||||
if (!this.state) return;
|
if (!this.state) return;
|
||||||
|
|
||||||
@@ -414,6 +422,12 @@ class GameSessionStore {
|
|||||||
setActiveTeam(teamId: string | null) {
|
setActiveTeam(teamId: string | null) {
|
||||||
if (!this.state) return;
|
if (!this.state) return;
|
||||||
this.state.activeTeamId = teamId;
|
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();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,17 +490,14 @@ class GameSessionStore {
|
|||||||
// Final Round
|
// Final Round
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
setFinalWager(teamId: string, wager: number) {
|
|
||||||
if (!this.state) return;
|
|
||||||
this.state.finalWagers[teamId] = wager;
|
|
||||||
this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
showFinalQuestion() {
|
showFinalQuestion() {
|
||||||
if (!this.state) return;
|
if (!this.state) return;
|
||||||
this.state.phase = "final-question";
|
this.state.phase = "final-question";
|
||||||
this.state.timerMax = 30; // Set 30 second timer for final round
|
this.state.timerMax = 30; // Set 30 second timer for final round
|
||||||
this.state.timerSeconds = 30;
|
this.state.timerSeconds = 30;
|
||||||
|
this.state.timerRunning = false;
|
||||||
|
this.state.activeTeamId = null;
|
||||||
|
this.state.finalRevealed = [];
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,17 +507,26 @@ class GameSessionStore {
|
|||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setFinalAnswer(teamId: string, answer: string) {
|
// Reveal the final answer (after all teams judged)
|
||||||
|
revealFinalAnswer() {
|
||||||
if (!this.state) return;
|
if (!this.state) return;
|
||||||
this.state.finalAnswers[teamId] = answer;
|
this.state.showAnswer = true;
|
||||||
this.persist();
|
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;
|
if (!this.state) return;
|
||||||
|
|
||||||
const team = this.state.teams.find(t => t.id === teamId);
|
const team = this.state.teams.find(t => t.id === teamId);
|
||||||
const wager = this.state.finalWagers[teamId] ?? 0;
|
|
||||||
|
|
||||||
if (team) {
|
if (team) {
|
||||||
if (correct) {
|
if (correct) {
|
||||||
@@ -516,12 +536,10 @@ class GameSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the wager for display purposes
|
||||||
|
this.state.finalWagers[teamId] = wager;
|
||||||
this.state.finalRevealed.push(teamId);
|
this.state.finalRevealed.push(teamId);
|
||||||
|
this.state.activeTeamId = null;
|
||||||
// Check if all revealed
|
|
||||||
if (this.state.finalRevealed.length === this.state.teams.length) {
|
|
||||||
this.state.phase = "finished";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
@@ -535,12 +553,71 @@ class GameSessionStore {
|
|||||||
if (this.timerInterval) return;
|
if (this.timerInterval) return;
|
||||||
|
|
||||||
this.timerInterval = setInterval(() => {
|
this.timerInterval = setInterval(() => {
|
||||||
if (this.state?.timerRunning) {
|
if (!this.state) return;
|
||||||
if (this.state.timerSeconds > 0) {
|
|
||||||
this.state.timerSeconds--;
|
// 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();
|
this.persist();
|
||||||
} else {
|
} else {
|
||||||
this.state.timerRunning = false;
|
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -578,15 +655,13 @@ class GameSessionStore {
|
|||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called externally - no longer needed but kept for compatibility
|
|
||||||
tickTimer() {
|
|
||||||
// Timer now runs internally, this is a no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
resetTimer() {
|
resetTimer() {
|
||||||
if (!this.state) return;
|
if (!this.state) return;
|
||||||
this.state.timerSeconds = this.state.timerMax;
|
this.state.timerSeconds = this.state.timerMax;
|
||||||
this.state.timerRunning = false;
|
this.state.timerRunning = false;
|
||||||
|
// Also clear any timeout countdowns
|
||||||
|
this.state.timeoutCountdown = null;
|
||||||
|
this.state.revealCountdown = null;
|
||||||
this.persist();
|
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";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = "kuldvillak-theme";
|
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
|
// Default theme colors
|
||||||
export const DEFAULT_THEME = {
|
export const DEFAULT_THEME = {
|
||||||
@@ -45,15 +52,32 @@ let savedSecondary = $state(initialTheme.secondary);
|
|||||||
let savedText = $state(initialTheme.text);
|
let savedText = $state(initialTheme.text);
|
||||||
let savedBackground = $state(initialTheme.background);
|
let savedBackground = $state(initialTheme.background);
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme(broadcast = true) {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
document.documentElement.style.setProperty("--kv-blue", primary);
|
document.documentElement.style.setProperty("--kv-blue", primary);
|
||||||
document.documentElement.style.setProperty("--kv-yellow", secondary);
|
document.documentElement.style.setProperty("--kv-yellow", secondary);
|
||||||
document.documentElement.style.setProperty("--kv-text", text);
|
document.documentElement.style.setProperty("--kv-text", text);
|
||||||
document.documentElement.style.setProperty("--kv-background", background);
|
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
|
// Save current values to localStorage
|
||||||
function save() {
|
function save() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type GamePhase =
|
|||||||
| 'daily-double'
|
| 'daily-double'
|
||||||
| 'final-intro' // Final round intro (Kuldvillak screen)
|
| 'final-intro' // Final round intro (Kuldvillak screen)
|
||||||
| 'final-category' // Reveal final round category
|
| 'final-category' // Reveal final round category
|
||||||
|
| 'final-wagers' // Collect wagers from each team
|
||||||
| 'final-question'
|
| 'final-question'
|
||||||
| 'final-reveal'
|
| 'final-reveal'
|
||||||
| 'final-scores'
|
| 'final-scores'
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Ultimate Gaming</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]">
|
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]">
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Kuldvillak - Ultimate Gaming</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen flex items-center justify-center overflow-hidden"
|
class="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -40,11 +44,14 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
|
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
|
||||||
<!-- Kuldvillak Logo -->
|
<!-- Kuldvillak Logo -->
|
||||||
<KvLogo size="lg" class="md:h-48 md:max-w-[768px]" />
|
<KvLogo
|
||||||
|
size="lg"
|
||||||
|
class="md:h-48 md:max-w-[768px] drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Menu Buttons -->
|
<!-- Menu Buttons -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-4 border-kv-black"
|
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-2 border-kv-black"
|
||||||
>
|
>
|
||||||
<KvButtonPrimary
|
<KvButtonPrimary
|
||||||
href="/kuldvillak/edit"
|
href="/kuldvillak/edit"
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { Toast, Settings } from "$lib/components";
|
import { Toast, Settings, ConfirmDialog } from "$lib/components";
|
||||||
import * as m from "$lib/paraglide/messages";
|
import * as m from "$lib/paraglide/messages";
|
||||||
import { gameSession } from "$lib/stores/gameSession.svelte";
|
import { gameSession } from "$lib/stores/gameSession.svelte";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
|
||||||
import type {
|
import type {
|
||||||
GameSettings,
|
GameSettings,
|
||||||
Team,
|
Team,
|
||||||
@@ -54,6 +53,24 @@
|
|||||||
// File input ref
|
// File input ref
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
// Confirm dialog states
|
||||||
|
let showResetConfirm = $state(false);
|
||||||
|
let showQuestionCloseConfirm = $state(false);
|
||||||
|
let showFinalCloseConfirm = $state(false);
|
||||||
|
|
||||||
|
// Original values for reverting
|
||||||
|
let originalQuestion = $state<{
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
isDailyDouble: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
let originalFinal = $state<{
|
||||||
|
category: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Autosave to localStorage
|
// Autosave to localStorage
|
||||||
function autoSave() {
|
function autoSave() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -69,15 +86,19 @@
|
|||||||
const data = JSON.parse(saved);
|
const data = JSON.parse(saved);
|
||||||
if (data.settings && data.teams && data.rounds) {
|
if (data.settings && data.teams && data.rounds) {
|
||||||
gameName = data.name || "";
|
gameName = data.name || "";
|
||||||
const { teamColors, ...cleanSettings } = data.settings;
|
const { teamColors, ...cleanSettings } =
|
||||||
settings = { ...DEFAULT_SETTINGS, ...cleanSettings };
|
data.settings as Record<string, unknown>;
|
||||||
teams = data.teams.map((t: any) => ({
|
settings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...cleanSettings,
|
||||||
|
} as GameSettings;
|
||||||
|
teams = (data.teams as Team[]).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
score: t.score ?? 0,
|
score: t.score ?? 0,
|
||||||
}));
|
}));
|
||||||
rounds = data.rounds;
|
rounds = data.rounds as Round[];
|
||||||
finalRound = data.finalRound || {
|
finalRound = (data.finalRound as FinalRound) || {
|
||||||
category: "",
|
category: "",
|
||||||
question: "",
|
question: "",
|
||||||
answer: "",
|
answer: "",
|
||||||
@@ -102,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return Math.random().toString(36).substring(2, 11);
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createQuestion(points: number): Question {
|
function createQuestion(points: number): Question {
|
||||||
@@ -266,7 +287,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetGame() {
|
function resetGame() {
|
||||||
if (!confirm(m.kv_edit_reset_confirm())) return;
|
|
||||||
settings = {
|
settings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
defaultTimerSeconds: 5,
|
defaultTimerSeconds: 5,
|
||||||
@@ -292,15 +312,19 @@
|
|||||||
const data = JSON.parse(e.target?.result as string);
|
const data = JSON.parse(e.target?.result as string);
|
||||||
if (data.settings && data.teams && data.rounds) {
|
if (data.settings && data.teams && data.rounds) {
|
||||||
gameName = data.name || "Loaded Game";
|
gameName = data.name || "Loaded Game";
|
||||||
const { teamColors, ...cleanSettings } = data.settings;
|
const { teamColors, ...cleanSettings } =
|
||||||
settings = { ...DEFAULT_SETTINGS, ...cleanSettings };
|
data.settings as Record<string, unknown>;
|
||||||
teams = data.teams.map((t: any) => ({
|
settings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...cleanSettings,
|
||||||
|
} as GameSettings;
|
||||||
|
teams = (data.teams as Team[]).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
score: t.score ?? 0,
|
score: t.score ?? 0,
|
||||||
}));
|
}));
|
||||||
rounds = data.rounds;
|
rounds = data.rounds as Round[];
|
||||||
finalRound = data.finalRound || {
|
finalRound = (data.finalRound as FinalRound) || {
|
||||||
category: "",
|
category: "",
|
||||||
question: "",
|
question: "",
|
||||||
answer: "",
|
answer: "",
|
||||||
@@ -332,25 +356,75 @@
|
|||||||
rounds = [...rounds];
|
rounds = [...rounds];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openQuestion(
|
||||||
|
roundIndex: number,
|
||||||
|
catIndex: number,
|
||||||
|
qIndex: number,
|
||||||
|
) {
|
||||||
|
const q = rounds[roundIndex].categories[catIndex].questions[qIndex];
|
||||||
|
originalQuestion = {
|
||||||
|
question: q.question,
|
||||||
|
answer: q.answer,
|
||||||
|
imageUrl: q.imageUrl,
|
||||||
|
isDailyDouble: q.isDailyDouble,
|
||||||
|
};
|
||||||
|
editingQuestion = { roundIndex, catIndex, qIndex };
|
||||||
|
}
|
||||||
|
|
||||||
function saveQuestion() {
|
function saveQuestion() {
|
||||||
|
originalQuestion = null;
|
||||||
editingQuestion = null;
|
editingQuestion = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditingQuestion() {
|
function handleQuestionCloseClick() {
|
||||||
if (!editingQuestion) return null;
|
showQuestionCloseConfirm = true;
|
||||||
return rounds[editingQuestion.roundIndex].categories[
|
|
||||||
editingQuestion.catIndex
|
|
||||||
].questions[editingQuestion.qIndex];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditingCategory() {
|
function discardQuestionChanges() {
|
||||||
if (!editingQuestion) return null;
|
if (editingQuestion && originalQuestion) {
|
||||||
return rounds[editingQuestion.roundIndex].categories[
|
const q =
|
||||||
editingQuestion.catIndex
|
rounds[editingQuestion.roundIndex].categories[
|
||||||
];
|
editingQuestion.catIndex
|
||||||
|
].questions[editingQuestion.qIndex];
|
||||||
|
q.question = originalQuestion.question;
|
||||||
|
q.answer = originalQuestion.answer;
|
||||||
|
q.imageUrl = originalQuestion.imageUrl;
|
||||||
|
q.isDailyDouble = originalQuestion.isDailyDouble;
|
||||||
|
rounds = [...rounds];
|
||||||
|
}
|
||||||
|
showQuestionCloseConfirm = false;
|
||||||
|
originalQuestion = null;
|
||||||
|
editingQuestion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFinalQuestion() {
|
||||||
|
originalFinal = { ...finalRound };
|
||||||
|
editingFinalQuestion = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFinalQuestion() {
|
||||||
|
originalFinal = null;
|
||||||
|
editingFinalQuestion = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinalCloseClick() {
|
||||||
|
showFinalCloseConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardFinalChanges() {
|
||||||
|
if (originalFinal) {
|
||||||
|
finalRound = { ...originalFinal };
|
||||||
|
}
|
||||||
|
showFinalCloseConfirm = false;
|
||||||
|
originalFinal = null;
|
||||||
|
editingFinalQuestion = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.kv_edit_title()} - Kuldvillak</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Main Layout -->
|
<!-- Main Layout -->
|
||||||
<div class="min-h-screen bg-kv-black flex flex-col gap-2 md:gap-4 p-2 md:p-4">
|
<div class="min-h-screen bg-kv-black flex flex-col gap-2 md:gap-4 p-2 md:p-4">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -361,6 +435,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/kuldvillak"
|
href="/kuldvillak"
|
||||||
class="block w-12 h-12 hover:opacity-80 transition-opacity text-kv-yellow"
|
class="block w-12 h-12 hover:opacity-80 transition-opacity text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_back()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 48 48"
|
viewBox="0 0 48 48"
|
||||||
@@ -386,6 +461,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => fileInput.click()}
|
onclick={() => fileInput.click()}
|
||||||
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_load()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 40 40"
|
viewBox="0 0 40 40"
|
||||||
@@ -400,6 +476,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={saveGame}
|
onclick={saveGame}
|
||||||
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_save()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 40 40"
|
viewBox="0 0 40 40"
|
||||||
@@ -412,8 +489,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={resetGame}
|
onclick={() => (showResetConfirm = true)}
|
||||||
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_reset()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 40 40"
|
viewBox="0 0 40 40"
|
||||||
@@ -428,6 +506,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => (settingsOpen = true)}
|
onclick={() => (settingsOpen = true)}
|
||||||
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
|
||||||
|
aria-label={m.kv_settings()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -452,7 +531,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={startGame}
|
onclick={startGame}
|
||||||
disabled={isStarting}
|
disabled={isStarting}
|
||||||
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50 border-none kv-shadow-button"
|
class="bg-kv-yellow px-6 py-4 kv-btn-text text-black cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50 border-4 border-black kv-shadow-button"
|
||||||
>
|
>
|
||||||
{isStarting ? "⏳" : "▶"}
|
{isStarting ? "⏳" : "▶"}
|
||||||
{m.kv_edit_start()}
|
{m.kv_edit_start()}
|
||||||
@@ -465,22 +544,11 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center justify-between gap-4 mb-4 md:mb-8"
|
class="flex flex-wrap items-center justify-between gap-4 mb-4 md:mb-8"
|
||||||
>
|
>
|
||||||
<h2
|
<h2 class="kv-h3 text-kv-white m-0">
|
||||||
class="font-kv-body text-lg md:text-[28px] text-kv-white uppercase kv-shadow-text m-0"
|
|
||||||
>
|
|
||||||
{m.kv_edit_settings_teams()}
|
{m.kv_edit_settings_teams()}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<!-- ... (no changes) -->
|
||||||
class="px-4 py-2 bg-kv-yellow font-kv-body text-sm md:text-lg text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
|
|
||||||
>
|
|
||||||
{m.kv_edit_rules()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 bg-kv-yellow font-kv-body text-sm md:text-lg text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
|
|
||||||
>
|
|
||||||
{m.kv_edit_how_to()}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -490,26 +558,22 @@
|
|||||||
<!-- Labels Column -->
|
<!-- Labels Column -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="h-12 flex items-center">
|
<div class="h-12 flex items-center">
|
||||||
<span
|
<span class="kv-label text-kv-white"
|
||||||
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
|
|
||||||
>{m.kv_edit_rounds()}</span
|
>{m.kv_edit_rounds()}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-12 flex items-center">
|
<div class="h-12 flex items-center">
|
||||||
<span
|
<span class="kv-label text-kv-white"
|
||||||
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
|
|
||||||
>{m.kv_play_timer()}</span
|
>{m.kv_play_timer()}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-12 flex items-center">
|
<div class="h-12 flex items-center">
|
||||||
<span
|
<span class="kv-label text-kv-white"
|
||||||
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
|
|
||||||
>{m.kv_play_timer_reveal()}</span
|
>{m.kv_play_timer_reveal()}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-12 flex items-center">
|
<div class="h-12 flex items-center">
|
||||||
<span
|
<span class="kv-label text-kv-white"
|
||||||
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
|
|
||||||
>{m.kv_edit_final_round()}</span
|
>{m.kv_edit_final_round()}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,9 +639,9 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
settings.enableFinalRound
|
settings.enableFinalRound
|
||||||
? (editingFinalQuestion = true)
|
? openFinalQuestion()
|
||||||
: (settings.enableFinalRound = true)}
|
: (settings.enableFinalRound = true)}
|
||||||
class="px-4 py-2 bg-kv-yellow font-kv-body text-base text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
|
class="px-4 py-2 bg-kv-yellow font-kv-body text-base text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90"
|
||||||
>
|
>
|
||||||
{settings.enableFinalRound
|
{settings.enableFinalRound
|
||||||
? m.kv_edit_question()
|
? m.kv_edit_question()
|
||||||
@@ -692,6 +756,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => removeTeam(team.id)}
|
onclick={() => removeTeam(team.id)}
|
||||||
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 flex items-center justify-center hover:opacity-70 flex-shrink-0 text-kv-yellow"
|
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 flex items-center justify-center hover:opacity-70 flex-shrink-0 text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_remove_team()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -710,6 +775,7 @@
|
|||||||
onclick={addTeam}
|
onclick={addTeam}
|
||||||
disabled={teams.length >= 6}
|
disabled={teams.length >= 6}
|
||||||
class="w-10 h-10 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 flex items-center justify-center text-kv-yellow"
|
class="w-10 h-10 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 flex items-center justify-center text-kv-yellow"
|
||||||
|
aria-label={m.kv_edit_add_team()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 48 48"
|
viewBox="0 0 48 48"
|
||||||
@@ -772,12 +838,7 @@
|
|||||||
{#each round.categories as cat, ci}
|
{#each round.categories as cat, ci}
|
||||||
{@const q = cat.questions[qi]}
|
{@const q = cat.questions[qi]}
|
||||||
<button
|
<button
|
||||||
onclick={() =>
|
onclick={() => openQuestion(ri, ci, qi)}
|
||||||
(editingQuestion = {
|
|
||||||
roundIndex: ri,
|
|
||||||
catIndex: ci,
|
|
||||||
qIndex: qi,
|
|
||||||
})}
|
|
||||||
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative
|
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative
|
||||||
{q.question.trim() ? 'opacity-100' : 'opacity-50'}
|
{q.question.trim() ? 'opacity-100' : 'opacity-50'}
|
||||||
{q.isDailyDouble ? 'ring-2 ring-inset ring-kv-yellow' : ''}"
|
{q.isDailyDouble ? 'ring-2 ring-inset ring-kv-yellow' : ''}"
|
||||||
@@ -807,8 +868,9 @@
|
|||||||
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)}
|
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
|
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
|
||||||
onclick={(e) => e.target === e.currentTarget && saveQuestion()}
|
onclick={(e) =>
|
||||||
onkeydown={(e) => e.key === "Escape" && saveQuestion()}
|
e.target === e.currentTarget && handleQuestionCloseClick()}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
@@ -823,8 +885,9 @@
|
|||||||
{cat.name || m.kv_edit_category()} - {q.points}€
|
{cat.name || m.kv_edit_category()} - {q.points}€
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={saveQuestion}
|
onclick={handleQuestionCloseClick}
|
||||||
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
|
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
|
||||||
|
aria-label={m.kv_settings_close()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -887,21 +950,34 @@
|
|||||||
<!-- Save button -->
|
<!-- Save button -->
|
||||||
<button
|
<button
|
||||||
onclick={saveQuestion}
|
onclick={saveQuestion}
|
||||||
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90 self-start"
|
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90 self-start"
|
||||||
>
|
>
|
||||||
{m.kv_edit_save_exit()}
|
{m.kv_edit_save_exit()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Question Confirm Dialog -->
|
||||||
|
<ConfirmDialog
|
||||||
|
bind:open={showQuestionCloseConfirm}
|
||||||
|
title={m.kv_confirm_close_title()}
|
||||||
|
message={m.kv_confirm_close_message()}
|
||||||
|
confirmText={m.kv_confirm_discard()}
|
||||||
|
cancelText={m.kv_edit_save()}
|
||||||
|
onconfirm={discardQuestionChanges}
|
||||||
|
oncancel={() => {
|
||||||
|
showQuestionCloseConfirm = false;
|
||||||
|
saveQuestion();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Final Question Modal -->
|
<!-- Final Question Modal -->
|
||||||
{#if editingFinalQuestion}
|
{#if editingFinalQuestion}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
|
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
|
||||||
onclick={(e) =>
|
onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()}
|
||||||
e.target === e.currentTarget && (editingFinalQuestion = false)}
|
onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()}
|
||||||
onkeydown={(e) => e.key === "Escape" && (editingFinalQuestion = false)}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
@@ -915,8 +991,9 @@
|
|||||||
{m.kv_edit_final_round()}
|
{m.kv_edit_final_round()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={() => (editingFinalQuestion = false)}
|
onclick={handleFinalCloseClick}
|
||||||
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
|
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
|
||||||
|
aria-label={m.kv_settings_close()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -977,13 +1054,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => (editingFinalQuestion = false)}
|
onclick={saveFinalQuestion}
|
||||||
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90 self-start"
|
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90 self-start"
|
||||||
>
|
>
|
||||||
{m.kv_edit_save_exit()}
|
{m.kv_edit_save_exit()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Confirm Dialog -->
|
||||||
|
<ConfirmDialog
|
||||||
|
bind:open={showFinalCloseConfirm}
|
||||||
|
title={m.kv_confirm_close_title()}
|
||||||
|
message={m.kv_confirm_close_message()}
|
||||||
|
confirmText={m.kv_confirm_discard()}
|
||||||
|
cancelText={m.kv_edit_save()}
|
||||||
|
onconfirm={discardFinalChanges}
|
||||||
|
oncancel={() => {
|
||||||
|
showFinalCloseConfirm = false;
|
||||||
|
saveFinalQuestion();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
@@ -1005,3 +1096,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Reset Confirmation -->
|
||||||
|
<ConfirmDialog
|
||||||
|
bind:open={showResetConfirm}
|
||||||
|
title={m.kv_edit_reset()}
|
||||||
|
message={m.kv_edit_reset_confirm()}
|
||||||
|
confirmText={m.kv_edit_reset()}
|
||||||
|
onconfirm={resetGame}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
let view = $derived($page.url.searchParams.get("view") ?? "moderator");
|
let view = $derived($page.url.searchParams.get("view") ?? "moderator");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{gameSession.state?.name ?? "Play"} - Kuldvillak</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
{#if !gameSession.state}
|
{#if !gameSession.state}
|
||||||
<div class="h-screen w-screen flex items-center justify-center bg-kv-black">
|
<div class="h-screen w-screen flex items-center justify-center bg-kv-black">
|
||||||
<div class="text-center font-[family-name:var(--kv-font-button)]">
|
<div class="text-center font-[family-name:var(--kv-font-button)]">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,9 @@
|
|||||||
let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">(
|
let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">(
|
||||||
"none",
|
"none",
|
||||||
);
|
);
|
||||||
|
let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "none">(
|
||||||
|
"none",
|
||||||
|
);
|
||||||
let prevPhase = $state<string | null>(null);
|
let prevPhase = $state<string | null>(null);
|
||||||
|
|
||||||
// Intro category animation state (used for both regular and final round)
|
// Intro category animation state (used for both regular and final round)
|
||||||
@@ -52,19 +55,17 @@
|
|||||||
return { left: 50, top: 50, width: 16, height: 20 };
|
return { left: 50, top: 50, width: 16, height: 20 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const gridRect = questionGridEl.getBoundingClientRect();
|
|
||||||
const cardRect = card.getBoundingClientRect();
|
const cardRect = card.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Calculate center position relative to viewport
|
||||||
const left =
|
const left =
|
||||||
((cardRect.left - gridRect.left + cardRect.width / 2) /
|
((cardRect.left + cardRect.width / 2) / viewportWidth) * 100;
|
||||||
gridRect.width) *
|
|
||||||
100;
|
|
||||||
const top =
|
const top =
|
||||||
((cardRect.top - gridRect.top + cardRect.height / 2) /
|
((cardRect.top + cardRect.height / 2) / viewportHeight) * 100;
|
||||||
gridRect.height) *
|
const width = (cardRect.width / viewportWidth) * 100;
|
||||||
100;
|
const height = (cardRect.height / viewportHeight) * 100;
|
||||||
const width = (cardRect.width / gridRect.width) * 100;
|
|
||||||
const height = (cardRect.height / gridRect.height) * 100;
|
|
||||||
|
|
||||||
return { left, top, width, height };
|
return { left, top, width, height };
|
||||||
});
|
});
|
||||||
@@ -77,14 +78,30 @@
|
|||||||
animationPhase = "waiting";
|
animationPhase = "waiting";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
animationPhase = "expanding";
|
animationPhase = "expanding";
|
||||||
}, 100);
|
}, 1000);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
animationPhase = "shown";
|
animationPhase = "shown";
|
||||||
}, 1100);
|
}, 2000);
|
||||||
} else if (currentPhase !== "question") {
|
} else if (currentPhase !== "question") {
|
||||||
animationPhase = "none";
|
animationPhase = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final question animation - wait 1s on Kuldvillak, then expand from center
|
||||||
|
if (
|
||||||
|
currentPhase === "final-question" &&
|
||||||
|
prevPhase !== "final-question"
|
||||||
|
) {
|
||||||
|
finalAnimPhase = "waiting";
|
||||||
|
setTimeout(() => {
|
||||||
|
finalAnimPhase = "expanding";
|
||||||
|
}, 1000); // Wait 1 second before expanding
|
||||||
|
setTimeout(() => {
|
||||||
|
finalAnimPhase = "shown";
|
||||||
|
}, 2000); // 1s wait + 1s expand
|
||||||
|
} else if (currentPhase !== "final-question") {
|
||||||
|
finalAnimPhase = "none";
|
||||||
|
}
|
||||||
|
|
||||||
prevPhase = currentPhase ?? null;
|
prevPhase = currentPhase ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,25 +154,62 @@
|
|||||||
boardRevealPhase = "revealing";
|
boardRevealPhase = "revealing";
|
||||||
revealedPrices = new Set();
|
revealedPrices = new Set();
|
||||||
|
|
||||||
// Stagger reveal each price cell (instant opacity, no transition)
|
// Custom reveal order: [ci, qi, order] - order determines when cell appears
|
||||||
const categories = currentRound?.categories ?? [];
|
// Grid: 6 columns (C1-C6) × 5 rows (R1-R5)
|
||||||
const questionsPerCat = categories[0]?.questions.length ?? 5;
|
const revealOrder: [number, number, number][] = [
|
||||||
let delay = 0;
|
// Row 1 (qi=0): 01 02 15 11 13 08
|
||||||
|
[0, 0, 1],
|
||||||
|
[1, 0, 2],
|
||||||
|
[2, 0, 15],
|
||||||
|
[3, 0, 11],
|
||||||
|
[4, 0, 13],
|
||||||
|
[5, 0, 8],
|
||||||
|
// Row 2 (qi=1): 25 04 28 24 05 07
|
||||||
|
[0, 1, 25],
|
||||||
|
[1, 1, 4],
|
||||||
|
[2, 1, 28],
|
||||||
|
[3, 1, 24],
|
||||||
|
[4, 1, 5],
|
||||||
|
[5, 1, 7],
|
||||||
|
// Row 3 (qi=2): 20 16 09 10 18 26
|
||||||
|
[0, 2, 20],
|
||||||
|
[1, 2, 16],
|
||||||
|
[2, 2, 9],
|
||||||
|
[3, 2, 10],
|
||||||
|
[4, 2, 18],
|
||||||
|
[5, 2, 26],
|
||||||
|
// Row 4 (qi=3): 12 27 06 23 21 30
|
||||||
|
[0, 3, 12],
|
||||||
|
[1, 3, 27],
|
||||||
|
[2, 3, 6],
|
||||||
|
[3, 3, 23],
|
||||||
|
[4, 3, 21],
|
||||||
|
[5, 3, 30],
|
||||||
|
// Row 5 (qi=4): 19 22 03 14 17 29
|
||||||
|
[0, 4, 19],
|
||||||
|
[1, 4, 22],
|
||||||
|
[2, 4, 3],
|
||||||
|
[3, 4, 14],
|
||||||
|
[4, 4, 17],
|
||||||
|
[5, 4, 29],
|
||||||
|
];
|
||||||
|
|
||||||
for (let qi = 0; qi < questionsPerCat; qi++) {
|
// Sort by order and schedule reveals
|
||||||
for (let ci = 0; ci < categories.length; ci++) {
|
const sorted = [...revealOrder].sort((a, b) => a[2] - b[2]);
|
||||||
const key = `${ci}-${qi}`;
|
sorted.forEach(([ci, qi, _order], idx) => {
|
||||||
setTimeout(() => {
|
const key = `${ci}-${qi}`;
|
||||||
revealedPrices = new Set([...revealedPrices, key]);
|
setTimeout(() => {
|
||||||
}, delay);
|
revealedPrices = new Set([...revealedPrices, key]);
|
||||||
delay += 50; // 50ms between each cell
|
}, idx * 50); // 50ms between each cell
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(
|
||||||
boardRevealPhase = "revealed";
|
() => {
|
||||||
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again
|
boardRevealPhase = "revealed";
|
||||||
}, delay + 100);
|
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again
|
||||||
|
},
|
||||||
|
sorted.length * 50 + 100,
|
||||||
|
);
|
||||||
} else if (currentPhase === "board" && alreadyRevealed) {
|
} else if (currentPhase === "board" && alreadyRevealed) {
|
||||||
// Already revealed - show all prices immediately
|
// Already revealed - show all prices immediately
|
||||||
boardRevealPhase = "revealed";
|
boardRevealPhase = "revealed";
|
||||||
@@ -235,7 +289,7 @@
|
|||||||
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
|
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
|
class="w-full h-full flex items-center justify-center bg-kv-blue overflow-hidden p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
|
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
|
||||||
@@ -266,16 +320,28 @@
|
|||||||
?.categories.length ?? 6}, 1fr);"
|
?.categories.length ?? 6}, 1fr);"
|
||||||
>
|
>
|
||||||
{#each currentRound?.categories ?? [] as cat}
|
{#each currentRound?.categories ?? [] as cat}
|
||||||
{@const roundName =
|
{@const logoVariant =
|
||||||
session.currentRoundIndex === 0
|
session.currentRoundIndex === 0
|
||||||
? "VILLAK"
|
? "villak"
|
||||||
: "TOPELTVILLAK"}
|
: "topeltvillak"}
|
||||||
<div
|
<div
|
||||||
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden"
|
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden relative category-header"
|
||||||
>
|
>
|
||||||
{session.boardRevealed
|
<div
|
||||||
? cat.name || "???"
|
class="category-content {session.boardRevealed
|
||||||
: roundName}
|
? 'show-name'
|
||||||
|
: 'show-logo'}"
|
||||||
|
>
|
||||||
|
<div class="category-logo">
|
||||||
|
<KvGameLogo
|
||||||
|
variant={logoVariant}
|
||||||
|
class="h-full w-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="category-name">
|
||||||
|
{cat.name || "???"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,44 +382,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Question Overlay - Full screen over board -->
|
<!-- Question Overlay - Full screen over board -->
|
||||||
{#if session.phase === "question" && questionData && (animationPhase === "expanding" || animationPhase === "shown")}
|
{#if session.phase === "question" && questionData && animationPhase !== "none"}
|
||||||
|
{@const pos = startPosition()}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-kv-black p-8 flex flex-col gap-8 expand-overlay {animationPhase}"
|
class="absolute bg-kv-black flex flex-col expand-overlay {animationPhase}"
|
||||||
|
style="--start-left: {pos.left}%; --start-top: {pos.top}%; --start-width: {pos.width}%; --start-height: {pos.height}%;"
|
||||||
>
|
>
|
||||||
<!-- Players row - top -->
|
<!-- Players row - top -->
|
||||||
<div
|
<div
|
||||||
class="grid gap-4 h-32"
|
class="grid gap-2 lg:gap-4 shrink-0"
|
||||||
style="grid-template-columns: repeat({session.teams
|
style="grid-template-columns: repeat({session.teams
|
||||||
.length}, 1fr);"
|
.length}, 1fr);"
|
||||||
>
|
>
|
||||||
{#each session.teams as team}
|
{#each session.teams as team}
|
||||||
|
{@const isAnswering =
|
||||||
|
session.activeTeamId === team.id}
|
||||||
<div
|
<div
|
||||||
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden {session.activeTeamId ===
|
class="bg-kv-blue px-2 py-4 lg:px-4 lg:py-6 flex flex-col items-center justify-between overflow-hidden font-kv-body text-kv-white uppercase text-center border-8 border-transparent {isAnswering
|
||||||
team.id
|
? 'team-answering'
|
||||||
? 'ring-4 ring-kv-yellow'
|
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
|
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text {team.score <
|
||||||
|
0
|
||||||
|
? 'text-kv-red'
|
||||||
|
: ''}">{team.score}€</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
|
||||||
|
>{team.name}</span
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
|
|
||||||
>{team.name}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
|
|
||||||
0
|
|
||||||
? 'text-kv-red'
|
|
||||||
: ''}">{team.score}€</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Question area - fills remaining space -->
|
<!-- Question area - fills remaining space -->
|
||||||
<div
|
<div
|
||||||
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
|
class="flex-1 flex items-center justify-center p-4 overflow-hidden bg-kv-blue"
|
||||||
>
|
>
|
||||||
{#if questionData.question.imageUrl}
|
{#if questionData.question.imageUrl}
|
||||||
<!-- Image question - show only image -->
|
<!-- Image question - show only image -->
|
||||||
@@ -369,14 +434,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Text question -->
|
<!-- Text question -->
|
||||||
<div
|
<div
|
||||||
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
|
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-[1] uppercase"
|
||||||
|
style="transform: scaleX(0.9225);"
|
||||||
>
|
>
|
||||||
{#if session.showAnswer}
|
{#if session.showAnswer}
|
||||||
<div class="text-kv-yellow kv-shadow-text">
|
<div class="text-kv-yellow kv-shadow-text">
|
||||||
{questionData.question.answer}
|
{questionData.question.answer}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-kv-white">
|
<div class="text-kv-white kv-shadow-text">
|
||||||
{questionData.question.question}
|
{questionData.question.question}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -428,7 +494,7 @@
|
|||||||
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
|
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
|
class="w-full h-full flex items-center justify-center bg-kv-blue overflow-hidden p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
|
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
|
||||||
@@ -449,158 +515,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if session.phase === "final-question"}
|
{:else if session.phase === "final-question"}
|
||||||
<!-- Final Round Question - Full screen question overlay -->
|
<!-- Final Round Question - Kuldvillak background with question overlay -->
|
||||||
<div class="flex-1 flex flex-col p-8 gap-8">
|
<div class="flex-1 flex items-center justify-center intro-grid-bg">
|
||||||
<!-- Players row - top -->
|
<KvGameLogo
|
||||||
|
variant="kuldvillak"
|
||||||
|
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Question overlay on top -->
|
||||||
|
{#if finalAnimPhase !== "none"}
|
||||||
<div
|
<div
|
||||||
class="grid gap-4 h-32"
|
class="absolute bg-kv-black flex flex-col expand-overlay-center {finalAnimPhase}"
|
||||||
style="grid-template-columns: repeat({session.teams
|
|
||||||
.length}, 1fr);"
|
|
||||||
>
|
>
|
||||||
{#each session.teams as team}
|
<!-- Players row - top -->
|
||||||
<div
|
<div
|
||||||
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden"
|
class="grid gap-2 lg:gap-4 shrink-0"
|
||||||
>
|
style="grid-template-columns: repeat({session.teams
|
||||||
|
.length}, 1fr);"
|
||||||
|
>
|
||||||
|
{#each session.teams as team}
|
||||||
|
{@const isActive = session.activeTeamId === team.id}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
|
class="bg-kv-blue px-2 py-4 lg:px-4 lg:py-6 flex flex-col items-center justify-between overflow-hidden font-kv-body text-kv-white uppercase text-center border-8 {isActive
|
||||||
|
? 'border-kv-yellow'
|
||||||
|
: 'border-transparent'}"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
|
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text {team.score <
|
||||||
>{team.name}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
|
|
||||||
0
|
0
|
||||||
? 'text-kv-red'
|
? 'text-kv-red'
|
||||||
: ''}">{team.score}€</span
|
: ''}">{team.score}€</span
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
|
||||||
|
>{team.name}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Question area - fills remaining space -->
|
<!-- Question area - fills remaining space -->
|
||||||
<div
|
|
||||||
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
|
class="flex-1 flex items-center justify-center p-4 overflow-hidden bg-kv-blue"
|
||||||
>
|
>
|
||||||
{#if session.showAnswer}
|
|
||||||
<div class="text-kv-yellow kv-shadow-text">
|
|
||||||
{session.finalRound?.answer}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-kv-white">
|
|
||||||
{session.finalRound?.question}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if session.phase === "final-scores"}
|
|
||||||
<!-- Final Scores Display - Before ending game -->
|
|
||||||
<div
|
|
||||||
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="font-kv-body text-kv-yellow text-[clamp(48px,8vw,120px)] uppercase tracking-wide"
|
|
||||||
style="text-shadow: var(--kv-shadow-title);"
|
|
||||||
>
|
|
||||||
{m.kv_play_scores()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Final Scoreboard -->
|
|
||||||
<div
|
|
||||||
class="grid gap-[clamp(4px,0.5vw,8px)]"
|
|
||||||
style="grid-template-columns: repeat({session.teams
|
|
||||||
.length}, 1fr);"
|
|
||||||
>
|
|
||||||
{#each gameSession.sortedTeams as team, i}
|
|
||||||
<div
|
<div
|
||||||
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
|
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-[1] uppercase"
|
||||||
0
|
style="transform: scaleX(0.9225);"
|
||||||
? 'ring-4 ring-kv-yellow'
|
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
<div
|
{#if session.showAnswer}
|
||||||
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
|
<div class="text-kv-yellow kv-shadow-text">
|
||||||
style="text-shadow: var(--kv-shadow-category);"
|
{session.finalRound?.answer}
|
||||||
>
|
</div>
|
||||||
#{i + 1}
|
{:else}
|
||||||
{team.name}
|
<div class="text-kv-white kv-shadow-text">
|
||||||
</div>
|
{session.finalRound?.question}
|
||||||
<div
|
</div>
|
||||||
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if session.phase === "final-scores"}
|
||||||
|
<!-- Final Scores Display - Flexible grid layout -->
|
||||||
|
{@const sorted = gameSession.sortedTeams}
|
||||||
|
{@const count = sorted.length}
|
||||||
|
{@const topRowCount = count <= 3 ? count : 3}
|
||||||
|
{@const bottomRowCount = count > 3 ? count - 3 : 0}
|
||||||
|
{@const topRow = sorted.slice(0, topRowCount)}
|
||||||
|
{@const bottomRow = sorted.slice(topRowCount)}
|
||||||
|
<div class="flex-1 flex flex-col bg-kv-black p-8 gap-4">
|
||||||
|
<!-- Top row -->
|
||||||
|
<div
|
||||||
|
class="flex-1 grid gap-4"
|
||||||
|
style="grid-template-columns: repeat({topRowCount}, 1fr);"
|
||||||
|
>
|
||||||
|
{#each topRow as team, i}
|
||||||
|
<div
|
||||||
|
class="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(36px,6vw,72px)] uppercase kv-shadow-text {team.score <
|
||||||
0
|
0
|
||||||
? 'text-kv-yellow'
|
? 'text-kv-red'
|
||||||
: 'text-kv-white'}"
|
: 'text-kv-white'}"
|
||||||
style="text-shadow: var(--kv-shadow-price);"
|
|
||||||
>
|
>
|
||||||
{team.score}€
|
{team.score}€
|
||||||
</div>
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
|
||||||
|
>
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Bottom row (if more than 3 players) -->
|
||||||
|
{#if bottomRowCount > 0}
|
||||||
|
<div
|
||||||
|
class="flex-1 grid gap-4"
|
||||||
|
style="grid-template-columns: repeat({bottomRowCount}, 1fr);"
|
||||||
|
>
|
||||||
|
{#each bottomRow as team, i}
|
||||||
|
<div
|
||||||
|
class="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(36px,6vw,72px)] uppercase kv-shadow-text {team.score <
|
||||||
|
0
|
||||||
|
? 'text-kv-red'
|
||||||
|
: 'text-kv-white'}"
|
||||||
|
>
|
||||||
|
{team.score}€
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
|
||||||
|
>
|
||||||
|
#{i + topRowCount + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if session.phase === "finished"}
|
{:else if session.phase === "finished"}
|
||||||
<!-- Game Over / Results - Title font, winner highlighted -->
|
<!-- Game Over - Back to Kuldvillak screen -->
|
||||||
<div
|
<div class="flex-1 flex items-center justify-center intro-grid-bg">
|
||||||
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
|
<KvGameLogo
|
||||||
>
|
variant="kuldvillak"
|
||||||
<div
|
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
|
||||||
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
|
/>
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="font-[family-name:var(--kv-font-title)] text-kv-yellow text-[clamp(48px,10vw,160px)] uppercase tracking-wide mb-8"
|
|
||||||
style="text-shadow: var(--kv-shadow-title);"
|
|
||||||
>
|
|
||||||
{m.kv_play_game_over()}!
|
|
||||||
</div>
|
|
||||||
<!-- Winner announcement -->
|
|
||||||
{#if gameSession.sortedTeams[0]}
|
|
||||||
<div
|
|
||||||
class="font-kv-body text-kv-white text-[clamp(24px,4vw,64px)] uppercase"
|
|
||||||
>
|
|
||||||
🏆 {gameSession.sortedTeams[0].name} 🏆
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- Final Scoreboard -->
|
|
||||||
<div
|
|
||||||
class="grid gap-[clamp(4px,0.5vw,8px)]"
|
|
||||||
style="grid-template-columns: repeat({session.teams
|
|
||||||
.length}, 1fr);"
|
|
||||||
>
|
|
||||||
{#each gameSession.sortedTeams as team, i}
|
|
||||||
<div
|
|
||||||
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
|
|
||||||
0
|
|
||||||
? 'ring-4 ring-kv-yellow'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
|
|
||||||
style="text-shadow: var(--kv-shadow-category);"
|
|
||||||
>
|
|
||||||
#{i + 1}
|
|
||||||
{team.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
|
|
||||||
0
|
|
||||||
? 'text-kv-yellow'
|
|
||||||
: 'text-kv-white'}"
|
|
||||||
style="text-shadow: var(--kv-shadow-price);"
|
|
||||||
>
|
|
||||||
{team.score}€
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -622,16 +675,6 @@
|
|||||||
background-size: 60px 60px;
|
background-size: 60px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Category gradient background - radial vignette effect */
|
|
||||||
.category-gradient-bg {
|
|
||||||
background: radial-gradient(
|
|
||||||
ellipse at center,
|
|
||||||
var(--kv-blue) 0%,
|
|
||||||
color-mix(in srgb, var(--kv-blue) 85%, black) 70%,
|
|
||||||
color-mix(in srgb, var(--kv-blue) 70%, black) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Intro category animation - 500ms dissolve ease-out */
|
/* Intro category animation - 500ms dissolve ease-out */
|
||||||
.intro-category {
|
.intro-category {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -703,13 +746,195 @@
|
|||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Question overlay animation - fade in */
|
/* Question overlay animation - expand from card position */
|
||||||
.expand-overlay {
|
.expand-overlay {
|
||||||
|
left: var(--start-left);
|
||||||
|
top: var(--start-top);
|
||||||
|
width: var(--start-width);
|
||||||
|
height: var(--start-height);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transform-origin: center center;
|
||||||
|
overflow: hidden;
|
||||||
|
container-type: size;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 500ms ease-out;
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.expand-overlay.expanding {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
left 1s linear,
|
||||||
|
top 1s linear,
|
||||||
|
width 1s linear,
|
||||||
|
height 1s linear,
|
||||||
|
transform 1s linear;
|
||||||
}
|
}
|
||||||
.expand-overlay.expanding,
|
.expand-overlay.expanding,
|
||||||
.expand-overlay.shown {
|
.expand-overlay.shown {
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
/* Scale all elements proportionally to container */
|
||||||
|
.expand-overlay {
|
||||||
|
padding: clamp(4px, 3cqh, 32px);
|
||||||
|
gap: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay .font-kv-question {
|
||||||
|
font-size: clamp(12px, 8cqh, 96px);
|
||||||
|
}
|
||||||
|
.expand-overlay .font-kv-body {
|
||||||
|
font-size: clamp(8px, 4cqh, 36px);
|
||||||
|
}
|
||||||
|
.expand-overlay .grid {
|
||||||
|
gap: clamp(2px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay .grid span {
|
||||||
|
font-size: clamp(8px, 3.5cqh, 36px);
|
||||||
|
}
|
||||||
|
.expand-overlay .border-8 {
|
||||||
|
border-width: clamp(2px, 0.8cqh, 8px);
|
||||||
|
}
|
||||||
|
.expand-overlay .bg-kv-blue {
|
||||||
|
padding: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay .px-2 {
|
||||||
|
padding-left: clamp(2px, 0.8cqw, 8px);
|
||||||
|
padding-right: clamp(2px, 0.8cqw, 8px);
|
||||||
|
}
|
||||||
|
.expand-overlay .py-4 {
|
||||||
|
padding-top: clamp(4px, 1.5cqh, 16px);
|
||||||
|
padding-bottom: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final question overlay animation - expand from center */
|
||||||
|
.expand-overlay-center {
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 10%;
|
||||||
|
height: 10%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transform-origin: center center;
|
||||||
|
overflow: hidden;
|
||||||
|
container-type: size;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.expand-overlay-center.waiting {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.expand-overlay-center.expanding {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
width 1s linear,
|
||||||
|
height 1s linear;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.expand-overlay-center.shown {
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
/* Scale elements for center expand */
|
||||||
|
.expand-overlay-center {
|
||||||
|
padding: clamp(4px, 3cqh, 32px);
|
||||||
|
gap: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .font-kv-question {
|
||||||
|
font-size: clamp(12px, 8cqh, 96px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .font-kv-body {
|
||||||
|
font-size: clamp(8px, 4cqh, 36px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .grid {
|
||||||
|
gap: clamp(2px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .grid span {
|
||||||
|
font-size: clamp(8px, 3.5cqh, 36px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .border-8 {
|
||||||
|
border-width: clamp(2px, 0.8cqh, 8px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .bg-kv-blue {
|
||||||
|
padding: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .px-2 {
|
||||||
|
padding-left: clamp(2px, 0.8cqw, 8px);
|
||||||
|
padding-right: clamp(2px, 0.8cqw, 8px);
|
||||||
|
}
|
||||||
|
.expand-overlay-center .py-4 {
|
||||||
|
padding-top: clamp(4px, 1.5cqh, 16px);
|
||||||
|
padding-bottom: clamp(4px, 1.5cqh, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Team answering - flash 3 times then stay white */
|
||||||
|
.team-answering {
|
||||||
|
animation:
|
||||||
|
border-flash 150ms ease-in-out 6,
|
||||||
|
border-stay 0ms 900ms forwards;
|
||||||
|
}
|
||||||
|
@keyframes border-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes border-stay {
|
||||||
|
to {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category header transition - smooth crossfade between logo and name */
|
||||||
|
.category-header {
|
||||||
|
min-height: 3em;
|
||||||
|
}
|
||||||
|
.category-content {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.category-logo,
|
||||||
|
.category-name {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
opacity 400ms ease-in-out,
|
||||||
|
transform 400ms ease-in-out;
|
||||||
|
}
|
||||||
|
.category-logo {
|
||||||
|
height: 1.2em;
|
||||||
|
}
|
||||||
|
.category-content.show-logo .category-logo {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
.category-content.show-logo .category-name {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.category-content.show-name .category-logo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.category-content.show-name .category-name {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
--color-kv-blue: var(--kv-blue);
|
--color-kv-blue: var(--kv-blue);
|
||||||
--color-kv-yellow: var(--kv-yellow);
|
--color-kv-yellow: var(--kv-yellow);
|
||||||
--color-kv-green: #009900;
|
--color-kv-green: #009900;
|
||||||
--color-kv-red: #990000;
|
--color-kv-red: #FF3333;
|
||||||
--color-kv-black: var(--kv-background);
|
--color-kv-black: var(--kv-background);
|
||||||
--color-kv-white: var(--kv-text);
|
--color-kv-white: var(--kv-text);
|
||||||
/* Additional theme-aware colors */
|
/* Additional theme-aware colors */
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'ITC Korinna';
|
font-family: 'ITC Korinna';
|
||||||
src: url('/fonts/ITC Korinna Std Bold.otf') format('opentype');
|
src: url('/fonts/ITC Korinna Regular.otf') format('opentype');
|
||||||
font-weight: 700;
|
font-weight: 400 500;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
--kv-text: #FFFFFF;
|
--kv-text: #FFFFFF;
|
||||||
--kv-background: #000000;
|
--kv-background: #000000;
|
||||||
--kv-green: #009900;
|
--kv-green: #009900;
|
||||||
--kv-red: #990000;
|
--kv-red: #FF3333;
|
||||||
--kv-black: #000000;
|
--kv-black: #000000;
|
||||||
--kv-white: #FFFFFF;
|
--kv-white: #FFFFFF;
|
||||||
|
|
||||||
@@ -112,6 +112,89 @@
|
|||||||
--kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5);
|
--kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Kuldvillak Typography Classes
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Headings - Swiss 921 font, uppercase, with shadow */
|
||||||
|
.kv-h1 {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
.kv-h2 {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
.kv-h3 {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
.kv-h4 {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
.kv-h5 {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
.kv-p {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title variant - for game logo text */
|
||||||
|
.kv-title {
|
||||||
|
font-family: var(--kv-font-title);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button text - standardized */
|
||||||
|
.kv-btn-text {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.kv-btn-text-sm {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.kv-btn-text-lg {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label text - for form labels and small UI text */
|
||||||
|
.kv-label {
|
||||||
|
font-family: var(--kv-font-body);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: var(--kv-shadow-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Global Styles
|
Global Styles
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -126,5 +209,6 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--kv-font-button);
|
font-family: var(--kv-font-button);
|
||||||
|
font-size: 16px;
|
||||||
color: var(--kv-text);
|
color: var(--kv-text);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
static/fonts/ITC Korinna Regular.otf
Normal file
BIN
static/fonts/ITC Korinna Regular.otf
Normal file
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user