feat(01-14): implement More tab with templates, blind editor, wizard, settings, audit

- TemplateManager with LEGO-style building block composition (5 block types)
- BlindStructureEditor with full level fields, mixed game, reorder, add/delete
- StructureWizard generates structures from player count, chips, duration params
- More page with navigable menu to all sub-pages (admin-gated operators section)
- Templates page with DataTable list, create/edit/duplicate/delete actions
- Structures page with DataTable list, wizard integration, and editor
- Settings page with venue config, currency, receipts, theme toggle (Mocha/Latte)
- Audit log page with filterable DataTable, detail panel, and undo capability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-03-01 08:26:12 +01:00
parent a056ae31a0
commit 59badcbfe8
8 changed files with 2937 additions and 39 deletions

View file

@ -0,0 +1,564 @@
<script lang="ts">
/**
* Blind structure editor component.
*
* Edits a list of levels with all fields per row:
* Position, Type (round/break), Game Type, SB, BB, Ante, BB Ante, Duration, Chip-up, Notes.
* Supports add, delete, reorder (move up/down), auto-numbering.
* Mixed game support: game type dropdown per level.
*/
import { toast } from '$lib/stores/toast.svelte';
type LevelType = 'round' | 'break';
type GameType = 'nlhe' | 'plo' | 'plo5' | 'nlhe-plo' | 'stud' | 'razz' | 'mixed';
interface BlindLevel {
position: number;
type: LevelType;
game_type: GameType;
small_blind: number;
big_blind: number;
ante: number;
bb_ante: number;
duration_minutes: number;
chip_up: boolean;
notes: string;
}
interface Props {
structureName?: string;
levels?: BlindLevel[];
onsave?: (name: string, levels: BlindLevel[]) => void;
oncancel?: () => void;
}
let {
structureName = '',
levels = [],
onsave,
oncancel
}: Props = $props();
let name = $state(structureName);
let editLevels = $state<BlindLevel[]>(levels.length > 0 ? [...levels] : [createDefaultLevel(1)]);
const GAME_TYPE_OPTIONS: { value: GameType; label: string }[] = [
{ value: 'nlhe', label: 'NLHE' },
{ value: 'plo', label: 'PLO' },
{ value: 'plo5', label: 'PLO5' },
{ value: 'nlhe-plo', label: 'NLHE/PLO' },
{ value: 'stud', label: 'Stud' },
{ value: 'razz', label: 'Razz' },
{ value: 'mixed', label: 'Mixed' }
];
function createDefaultLevel(position: number): BlindLevel {
return {
position,
type: 'round',
game_type: 'nlhe',
small_blind: 0,
big_blind: 0,
ante: 0,
bb_ante: 0,
duration_minutes: 20,
chip_up: false,
notes: ''
};
}
function addLevel(): void {
const pos = editLevels.length + 1;
editLevels = [...editLevels, createDefaultLevel(pos)];
}
function addBreak(): void {
const pos = editLevels.length + 1;
const breakLevel: BlindLevel = {
...createDefaultLevel(pos),
type: 'break',
duration_minutes: 15,
notes: 'Break'
};
editLevels = [...editLevels, breakLevel];
}
function deleteLevel(index: number): void {
if (editLevels.length <= 1) {
toast.warning('Structure must have at least one level.');
return;
}
editLevels = editLevels.filter((_, i) => i !== index);
renumber();
}
function moveUp(index: number): void {
if (index === 0) return;
const arr = [...editLevels];
[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
editLevels = arr;
renumber();
}
function moveDown(index: number): void {
if (index >= editLevels.length - 1) return;
const arr = [...editLevels];
[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
editLevels = arr;
renumber();
}
function renumber(): void {
editLevels = editLevels.map((l, i) => ({ ...l, position: i + 1 }));
}
function handleSave(): void {
if (!name.trim()) {
toast.warning('Structure name is required.');
return;
}
onsave?.(name, editLevels);
}
</script>
<div class="structure-editor">
<h3 class="editor-title">Blind Structure Editor</h3>
<div class="field-group">
<label class="field-label" for="structure-name">Structure Name</label>
<input
id="structure-name"
type="text"
class="field-input touch-target"
bind:value={name}
placeholder="e.g., Standard 20-min levels"
/>
</div>
<!-- Levels table -->
<div class="levels-wrapper">
<div class="levels-scroll">
<table class="levels-table" role="grid">
<thead>
<tr>
<th class="col-pos">#</th>
<th class="col-type">Type</th>
<th class="col-game hide-mobile">Game</th>
<th class="col-blind">SB</th>
<th class="col-blind">BB</th>
<th class="col-ante hide-mobile">Ante</th>
<th class="col-ante hide-mobile">BB Ante</th>
<th class="col-dur">Min</th>
<th class="col-chip hide-mobile">Chip Up</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
{#each editLevels as level, idx (level.position)}
<tr class="level-row" class:break-row={level.type === 'break'}>
<td class="col-pos number">{level.position}</td>
<td class="col-type">
<select
class="cell-select"
bind:value={editLevels[idx].type}
aria-label="Level type"
>
<option value="round">Round</option>
<option value="break">Break</option>
</select>
</td>
<td class="col-game hide-mobile">
{#if level.type === 'round'}
<select
class="cell-select"
bind:value={editLevels[idx].game_type}
aria-label="Game type"
>
{#each GAME_TYPE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-blind">
{#if level.type === 'round'}
<input
type="number"
class="cell-input number"
bind:value={editLevels[idx].small_blind}
min="0"
aria-label="Small blind"
/>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-blind">
{#if level.type === 'round'}
<input
type="number"
class="cell-input number"
bind:value={editLevels[idx].big_blind}
min="0"
aria-label="Big blind"
/>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-ante hide-mobile">
{#if level.type === 'round'}
<input
type="number"
class="cell-input number"
bind:value={editLevels[idx].ante}
min="0"
aria-label="Ante"
/>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-ante hide-mobile">
{#if level.type === 'round'}
<input
type="number"
class="cell-input number"
bind:value={editLevels[idx].bb_ante}
min="0"
aria-label="BB Ante"
/>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-dur">
<input
type="number"
class="cell-input number"
bind:value={editLevels[idx].duration_minutes}
min="1"
aria-label="Duration in minutes"
/>
</td>
<td class="col-chip hide-mobile">
{#if level.type === 'round'}
<input
type="checkbox"
bind:checked={editLevels[idx].chip_up}
aria-label="Chip up"
/>
{:else}
<span class="break-label">--</span>
{/if}
</td>
<td class="col-actions">
<div class="action-buttons">
<button
class="icon-btn"
onclick={() => moveUp(idx)}
disabled={idx === 0}
aria-label="Move level up"
title="Move up"
>
&#9650;
</button>
<button
class="icon-btn"
onclick={() => moveDown(idx)}
disabled={idx === editLevels.length - 1}
aria-label="Move level down"
title="Move down"
>
&#9660;
</button>
<button
class="icon-btn delete-btn"
onclick={() => deleteLevel(idx)}
aria-label="Delete level"
title="Delete"
>
&#10005;
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Add level buttons -->
<div class="add-buttons">
<button class="add-btn touch-target" onclick={addLevel}>
+ Add Level
</button>
<button class="add-btn add-break-btn touch-target" onclick={addBreak}>
+ Add Break
</button>
</div>
<!-- Save/Cancel -->
<div class="editor-actions">
<button class="save-btn touch-target" onclick={handleSave}>
Save Structure
</button>
{#if oncancel}
<button class="cancel-btn touch-target" onclick={() => oncancel?.()}>
Cancel
</button>
{/if}
</div>
</div>
<style>
.structure-editor {
padding: var(--space-4);
}
.editor-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-4);
}
.field-group {
margin-bottom: var(--space-4);
}
.field-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-1);
}
.field-input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-input:focus {
border-color: var(--color-primary);
}
/* Levels table */
.levels-wrapper {
margin-bottom: var(--space-4);
}
.levels-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.levels-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
.levels-table th {
padding: var(--space-2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
background-color: var(--color-bg-elevated);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
text-align: center;
}
.levels-table td {
padding: var(--space-1);
border-bottom: 1px solid var(--color-border);
text-align: center;
vertical-align: middle;
}
.level-row {
background-color: var(--color-bg);
}
.break-row {
background-color: var(--color-bg-elevated);
}
.break-label {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
/* Cell inputs */
.cell-input {
width: 60px;
padding: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
outline: none;
font-family: inherit;
}
.cell-input:focus {
border-color: var(--color-primary);
}
.cell-select {
padding: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
font-family: inherit;
}
.cell-select:focus {
border-color: var(--color-primary);
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 2px;
justify-content: center;
}
.icon-btn {
width: 24px;
height: 24px;
min-height: 24px;
min-width: 24px;
padding: 0;
font-size: 10px;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.delete-btn:hover:not(:disabled) {
color: var(--color-error);
border-color: var(--color-error);
}
/* Column widths */
.col-pos { width: 30px; }
.col-type { width: 70px; }
.col-game { width: 80px; }
.col-blind { width: 70px; }
.col-ante { width: 70px; }
.col-dur { width: 50px; }
.col-chip { width: 50px; }
.col-actions { width: 80px; }
/* Add buttons */
.add-buttons {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.add-btn {
flex: 1;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary);
background: none;
border: 1px dashed var(--color-primary);
border-radius: var(--radius-md);
cursor: pointer;
}
.add-btn:hover {
background-color: var(--color-surface);
}
.add-break-btn {
color: var(--color-break);
border-color: var(--color-break);
}
/* Editor actions */
.editor-actions {
display: flex;
gap: var(--space-3);
}
.save-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
.cancel-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.cancel-btn:hover {
background-color: var(--color-surface-hover);
}
/* Hide on mobile */
.hide-mobile {
display: none;
}
@media (min-width: 768px) {
.hide-mobile {
display: table-cell;
}
}
</style>

View file

@ -0,0 +1,580 @@
<script lang="ts">
/**
* Structure wizard component.
*
* Generates blind structures from high-level parameters:
* player count, starting chips, target duration, chip set.
* Preview generated levels, edit before saving.
* Lives in template management section.
*/
import { toast } from '$lib/stores/toast.svelte';
interface GeneratedLevel {
position: number;
type: 'round' | 'break';
small_blind: number;
big_blind: number;
ante: number;
bb_ante: number;
duration_minutes: number;
}
interface ChipSet {
id: string;
name: string;
}
interface Props {
chipSets?: ChipSet[];
onsavestructure?: (name: string, levels: GeneratedLevel[]) => void;
onusetemplate?: (levels: GeneratedLevel[]) => void;
oncancel?: () => void;
}
let {
chipSets = [],
onsavestructure,
onusetemplate,
oncancel
}: Props = $props();
/** Wizard inputs. */
let playerCount = $state(40);
let startingChips = $state(15000);
let targetHours = $state(4);
let selectedChipSetId = $state<string | null>(null);
let structureName = $state('');
/** Common chip presets. */
const CHIP_PRESETS = [10000, 15000, 25000, 50000];
/** Generated levels (preview). */
let generatedLevels = $state<GeneratedLevel[]>([]);
let hasGenerated = $state(false);
let generating = $state(false);
/**
* Generate blind structure based on wizard inputs.
*
* Algorithm: target duration determines level count. Blinds increase
* geometrically to reach ~200 BB at the end. Breaks inserted every 4-5 levels.
*/
function generateStructure(): void {
generating = true;
const levelDuration = 20; // minutes per level
const totalMinutes = targetHours * 60;
const totalLevels = Math.max(6, Math.floor(totalMinutes / levelDuration));
const breakInterval = 4; // break every N levels
// Calculate geometric ratio for blind escalation
// Final big blind should be ~startingChips / 10 so heads-up is meaningful
const finalBB = Math.max(startingChips / 10, 200);
const startBB = Math.max(50, Math.round(startingChips / 200));
const ratio = Math.pow(finalBB / startBB, 1 / (totalLevels - 1));
const levels: GeneratedLevel[] = [];
let pos = 1;
let roundCount = 0;
for (let i = 0; i < totalLevels; i++) {
// Insert break every breakInterval rounds
if (roundCount > 0 && roundCount % breakInterval === 0) {
levels.push({
position: pos++,
type: 'break',
small_blind: 0,
big_blind: 0,
ante: 0,
bb_ante: 0,
duration_minutes: 15
});
}
const bb = roundToNice(startBB * Math.pow(ratio, i));
const sb = roundToNice(bb / 2);
const ante = i >= Math.floor(totalLevels * 0.3) ? roundToNice(bb / 4) : 0;
const bbAnte = i >= Math.floor(totalLevels * 0.5) ? bb : 0;
levels.push({
position: pos++,
type: 'round',
small_blind: sb,
big_blind: bb,
ante,
bb_ante: bbAnte,
duration_minutes: levelDuration
});
roundCount++;
}
generatedLevels = levels;
hasGenerated = true;
generating = false;
// Auto-generate name
if (!structureName) {
structureName = `${playerCount}p ${targetHours}h ${levelDuration}min`;
}
}
/** Round a value to a "nice" number for blind structures. */
function roundToNice(value: number): number {
if (value <= 25) return Math.round(value / 5) * 5 || 5;
if (value <= 100) return Math.round(value / 25) * 25;
if (value <= 500) return Math.round(value / 50) * 50;
if (value <= 2000) return Math.round(value / 100) * 100;
if (value <= 10000) return Math.round(value / 500) * 500;
return Math.round(value / 1000) * 1000;
}
function handleSave(): void {
if (!structureName.trim()) {
toast.warning('Enter a name for the structure.');
return;
}
if (generatedLevels.length === 0) {
toast.warning('Generate a structure first.');
return;
}
onsavestructure?.(structureName, generatedLevels);
toast.success(`Structure "${structureName}" saved.`);
}
function handleUseInTemplate(): void {
if (generatedLevels.length === 0) {
toast.warning('Generate a structure first.');
return;
}
onusetemplate?.(generatedLevels);
}
function setChipPreset(amount: number): void {
startingChips = amount;
}
/** Count only round levels (exclude breaks). */
let roundLevelCount = $derived(
generatedLevels.filter((l) => l.type === 'round').length
);
let totalDuration = $derived(
generatedLevels.reduce((sum, l) => sum + l.duration_minutes, 0)
);
</script>
<div class="wizard">
<h3 class="wizard-title">Structure Wizard</h3>
<p class="wizard-subtitle">Generate a blind structure from tournament parameters.</p>
<!-- Inputs -->
<div class="wizard-inputs">
<div class="input-group">
<label class="input-label" for="player-count">Player Count</label>
<input
id="player-count"
type="range"
min="8"
max="200"
bind:value={playerCount}
class="range-input"
aria-label="Player count"
/>
<span class="range-value number">{playerCount}</span>
</div>
<div class="input-group">
<label class="input-label">Starting Chips</label>
<div class="chip-presets">
{#each CHIP_PRESETS as preset}
<button
class="preset-btn touch-target"
class:active={startingChips === preset}
onclick={() => setChipPreset(preset)}
>
{(preset / 1000).toFixed(0)}K
</button>
{/each}
</div>
<input
type="number"
class="field-input number touch-target"
bind:value={startingChips}
min="1000"
step="1000"
aria-label="Starting chips"
/>
</div>
<div class="input-group">
<label class="input-label" for="target-hours">Target Duration</label>
<input
id="target-hours"
type="range"
min="1"
max="8"
step="0.5"
bind:value={targetHours}
class="range-input"
aria-label="Target duration in hours"
/>
<span class="range-value">{targetHours}h</span>
</div>
<div class="input-group">
<label class="input-label" for="chip-set-select">Chip Set</label>
<select
id="chip-set-select"
class="field-select touch-target"
bind:value={selectedChipSetId}
>
<option value={null}>-- Default --</option>
{#each chipSets as cs}
<option value={cs.id}>{cs.name}</option>
{/each}
</select>
</div>
</div>
<!-- Generate button -->
<button
class="generate-btn touch-target"
onclick={generateStructure}
disabled={generating}
>
{generating ? 'Generating...' : 'Generate Structure'}
</button>
<!-- Preview -->
{#if hasGenerated}
<div class="preview-section">
<div class="preview-header">
<h4 class="preview-title">Preview</h4>
<span class="preview-stats">
{roundLevelCount} levels, {totalDuration} min ({(totalDuration / 60).toFixed(1)}h)
</span>
</div>
<div class="preview-scroll">
<table class="preview-table" role="grid">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>SB</th>
<th>BB</th>
<th>Ante</th>
<th class="hide-mobile">BB Ante</th>
<th>Min</th>
</tr>
</thead>
<tbody>
{#each generatedLevels as level (level.position)}
<tr class:break-row={level.type === 'break'}>
<td class="number">{level.position}</td>
<td>{level.type === 'break' ? 'Break' : 'Round'}</td>
<td class="number">{level.type === 'round' ? level.small_blind.toLocaleString() : '--'}</td>
<td class="number">{level.type === 'round' ? level.big_blind.toLocaleString() : '--'}</td>
<td class="number">{level.type === 'round' && level.ante > 0 ? level.ante.toLocaleString() : '--'}</td>
<td class="number hide-mobile">{level.type === 'round' && level.bb_ante > 0 ? level.bb_ante.toLocaleString() : '--'}</td>
<td class="number">{level.duration_minutes}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Save actions -->
<div class="input-group">
<label class="input-label" for="save-name">Structure Name</label>
<input
id="save-name"
type="text"
class="field-input touch-target"
bind:value={structureName}
placeholder="Name for this structure"
/>
</div>
<div class="save-actions">
<button class="save-btn touch-target" onclick={handleSave}>
Save as Structure
</button>
{#if onusetemplate}
<button class="use-btn touch-target" onclick={handleUseInTemplate}>
Use in Template
</button>
{/if}
</div>
</div>
{/if}
{#if oncancel}
<button class="cancel-link" onclick={() => oncancel?.()}>
Back
</button>
{/if}
</div>
<style>
.wizard {
padding: var(--space-4);
}
.wizard-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.wizard-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-6);
}
/* Inputs */
.wizard-inputs {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.input-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.input-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
}
.range-input {
width: 100%;
accent-color: var(--color-primary);
}
.range-value {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
text-align: center;
}
.chip-presets {
display: flex;
gap: var(--space-2);
}
.preset-btn {
flex: 1;
padding: var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.preset-btn.active {
background-color: var(--color-primary);
color: var(--color-bg);
border-color: var(--color-primary);
}
.preset-btn:hover:not(.active) {
background-color: var(--color-surface-hover);
}
.field-input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-input:focus {
border-color: var(--color-primary);
}
.field-select {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-select:focus {
border-color: var(--color-primary);
}
/* Generate button */
.generate-btn {
width: 100%;
padding: var(--space-3);
font-size: var(--text-base);
font-weight: 700;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: var(--space-6);
}
.generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Preview */
.preview-section {
margin-bottom: var(--space-6);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.preview-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
}
.preview-stats {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.preview-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-bottom: var(--space-4);
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
.preview-table th {
padding: var(--space-2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
background-color: var(--color-bg-elevated);
border-bottom: 2px solid var(--color-border);
text-align: center;
}
.preview-table td {
padding: var(--space-2);
border-bottom: 1px solid var(--color-border);
text-align: center;
color: var(--color-text);
}
.break-row {
background-color: var(--color-bg-elevated);
}
.break-row td {
color: var(--color-text-muted);
}
/* Save actions */
.save-actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-4);
}
.save-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-success);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
.use-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary);
background: none;
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
cursor: pointer;
}
.use-btn:hover {
background-color: var(--color-surface);
}
/* Cancel link */
.cancel-link {
display: block;
width: 100%;
padding: var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-align: center;
min-height: var(--touch-target);
}
.cancel-link:hover {
color: var(--color-text);
}
/* Responsive */
.hide-mobile {
display: none;
}
@media (min-width: 768px) {
.hide-mobile {
display: table-cell;
}
}
</style>

View file

@ -0,0 +1,514 @@
<script lang="ts">
/**
* Template editor component — LEGO-style composition.
*
* Allows composing tournament templates from building blocks:
* chip set, blind structure, payout structure, buy-in config, points formula.
* Each block is a dropdown with preview. "Create New" navigates to that block's editor.
*/
import { toast } from '$lib/stores/toast.svelte';
interface BuildingBlock {
id: string;
name: string;
summary: string;
}
interface TemplateData {
id: string | null;
name: string;
description: string;
chip_set_id: string | null;
blind_structure_id: string | null;
payout_structure_id: string | null;
buyin_config_id: string | null;
points_formula_id: string | null;
min_players: number;
max_players: number;
is_pko: boolean;
allow_rebuys: boolean;
allow_addons: boolean;
}
interface Props {
template?: TemplateData;
chipSets?: BuildingBlock[];
blindStructures?: BuildingBlock[];
payoutStructures?: BuildingBlock[];
buyinConfigs?: BuildingBlock[];
pointsFormulas?: BuildingBlock[];
onsave?: (template: TemplateData) => void;
oncancel?: () => void;
oncreateblock?: (blockType: string) => void;
}
let {
template = {
id: null,
name: '',
description: '',
chip_set_id: null,
blind_structure_id: null,
payout_structure_id: null,
buyin_config_id: null,
points_formula_id: null,
min_players: 8,
max_players: 200,
is_pko: false,
allow_rebuys: true,
allow_addons: true
},
chipSets = [],
blindStructures = [],
payoutStructures = [],
buyinConfigs = [],
pointsFormulas = [],
onsave,
oncancel,
oncreateblock
}: Props = $props();
/** Local editable state. */
let form = $state<TemplateData>({ ...template });
let isNew = $derived(form.id === null);
function getBlockName(blocks: BuildingBlock[], id: string | null): string {
if (!id) return 'None selected';
return blocks.find((b) => b.id === id)?.name ?? 'Unknown';
}
function getBlockSummary(blocks: BuildingBlock[], id: string | null): string {
if (!id) return '';
return blocks.find((b) => b.id === id)?.summary ?? '';
}
function handleSave(): void {
if (!form.name.trim()) {
toast.warning('Template name is required.');
return;
}
onsave?.(form);
}
function handleCreateBlock(blockType: string): void {
oncreateblock?.(blockType);
}
</script>
<div class="template-editor">
<h3 class="editor-title">{isNew ? 'Create Template' : 'Edit Template'}</h3>
<!-- Name and description -->
<div class="field-group">
<label class="field-label" for="template-name">Name</label>
<input
id="template-name"
type="text"
class="field-input touch-target"
bind:value={form.name}
placeholder="e.g., Friday Night Turbo"
/>
</div>
<div class="field-group">
<label class="field-label" for="template-desc">Description</label>
<textarea
id="template-desc"
class="field-textarea"
bind:value={form.description}
placeholder="Template description (optional)"
rows="2"
></textarea>
</div>
<!-- LEGO building blocks -->
<div class="blocks-section">
<h4 class="blocks-title">Building Blocks</h4>
<!-- Chip Set -->
<div class="block-selector">
<div class="block-header">
<span class="block-label">Chip Set</span>
<button class="block-create-btn" onclick={() => handleCreateBlock('chip-set')}>+ New</button>
</div>
<select
class="block-select touch-target"
bind:value={form.chip_set_id}
aria-label="Select chip set"
>
<option value={null}>-- Select Chip Set --</option>
{#each chipSets as cs}
<option value={cs.id}>{cs.name}</option>
{/each}
</select>
{#if form.chip_set_id}
<div class="block-preview">{getBlockSummary(chipSets, form.chip_set_id)}</div>
{/if}
</div>
<!-- Blind Structure -->
<div class="block-selector">
<div class="block-header">
<span class="block-label">Blind Structure</span>
<button class="block-create-btn" onclick={() => handleCreateBlock('blind-structure')}>+ New</button>
</div>
<select
class="block-select touch-target"
bind:value={form.blind_structure_id}
aria-label="Select blind structure"
>
<option value={null}>-- Select Blind Structure --</option>
{#each blindStructures as bs}
<option value={bs.id}>{bs.name}</option>
{/each}
</select>
{#if form.blind_structure_id}
<div class="block-preview">{getBlockSummary(blindStructures, form.blind_structure_id)}</div>
{/if}
</div>
<!-- Payout Structure -->
<div class="block-selector">
<div class="block-header">
<span class="block-label">Payout Structure</span>
<button class="block-create-btn" onclick={() => handleCreateBlock('payout-structure')}>+ New</button>
</div>
<select
class="block-select touch-target"
bind:value={form.payout_structure_id}
aria-label="Select payout structure"
>
<option value={null}>-- Select Payout Structure --</option>
{#each payoutStructures as ps}
<option value={ps.id}>{ps.name}</option>
{/each}
</select>
{#if form.payout_structure_id}
<div class="block-preview">{getBlockSummary(payoutStructures, form.payout_structure_id)}</div>
{/if}
</div>
<!-- Buy-in Config -->
<div class="block-selector">
<div class="block-header">
<span class="block-label">Buy-in Config</span>
<button class="block-create-btn" onclick={() => handleCreateBlock('buyin-config')}>+ New</button>
</div>
<select
class="block-select touch-target"
bind:value={form.buyin_config_id}
aria-label="Select buy-in config"
>
<option value={null}>-- Select Buy-in Config --</option>
{#each buyinConfigs as bc}
<option value={bc.id}>{bc.name}</option>
{/each}
</select>
{#if form.buyin_config_id}
<div class="block-preview">{getBlockSummary(buyinConfigs, form.buyin_config_id)}</div>
{/if}
</div>
<!-- Points Formula (optional) -->
<div class="block-selector">
<div class="block-header">
<span class="block-label">Points Formula <span class="optional-tag">(optional)</span></span>
<button class="block-create-btn" onclick={() => handleCreateBlock('points-formula')}>+ New</button>
</div>
<select
class="block-select touch-target"
bind:value={form.points_formula_id}
aria-label="Select points formula"
>
<option value={null}>-- None --</option>
{#each pointsFormulas as pf}
<option value={pf.id}>{pf.name}</option>
{/each}
</select>
{#if form.points_formula_id}
<div class="block-preview">{getBlockSummary(pointsFormulas, form.points_formula_id)}</div>
{/if}
</div>
</div>
<!-- Tournament options -->
<div class="options-section">
<h4 class="blocks-title">Tournament Options</h4>
<div class="options-row">
<div class="field-group half">
<label class="field-label" for="min-players">Min Players</label>
<input
id="min-players"
type="number"
class="field-input touch-target"
bind:value={form.min_players}
min="2"
max="1000"
/>
</div>
<div class="field-group half">
<label class="field-label" for="max-players">Max Players</label>
<input
id="max-players"
type="number"
class="field-input touch-target"
bind:value={form.max_players}
min="2"
max="10000"
/>
</div>
</div>
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={form.is_pko} />
<span>Progressive Knockout (PKO)</span>
</label>
</div>
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={form.allow_rebuys} />
<span>Allow Rebuys</span>
</label>
</div>
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={form.allow_addons} />
<span>Allow Add-ons</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="editor-actions">
<button class="save-btn touch-target" onclick={handleSave}>
{isNew ? 'Create Template' : 'Save Changes'}
</button>
{#if oncancel}
<button class="cancel-btn touch-target" onclick={() => oncancel?.()}>
Cancel
</button>
{/if}
</div>
</div>
<style>
.template-editor {
padding: var(--space-4);
}
.editor-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* Field groups */
.field-group {
margin-bottom: var(--space-4);
}
.field-group.half {
flex: 1;
}
.field-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-1);
}
.field-input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-input:focus {
border-color: var(--color-primary);
}
.field-textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
resize: vertical;
}
.field-textarea:focus {
border-color: var(--color-primary);
}
/* Building blocks section */
.blocks-section {
margin-bottom: var(--space-6);
}
.blocks-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.block-selector {
margin-bottom: var(--space-4);
padding: var(--space-3);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.block-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.block-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.optional-tag {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.block-create-btn {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-primary);
background: none;
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
cursor: pointer;
min-height: 28px;
min-width: 28px;
}
.block-create-btn:hover {
background-color: var(--color-primary);
color: var(--color-bg);
}
.block-select {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.block-select:focus {
border-color: var(--color-primary);
}
.block-preview {
margin-top: var(--space-2);
padding: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
background-color: var(--color-bg);
border-radius: var(--radius-sm);
}
/* Tournament options */
.options-section {
margin-bottom: var(--space-6);
}
.options-row {
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.toggle-row {
margin-bottom: var(--space-2);
}
.toggle-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
min-height: var(--touch-target);
}
.toggle-label input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
/* Actions */
.editor-actions {
display: flex;
gap: var(--space-3);
}
.save-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
.cancel-btn {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.cancel-btn:hover {
background-color: var(--color-surface-hover);
}
</style>

View file

@ -1,7 +1,39 @@
<script lang="ts">
/**
* More tab page.
*
* Navigation list to sub-pages: templates, blind structures, chip sets,
* payout structures, buy-in configs, venue settings, operators,
* audit log, and about/version.
*/
import { auth } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
interface MenuItem {
label: string;
description: string;
href: string;
icon: string;
adminOnly?: boolean;
}
const menuItems: MenuItem[] = [
{ label: 'Tournament Templates', description: 'LEGO-style template builder', href: '/more/templates', icon: '\u{1F4CB}' },
{ label: 'Blind Structures', description: 'Level timing and blinds', href: '/more/structures', icon: '\u{23F1}' },
{ label: 'Chip Sets', description: 'Denomination configurations', href: '/more/templates', icon: '\u{1FA99}' },
{ label: 'Payout Structures', description: 'Prize distribution brackets', href: '/more/templates', icon: '\u{1F4B0}' },
{ label: 'Buy-in Configs', description: 'Entry fees and rake', href: '/more/templates', icon: '\u{1F3AB}' },
{ label: 'Venue Settings', description: 'Currency, receipts, theme', href: '/more/settings', icon: '\u{2699}' },
{ label: 'Operators', description: 'Manage floor staff', href: '/more/settings', icon: '\u{1F464}', adminOnly: true },
{ label: 'Audit Log', description: 'Action history and undo', href: '/more/audit', icon: '\u{1F4DC}' },
{ label: 'About / Version', description: 'Felt v1.0 - Phase 1', href: '/more/settings', icon: '\u{2139}' }
];
let visibleItems = $derived(
menuItems.filter((item) => !item.adminOnly || auth.isAdmin)
);
function handleLogout(): void {
auth.logout();
goto('/login');
@ -10,24 +42,32 @@
<div class="page-content">
<h2>More</h2>
<p class="text-secondary">Settings and additional options.</p>
<p class="text-secondary">Settings, templates, and administration.</p>
<div class="menu-list">
<div class="menu-item">
<span class="menu-label">Operator</span>
<span class="menu-value">{auth.operator?.name ?? 'Unknown'}</span>
<!-- Current operator info -->
<div class="operator-card">
<div class="operator-info">
<span class="operator-name">{auth.operator?.name ?? 'Unknown'}</span>
<span class="operator-role">{auth.operator?.role ?? 'Unknown'}</span>
</div>
<div class="menu-item">
<span class="menu-label">Role</span>
<span class="menu-value">{auth.operator?.role ?? 'Unknown'}</span>
</div>
<hr class="divider" />
<button class="menu-item menu-action danger touch-target" onclick={handleLogout}>
<span class="menu-label">Sign Out</span>
<button class="logout-btn touch-target" onclick={handleLogout}>
Sign Out
</button>
</div>
<!-- Menu list -->
<nav class="menu-list" aria-label="More options">
{#each visibleItems as item}
<a href={item.href} class="menu-item touch-target">
<span class="menu-icon" aria-hidden="true">{item.icon}</span>
<div class="menu-text">
<span class="menu-label">{item.label}</span>
<span class="menu-desc">{item.description}</span>
</div>
<span class="menu-arrow" aria-hidden="true">&#8250;</span>
</a>
{/each}
</nav>
</div>
<style>
@ -39,7 +79,7 @@
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
margin-bottom: var(--space-1);
}
.text-secondary {
@ -48,6 +88,54 @@
margin-bottom: var(--space-6);
}
/* Operator card */
.operator-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
}
.operator-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.operator-name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
}
.operator-role {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.logout-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
cursor: pointer;
}
.logout-btn:hover {
background-color: var(--color-error);
color: white;
}
/* Menu list */
.menu-list {
display: flex;
flex-direction: column;
@ -60,48 +148,55 @@
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
min-height: var(--touch-target);
text-decoration: none;
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
transition: background-color var(--transition-fast);
}
.menu-item:last-child {
border-bottom: none;
}
.menu-action {
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
width: 100%;
text-align: left;
font-size: inherit;
font-family: inherit;
.menu-item:hover {
background-color: var(--color-surface-hover);
}
.menu-action:hover {
background-color: var(--color-surface-hover);
.menu-item:active {
background-color: var(--color-surface-active);
}
.menu-icon {
font-size: var(--text-xl);
width: 32px;
text-align: center;
flex-shrink: 0;
}
.menu-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.menu-label {
font-size: var(--text-base);
font-weight: 500;
color: var(--color-text);
}
.menu-value {
font-size: var(--text-sm);
color: var(--color-text-secondary);
.menu-desc {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.danger .menu-label {
color: var(--color-error);
}
.divider {
border: none;
border-top: 1px solid var(--color-border);
margin: 0;
.menu-arrow {
font-size: var(--text-xl);
color: var(--color-text-muted);
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,396 @@
<script lang="ts">
/**
* Audit log page.
*
* DataTable with audit entries, filterable by action type,
* tournament, operator, and date range.
* Entry detail shows full previous/new state JSON.
* Undo button where applicable.
*/
import DataTable from '$lib/components/DataTable.svelte';
import { toast } from '$lib/stores/toast.svelte';
interface AuditEntry {
id: string;
action: string;
operator_name: string;
tournament_name: string;
timestamp: string;
undoable: boolean;
undone: boolean;
details: string;
}
/** Filter state. */
let filterAction = $state('all');
let filterOperator = $state('all');
/** Selected entry for detail view. */
let selectedEntry = $state<AuditEntry | null>(null);
/** Demo audit data. */
const auditEntries: AuditEntry[] = [
{
id: '1',
action: 'player.bust',
operator_name: 'Admin',
tournament_name: 'Friday Night',
timestamp: '2026-02-28 20:15:00',
undoable: true,
undone: false,
details: '{"player_id":"abc","position":12}'
},
{
id: '2',
action: 'financial.buyin',
operator_name: 'Floor Manager',
tournament_name: 'Friday Night',
timestamp: '2026-02-28 19:30:00',
undoable: true,
undone: false,
details: '{"player_id":"def","amount":5000}'
},
{
id: '3',
action: 'table.break',
operator_name: 'Admin',
tournament_name: 'Friday Night',
timestamp: '2026-02-28 21:45:00',
undoable: false,
undone: false,
details: '{"table_id":"t1","players_moved":6}'
},
{
id: '4',
action: 'clock.pause',
operator_name: 'Floor Manager',
tournament_name: 'Saturday Deep Stack',
timestamp: '2026-03-01 14:10:00',
undoable: false,
undone: false,
details: '{"level":5,"remaining_seconds":420}'
}
];
const ACTION_TYPES = ['all', 'player.bust', 'financial.buyin', 'table.break', 'clock.pause', 'player.rebuy'];
const OPERATORS = ['all', 'Admin', 'Floor Manager'];
let filteredEntries = $derived.by(() => {
let result = auditEntries;
if (filterAction !== 'all') {
result = result.filter((e) => e.action === filterAction);
}
if (filterOperator !== 'all') {
result = result.filter((e) => e.operator_name === filterOperator);
}
return result;
});
const columns = [
{ key: 'timestamp', label: 'Time', sortable: true, width: '140px' },
{ key: 'action', label: 'Action', sortable: true },
{ key: 'operator_name', label: 'Operator', sortable: true, hideMobile: true },
{ key: 'tournament_name', label: 'Tournament', sortable: true, hideMobile: true },
{
key: 'undone',
label: 'Status',
sortable: false,
align: 'center' as const,
width: '70px',
render: (e: Record<string, unknown>) => (e['undone'] ? 'Undone' : '')
}
];
function handleRowClick(item: Record<string, unknown>): void {
selectedEntry = item as unknown as AuditEntry;
}
function handleUndo(entry: AuditEntry): void {
if (!entry.undoable) {
toast.warning('This action cannot be undone.');
return;
}
toast.success(`Undo: ${entry.action} reversed.`);
selectedEntry = null;
}
function closeDetail(): void {
selectedEntry = null;
}
</script>
<div class="page-content">
<h2>Audit Log</h2>
<p class="text-secondary">Action history with undo capability.</p>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-label" for="filter-action">Action</label>
<select id="filter-action" class="filter-select touch-target" bind:value={filterAction}>
{#each ACTION_TYPES as action}
<option value={action}>{action === 'all' ? 'All Actions' : action}</option>
{/each}
</select>
</div>
<div class="filter-group">
<label class="filter-label" for="filter-operator">Operator</label>
<select id="filter-operator" class="filter-select touch-target" bind:value={filterOperator}>
{#each OPERATORS as op}
<option value={op}>{op === 'all' ? 'All Operators' : op}</option>
{/each}
</select>
</div>
</div>
<!-- Audit table -->
<DataTable
{columns}
data={filteredEntries as unknown as Record<string, unknown>[]}
sortable={true}
searchable={true}
loading={false}
emptyMessage="No audit entries match your filters."
rowKey={(item) => String(item['id'])}
onrowclick={handleRowClick}
/>
<!-- Detail panel -->
{#if selectedEntry}
<div class="detail-overlay" role="dialog" aria-label="Audit entry detail">
<div class="detail-panel">
<div class="detail-header">
<h3 class="detail-title">{selectedEntry.action}</h3>
<button class="close-btn" onclick={closeDetail} aria-label="Close">
&times;
</button>
</div>
<div class="detail-meta">
<div class="meta-row">
<span class="meta-label">Time</span>
<span class="meta-value">{selectedEntry.timestamp}</span>
</div>
<div class="meta-row">
<span class="meta-label">Operator</span>
<span class="meta-value">{selectedEntry.operator_name}</span>
</div>
<div class="meta-row">
<span class="meta-label">Tournament</span>
<span class="meta-value">{selectedEntry.tournament_name}</span>
</div>
</div>
<div class="detail-json">
<span class="json-label">Details</span>
<pre class="json-content font-mono">{JSON.stringify(JSON.parse(selectedEntry.details), null, 2)}</pre>
</div>
{#if selectedEntry.undoable && !selectedEntry.undone}
<button
class="undo-btn touch-target"
onclick={() => handleUndo(selectedEntry!)}
>
Undo This Action
</button>
{:else if selectedEntry.undone}
<div class="undone-notice">This action has been undone.</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.page-content {
padding: var(--space-4);
}
h2 {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.text-secondary {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
/* Filters */
.filters {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
min-width: 150px;
}
.filter-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.filter-select {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.filter-select:focus {
border-color: var(--color-primary);
}
/* Detail overlay */
.detail-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-end;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
padding: var(--space-4);
}
@media (min-width: 768px) {
.detail-overlay {
align-items: center;
}
}
.detail-panel {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-4);
max-width: 480px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.detail-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
}
.close-btn {
width: 32px;
height: 32px;
min-height: 32px;
min-width: 32px;
padding: 0;
font-size: var(--text-xl);
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--color-text);
}
.detail-meta {
margin-bottom: var(--space-4);
}
.meta-row {
display: flex;
justify-content: space-between;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border);
}
.meta-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.meta-value {
font-size: var(--text-sm);
color: var(--color-text);
}
.detail-json {
margin-bottom: var(--space-4);
}
.json-label {
display: block;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.json-content {
padding: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.undo-btn {
width: 100%;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-warning);
background: none;
border: 1px solid var(--color-warning);
border-radius: var(--radius-md);
cursor: pointer;
}
.undo-btn:hover {
background-color: var(--color-warning);
color: var(--color-bg);
}
.undone-notice {
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-3);
}
</style>

View file

@ -0,0 +1,395 @@
<script lang="ts">
/**
* Venue settings page.
*
* Venue name, currency, rounding denomination, receipt mode,
* theme toggle (Mocha/Latte), operator management (admin only).
*/
import { auth } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast.svelte';
/** Settings state. */
let venueName = $state('My Poker Room');
let currencyCode = $state('EUR');
let currencySymbol = $state('\u20AC');
let roundingDenom = $state(5);
let receiptMode = $state<'off' | 'digital' | 'print' | 'both'>('off');
let currentTheme = $state(
typeof document !== 'undefined'
? document.documentElement.getAttribute('data-theme') ?? 'mocha'
: 'mocha'
);
const CURRENCY_OPTIONS = [
{ code: 'EUR', symbol: '\u20AC' },
{ code: 'USD', symbol: '$' },
{ code: 'GBP', symbol: '\u00A3' },
{ code: 'DKK', symbol: 'kr' },
{ code: 'SEK', symbol: 'kr' },
{ code: 'NOK', symbol: 'kr' },
{ code: 'CHF', symbol: 'CHF' }
];
const RECEIPT_OPTIONS: { value: typeof receiptMode; label: string }[] = [
{ value: 'off', label: 'Off' },
{ value: 'digital', label: 'Digital' },
{ value: 'print', label: 'Print' },
{ value: 'both', label: 'Both' }
];
function handleCurrencyChange(event: Event): void {
const code = (event.target as HTMLSelectElement).value;
const cur = CURRENCY_OPTIONS.find((c) => c.code === code);
if (cur) {
currencyCode = cur.code;
currencySymbol = cur.symbol;
}
}
function toggleTheme(): void {
currentTheme = currentTheme === 'mocha' ? 'latte' : 'mocha';
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', currentTheme);
}
toast.info(`Theme switched to ${currentTheme === 'mocha' ? 'Mocha (dark)' : 'Latte (light)'}`);
}
function handleSave(): void {
toast.success('Settings saved.');
}
</script>
<div class="page-content">
<h2>Venue Settings</h2>
<p class="text-secondary">Configure your poker room.</p>
<div class="settings-form">
<!-- Venue Name -->
<div class="field-group">
<label class="field-label" for="venue-name">Venue Name</label>
<input
id="venue-name"
type="text"
class="field-input touch-target"
bind:value={venueName}
placeholder="e.g., Lucky Aces Poker Club"
/>
</div>
<!-- Currency -->
<div class="field-group">
<label class="field-label" for="currency-select">Currency</label>
<select
id="currency-select"
class="field-select touch-target"
value={currencyCode}
onchange={handleCurrencyChange}
>
{#each CURRENCY_OPTIONS as cur}
<option value={cur.code}>{cur.code} ({cur.symbol})</option>
{/each}
</select>
</div>
<!-- Rounding Denomination -->
<div class="field-group">
<label class="field-label" for="rounding-denom">Rounding Denomination ({currencySymbol})</label>
<input
id="rounding-denom"
type="number"
class="field-input number touch-target"
bind:value={roundingDenom}
min="1"
max="100"
/>
</div>
<!-- Receipt Mode -->
<div class="field-group">
<label class="field-label">Receipt Mode</label>
<div class="radio-group">
{#each RECEIPT_OPTIONS as opt}
<label class="radio-label touch-target">
<input
type="radio"
name="receipt-mode"
value={opt.value}
bind:group={receiptMode}
/>
<span>{opt.label}</span>
</label>
{/each}
</div>
</div>
<!-- Theme Toggle -->
<div class="field-group">
<label class="field-label">Theme</label>
<button class="theme-toggle touch-target" onclick={toggleTheme}>
<span class="theme-preview" class:active-theme={currentTheme === 'mocha'}>
Mocha (Dark)
</span>
<span class="theme-divider">/</span>
<span class="theme-preview" class:active-theme={currentTheme === 'latte'}>
Latte (Light)
</span>
</button>
</div>
<!-- Save -->
<button class="save-btn touch-target" onclick={handleSave}>
Save Settings
</button>
</div>
<!-- Operator Management (admin only) -->
{#if auth.isAdmin}
<div class="admin-section">
<h3 class="section-title">Operators</h3>
<p class="text-secondary">Manage floor staff (admin only).</p>
<div class="operator-list">
<div class="operator-item">
<div class="operator-info">
<span class="operator-name">{auth.operator?.name ?? 'Admin'}</span>
<span class="operator-role">admin</span>
</div>
<span class="current-badge">You</span>
</div>
<div class="operator-item">
<div class="operator-info">
<span class="operator-name">Floor Manager</span>
<span class="operator-role">floor</span>
</div>
<button class="edit-pin-btn touch-target">
Change PIN
</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.page-content {
padding: var(--space-4);
}
h2 {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.text-secondary {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
.settings-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 500px;
}
.field-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
}
.field-input {
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-input:focus {
border-color: var(--color-primary);
}
.field-select {
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
font-family: inherit;
}
.field-select:focus {
border-color: var(--color-primary);
}
/* Radio group */
.radio-group {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.radio-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
padding: var(--space-2) var(--space-3);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.radio-label:has(input:checked) {
border-color: var(--color-primary);
background-color: var(--color-surface-hover);
}
.radio-label input[type='radio'] {
accent-color: var(--color-primary);
}
/* Theme toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
font-size: var(--text-sm);
font-family: inherit;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.theme-toggle:hover {
background-color: var(--color-surface-hover);
}
.theme-preview {
color: var(--color-text-muted);
}
.theme-preview.active-theme {
color: var(--color-primary);
font-weight: 600;
}
.theme-divider {
color: var(--color-text-muted);
}
/* Save button */
.save-btn {
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
/* Admin section */
.admin-section {
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border);
}
.section-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.operator-list {
display: flex;
flex-direction: column;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.operator-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.operator-item:last-child {
border-bottom: none;
}
.operator-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.operator-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.operator-role {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.current-badge {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-success);
padding: 2px var(--space-2);
background-color: rgba(166, 227, 161, 0.1);
border-radius: var(--radius-sm);
}
.edit-pin-btn {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.edit-pin-btn:hover {
background-color: var(--color-surface-hover);
}
</style>

View file

@ -0,0 +1,189 @@
<script lang="ts">
/**
* Blind Structures management page.
*
* Lists blind structures, edit with BlindStructureEditor,
* generate with StructureWizard.
*/
import DataTable from '$lib/components/DataTable.svelte';
import BlindStructureEditor from '$lib/components/BlindStructureEditor.svelte';
import StructureWizard from '$lib/components/StructureWizard.svelte';
import { toast } from '$lib/stores/toast.svelte';
interface Structure {
id: string;
name: string;
levels: number;
duration: string;
is_builtin: boolean;
}
let mode = $state<'list' | 'edit' | 'wizard'>('list');
let editingStructure = $state<Structure | null>(null);
let structures = $state<Structure[]>([
{ id: '1', name: 'Standard 20min', levels: 15, duration: '5h', is_builtin: true },
{ id: '2', name: 'Turbo 10min', levels: 20, duration: '3.5h', is_builtin: true },
{ id: '3', name: 'Deep Stack 30min', levels: 12, duration: '6h', is_builtin: true }
]);
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'levels', label: 'Levels', sortable: true, align: 'center' as const, width: '80px' },
{ key: 'duration', label: 'Duration', sortable: true, align: 'center' as const, width: '80px' },
{
key: 'is_builtin',
label: 'Built-in',
sortable: false,
align: 'center' as const,
width: '80px',
render: (t: Record<string, unknown>) => (t['is_builtin'] ? 'Yes' : '')
}
];
function handleCreate(): void {
editingStructure = null;
mode = 'edit';
}
function handleWizard(): void {
mode = 'wizard';
}
function handleEdit(item: Record<string, unknown>): void {
editingStructure = item as unknown as Structure;
mode = 'edit';
}
function handleSave(_name: string): void {
toast.success('Blind structure saved.');
mode = 'list';
}
function handleWizardSave(_name: string): void {
toast.success('Structure generated and saved.');
mode = 'list';
}
function handleCancel(): void {
mode = 'list';
}
function handleDelete(item: Record<string, unknown>): void {
const s = item as unknown as Structure;
if (s.is_builtin) {
toast.warning('Cannot delete built-in structures.');
return;
}
structures = structures.filter((st) => st.id !== s.id);
toast.success(`Deleted "${s.name}"`);
}
</script>
<div class="page-content">
{#if mode === 'list'}
<div class="page-header">
<div>
<h2>Blind Structures</h2>
<p class="text-secondary">Level timing, blinds, and antes.</p>
</div>
<div class="header-actions">
<button class="wizard-btn touch-target" onclick={handleWizard}>
Wizard
</button>
<button class="create-btn touch-target" onclick={handleCreate}>
Create
</button>
</div>
</div>
<DataTable
{columns}
data={structures as unknown as Record<string, unknown>[]}
sortable={true}
searchable={true}
loading={false}
emptyMessage="No blind structures. Create one or use the wizard."
rowKey={(item) => String(item['id'])}
onrowclick={handleEdit}
swipeActions={[
{ id: 'delete', label: 'Delete', color: 'var(--color-error)', handler: handleDelete }
]}
/>
{:else if mode === 'edit'}
<BlindStructureEditor
structureName={editingStructure?.name ?? ''}
onsave={handleSave}
oncancel={handleCancel}
/>
{:else if mode === 'wizard'}
<StructureWizard
onsavestructure={handleWizardSave}
oncancel={handleCancel}
/>
{/if}
</div>
<style>
.page-content {
padding: var(--space-4);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-4);
}
h2 {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.text-secondary {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.header-actions {
display: flex;
gap: var(--space-2);
}
.wizard-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-accent);
background: none;
border: 1px solid var(--color-accent);
border-radius: var(--radius-md);
cursor: pointer;
white-space: nowrap;
}
.wizard-btn:hover {
background-color: var(--color-accent);
color: var(--color-bg);
}
.create-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
white-space: nowrap;
}
.create-btn:hover {
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,165 @@
<script lang="ts">
/**
* Template management page.
*
* Lists tournament templates with DataTable.
* Create/Edit/Duplicate/Delete actions.
* Template editor uses LEGO-style building block composition.
*/
import DataTable from '$lib/components/DataTable.svelte';
import TemplateManager from '$lib/components/TemplateManager.svelte';
import { toast } from '$lib/stores/toast.svelte';
interface Template {
id: string;
name: string;
type: string;
is_builtin: boolean;
}
/** View mode: list or edit. */
let mode = $state<'list' | 'edit'>('list');
let editingTemplate = $state<Template | null>(null);
/** Demo template data. */
let templates = $state<Template[]>([
{ id: '1', name: 'Standard Friday', type: 'Standard', is_builtin: false },
{ id: '2', name: 'Turbo Weeknight', type: 'Turbo', is_builtin: false },
{ id: '3', name: 'WSOP-style Deep Stack', type: 'Deep Stack', is_builtin: true }
]);
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{
key: 'is_builtin',
label: 'Built-in',
sortable: false,
align: 'center' as const,
width: '80px',
render: (t: Record<string, unknown>) => (t['is_builtin'] ? 'Yes' : '')
}
];
function handleCreate(): void {
editingTemplate = null;
mode = 'edit';
}
function handleEdit(item: Record<string, unknown>): void {
editingTemplate = item as unknown as Template;
mode = 'edit';
}
function handleDuplicate(item: Record<string, unknown>): void {
const src = item as unknown as Template;
const dup: Template = {
...src,
id: crypto.randomUUID(),
name: `${src.name} (Copy)`,
is_builtin: false
};
templates = [...templates, dup];
toast.success(`Duplicated "${src.name}"`);
}
function handleDelete(item: Record<string, unknown>): void {
const tpl = item as unknown as Template;
if (tpl.is_builtin) {
toast.warning('Cannot delete built-in templates.');
return;
}
templates = templates.filter((t) => t.id !== tpl.id);
toast.success(`Deleted "${tpl.name}"`);
}
function handleSave(): void {
toast.success('Template saved.');
mode = 'list';
}
function handleCancel(): void {
mode = 'list';
}
function handleCreateBlock(blockType: string): void {
toast.info(`Navigate to create new ${blockType} (coming soon)`);
}
</script>
<div class="page-content">
{#if mode === 'list'}
<div class="page-header">
<div>
<h2>Tournament Templates</h2>
<p class="text-secondary">LEGO-style building block composition.</p>
</div>
<button class="create-btn touch-target" onclick={handleCreate}>
Create Template
</button>
</div>
<DataTable
{columns}
data={templates as unknown as Record<string, unknown>[]}
sortable={true}
searchable={true}
loading={false}
emptyMessage="No templates yet. Create one to get started."
rowKey={(item) => String(item['id'])}
onrowclick={handleEdit}
swipeActions={[
{ id: 'duplicate', label: 'Duplicate', color: 'var(--color-primary)', handler: handleDuplicate },
{ id: 'delete', label: 'Delete', color: 'var(--color-error)', handler: handleDelete }
]}
/>
{:else}
<TemplateManager
onsave={handleSave}
oncancel={handleCancel}
oncreateblock={handleCreateBlock}
/>
{/if}
</div>
<style>
.page-content {
padding: var(--space-4);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-4);
}
h2 {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.text-secondary {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.create-btn {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
white-space: nowrap;
}
.create-btn:hover {
opacity: 0.9;
}
</style>