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:
parent
a056ae31a0
commit
59badcbfe8
8 changed files with 2937 additions and 39 deletions
564
frontend/src/lib/components/BlindStructureEditor.svelte
Normal file
564
frontend/src/lib/components/BlindStructureEditor.svelte
Normal 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"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={() => moveDown(idx)}
|
||||||
|
disabled={idx === editLevels.length - 1}
|
||||||
|
aria-label="Move level down"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn delete-btn"
|
||||||
|
onclick={() => deleteLevel(idx)}
|
||||||
|
aria-label="Delete level"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</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>
|
||||||
580
frontend/src/lib/components/StructureWizard.svelte
Normal file
580
frontend/src/lib/components/StructureWizard.svelte
Normal 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>
|
||||||
514
frontend/src/lib/components/TemplateManager.svelte
Normal file
514
frontend/src/lib/components/TemplateManager.svelte
Normal 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>
|
||||||
|
|
@ -1,7 +1,39 @@
|
||||||
<script lang="ts">
|
<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 { auth } from '$lib/stores/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
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 {
|
function handleLogout(): void {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
goto('/login');
|
goto('/login');
|
||||||
|
|
@ -10,24 +42,32 @@
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<h2>More</h2>
|
<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">
|
<!-- Current operator info -->
|
||||||
<div class="menu-item">
|
<div class="operator-card">
|
||||||
<span class="menu-label">Operator</span>
|
<div class="operator-info">
|
||||||
<span class="menu-value">{auth.operator?.name ?? 'Unknown'}</span>
|
<span class="operator-name">{auth.operator?.name ?? 'Unknown'}</span>
|
||||||
|
<span class="operator-role">{auth.operator?.role ?? 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item">
|
<button class="logout-btn touch-target" onclick={handleLogout}>
|
||||||
<span class="menu-label">Role</span>
|
Sign Out
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</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">›</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -39,7 +79,7 @@
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
|
|
@ -48,6 +88,54 @@
|
||||||
margin-bottom: var(--space-6);
|
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 {
|
.menu-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -60,48 +148,55 @@
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
min-height: var(--touch-target);
|
min-height: var(--touch-target);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:last-child {
|
.menu-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-action {
|
.menu-item:hover {
|
||||||
background: none;
|
background-color: var(--color-surface-hover);
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-action:hover {
|
.menu-item:active {
|
||||||
background-color: var(--color-surface-hover);
|
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 {
|
.menu-label {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-value {
|
.menu-desc {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger .menu-label {
|
.menu-arrow {
|
||||||
color: var(--color-error);
|
font-size: var(--text-xl);
|
||||||
}
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
.divider {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
396
frontend/src/routes/more/audit/+page.svelte
Normal file
396
frontend/src/routes/more/audit/+page.svelte
Normal 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">
|
||||||
|
×
|
||||||
|
</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>
|
||||||
395
frontend/src/routes/more/settings/+page.svelte
Normal file
395
frontend/src/routes/more/settings/+page.svelte
Normal 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>
|
||||||
189
frontend/src/routes/more/structures/+page.svelte
Normal file
189
frontend/src/routes/more/structures/+page.svelte
Normal 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>
|
||||||
165
frontend/src/routes/more/templates/+page.svelte
Normal file
165
frontend/src/routes/more/templates/+page.svelte
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue