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