feat(01-11): implement Overview tab with clock display and activity feed
- ClockDisplay: large countdown timer with MM:SS, break/pause overlays, hand-for-hand badge, urgent pulse in final 10s, next level preview, chip-up indicator, BB ante support - BlindInfo: time-to-break countdown, break-ends-in when on break - ActivityFeed: recent actions with type icons/colors, relative timestamps, slide-in animation, view-all link - Overview page: assembles all components in CONTEXT.md priority order (clock > break > players > balance > financials > activity) - Extended ClockSnapshot type with bb_ante, game_type, hand_for_hand, next level info, chip_up_denomination - Extended FinancialSummary with detailed breakdown fields - Added Transaction, DealProposal, DealPlayerEntry types - Added derived properties: bustedPlayers, averageStack, totalChips - Added transaction WS message handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
968a38dd87
commit
e7da206d32
9 changed files with 2125 additions and 76 deletions
203
frontend/src/lib/components/ActivityFeed.svelte
Normal file
203
frontend/src/lib/components/ActivityFeed.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ActivityEntry } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity feed showing recent tournament actions.
|
||||||
|
*
|
||||||
|
* Displays last N actions in reverse chronological order with
|
||||||
|
* type-specific icons, colors, and relative timestamps.
|
||||||
|
* New entries animate in from the top.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: ActivityEntry[];
|
||||||
|
/** Maximum entries to display. */
|
||||||
|
limit?: number;
|
||||||
|
/** Show "View all" link. */
|
||||||
|
showViewAll?: boolean;
|
||||||
|
/** Handler for "View all" click. */
|
||||||
|
onviewall?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
limit = 15,
|
||||||
|
showViewAll = true,
|
||||||
|
onviewall
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let visibleEntries = $derived(entries.slice(0, limit));
|
||||||
|
|
||||||
|
/** Icon and color mapping for activity types. */
|
||||||
|
function getEntryStyle(type: string): { icon: string; color: string } {
|
||||||
|
switch (type) {
|
||||||
|
case 'buyin':
|
||||||
|
case 'buy_in':
|
||||||
|
return { icon: '\u{1F464}', color: 'var(--color-success)' };
|
||||||
|
case 'bust':
|
||||||
|
case 'elimination':
|
||||||
|
return { icon: '\u{2716}', color: 'var(--color-error)' };
|
||||||
|
case 'rebuy':
|
||||||
|
return { icon: '\u{1F504}', color: 'var(--color-primary)' };
|
||||||
|
case 'addon':
|
||||||
|
case 'add_on':
|
||||||
|
return { icon: '\u{2B06}', color: 'var(--color-warning)' };
|
||||||
|
case 'level_change':
|
||||||
|
return { icon: '\u{1F552}', color: 'var(--color-clock)' };
|
||||||
|
case 'break_start':
|
||||||
|
return { icon: '\u{2615}', color: 'var(--color-break)' };
|
||||||
|
case 'break_end':
|
||||||
|
return { icon: '\u{25B6}', color: 'var(--color-break)' };
|
||||||
|
case 'seat_move':
|
||||||
|
return { icon: '\u{2194}', color: 'var(--ctp-lavender)' };
|
||||||
|
case 'table_break':
|
||||||
|
return { icon: '\u{1F4CB}', color: 'var(--ctp-peach)' };
|
||||||
|
case 'reentry':
|
||||||
|
case 're_entry':
|
||||||
|
return { icon: '\u{1F503}', color: 'var(--ctp-sapphire)' };
|
||||||
|
case 'deal':
|
||||||
|
case 'chop':
|
||||||
|
return { icon: '\u{1F91D}', color: 'var(--color-prize)' };
|
||||||
|
default:
|
||||||
|
return { icon: '\u{2022}', color: 'var(--color-text-muted)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format timestamp as relative time ("2m ago", "1h ago"). */
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000; // Handle both ms and seconds
|
||||||
|
const diff = Math.max(0, Math.floor((now - ts) / 1000));
|
||||||
|
|
||||||
|
if (diff < 5) return 'just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="activity-feed">
|
||||||
|
<div class="feed-header">
|
||||||
|
<h3 class="feed-title">Recent Activity</h3>
|
||||||
|
{#if showViewAll && onviewall && entries.length > limit}
|
||||||
|
<button class="view-all-btn touch-target" onclick={onviewall}>
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleEntries.length === 0}
|
||||||
|
<p class="feed-empty">No activity yet</p>
|
||||||
|
{:else}
|
||||||
|
<div class="feed-list" role="log" aria-label="Tournament activity feed">
|
||||||
|
{#each visibleEntries as entry (entry.id)}
|
||||||
|
{@const style = getEntryStyle(entry.type)}
|
||||||
|
<div class="feed-entry" style="--entry-color: {style.color}">
|
||||||
|
<span class="entry-icon" aria-hidden="true">{style.icon}</span>
|
||||||
|
<span class="entry-message">{entry.message}</span>
|
||||||
|
<span class="entry-time">{formatRelativeTime(entry.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.activity-feed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
min-height: auto;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
animation: slide-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.5em;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
332
frontend/src/lib/components/BalancingPanel.svelte
Normal file
332
frontend/src/lib/components/BalancingPanel.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Balancing panel for table balancing workflow.
|
||||||
|
*
|
||||||
|
* Shows when tables are unbalanced (yellow/red indicator).
|
||||||
|
* "Suggest Moves" shows system-generated suggestions.
|
||||||
|
* Accept flow uses 2-tap recording: source seat, destination seat.
|
||||||
|
* Stale suggestions auto-cancel when state changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BalanceStatus, BalanceMove } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
balanceStatus: BalanceStatus | null;
|
||||||
|
onacceptmove?: (move: BalanceMove) => void;
|
||||||
|
onsuggestmoves?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
balanceStatus,
|
||||||
|
onacceptmove,
|
||||||
|
onsuggestmoves
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
/** Panel expanded state. */
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
/** Currently selected move index for execution. */
|
||||||
|
let selectedMoveIdx = $state<number | null>(null);
|
||||||
|
|
||||||
|
/** Whether suggestions are being loaded. */
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
/** Whether the panel is visible at all. */
|
||||||
|
let isUnbalanced = $derived(
|
||||||
|
balanceStatus !== null && !balanceStatus.is_balanced
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Severity level based on max difference. */
|
||||||
|
let severity = $derived.by(() => {
|
||||||
|
if (!balanceStatus) return 'ok';
|
||||||
|
if (balanceStatus.max_diff >= 3) return 'critical';
|
||||||
|
if (balanceStatus.max_diff >= 2) return 'warning';
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasSuggestions = $derived(
|
||||||
|
balanceStatus !== null && balanceStatus.moves_needed.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSuggestMoves(): void {
|
||||||
|
loading = true;
|
||||||
|
onsuggestmoves?.();
|
||||||
|
// In real implementation, loading would be cleared when suggestions arrive via WS
|
||||||
|
setTimeout(() => { loading = false; }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAcceptMove(move: BalanceMove, idx: number): void {
|
||||||
|
selectedMoveIdx = idx;
|
||||||
|
onacceptmove?.(move);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelSuggestion(): void {
|
||||||
|
selectedMoveIdx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(): void {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isUnbalanced}
|
||||||
|
<div class="balance-panel" class:critical={severity === 'critical'} class:warning={severity === 'warning'}>
|
||||||
|
<!-- Banner -->
|
||||||
|
<button
|
||||||
|
class="balance-banner touch-target"
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls="balance-details"
|
||||||
|
>
|
||||||
|
<span class="balance-indicator">
|
||||||
|
<span class="indicator-dot" class:critical={severity === 'critical'} class:warning={severity === 'warning'}></span>
|
||||||
|
<span class="balance-text">
|
||||||
|
Tables Unbalanced
|
||||||
|
{#if balanceStatus}
|
||||||
|
(max diff: {balanceStatus.max_diff})
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="expand-arrow" class:expanded>{expanded ? '\u25B2' : '\u25BC'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expandable details -->
|
||||||
|
{#if expanded}
|
||||||
|
<div id="balance-details" class="balance-details">
|
||||||
|
{#if !hasSuggestions && !loading}
|
||||||
|
<button
|
||||||
|
class="suggest-btn touch-target"
|
||||||
|
onclick={handleSuggestMoves}
|
||||||
|
>
|
||||||
|
Suggest Moves
|
||||||
|
</button>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="loading-text">Calculating suggestions...</div>
|
||||||
|
{:else if balanceStatus}
|
||||||
|
<div class="suggestions-header">
|
||||||
|
<span class="suggestions-label">Suggested Moves</span>
|
||||||
|
<span class="live-indicator">Live</span>
|
||||||
|
</div>
|
||||||
|
<ul class="suggestion-list">
|
||||||
|
{#each balanceStatus.moves_needed as move, idx}
|
||||||
|
<li class="suggestion-item" class:active={selectedMoveIdx === idx}>
|
||||||
|
<div class="suggestion-text">
|
||||||
|
Move <strong>{move.player_name}</strong>
|
||||||
|
from Table {move.from_table} to Table {move.to_table}, Seat {move.to_seat}
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-actions">
|
||||||
|
{#if selectedMoveIdx === idx}
|
||||||
|
<button
|
||||||
|
class="cancel-btn touch-target"
|
||||||
|
onclick={handleCancelSuggestion}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="accept-btn touch-target"
|
||||||
|
onclick={() => handleAcceptMove(move, idx)}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.balance-panel {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-warning);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-panel.critical {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-panel.warning {
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-dot.critical {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-dot.warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details panel */
|
||||||
|
.balance-details {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-btn {
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-indicator {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
frontend/src/lib/components/BlindInfo.svelte
Normal file
81
frontend/src/lib/components/BlindInfo.svelte
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ClockSnapshot } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-to-break display.
|
||||||
|
*
|
||||||
|
* Shows countdown until next break, or time remaining on current break.
|
||||||
|
* Hidden when no upcoming break exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clock: ClockSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { clock }: Props = $props();
|
||||||
|
|
||||||
|
/** Format seconds to MM:SS. */
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (seconds < 0) seconds = 0;
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label and time for the break indicator. */
|
||||||
|
let breakInfo = $derived.by(() => {
|
||||||
|
if (clock.is_break) {
|
||||||
|
return {
|
||||||
|
label: 'Break ends in',
|
||||||
|
time: formatTime(clock.remaining_seconds),
|
||||||
|
visible: true,
|
||||||
|
isBreak: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (clock.next_break_in_seconds !== null && clock.next_break_in_seconds > 0) {
|
||||||
|
return {
|
||||||
|
label: 'Break in',
|
||||||
|
time: formatTime(clock.next_break_in_seconds),
|
||||||
|
visible: true,
|
||||||
|
isBreak: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: '', time: '', visible: false, isBreak: false };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if breakInfo.visible}
|
||||||
|
<div class="break-info" class:on-break={breakInfo.isBreak}>
|
||||||
|
<span class="break-label">{breakInfo.label}:</span>
|
||||||
|
<span class="timer break-time">{breakInfo.time}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.break-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-info.on-break {
|
||||||
|
background-color: color-mix(in srgb, var(--ctp-teal) 10%, var(--color-surface));
|
||||||
|
border-color: var(--ctp-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-time {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-break);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
276
frontend/src/lib/components/ClockDisplay.svelte
Normal file
276
frontend/src/lib/components/ClockDisplay.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ClockSnapshot } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Large clock display for the Overview tab.
|
||||||
|
*
|
||||||
|
* Shows current level timer, blinds, ante, next level preview,
|
||||||
|
* break/pause overlays, hand-for-hand badge, and chip-up indicator.
|
||||||
|
* Timer text turns red and pulses in the final 10 seconds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clock: ClockSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { clock }: Props = $props();
|
||||||
|
|
||||||
|
/** Whether we are in the final 10 seconds (urgent state). */
|
||||||
|
let isUrgent = $derived(
|
||||||
|
clock.remaining_seconds <= 10 && !clock.is_break && !clock.is_paused
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Format seconds to MM:SS. */
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (seconds < 0) seconds = 0;
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format blind values with locale separators. */
|
||||||
|
function formatChips(value: number): string {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the level label (e.g., "Level 5" or "Level 5 -- PLO"). */
|
||||||
|
let levelLabel = $derived.by(() => {
|
||||||
|
const base = `Level ${clock.level}`;
|
||||||
|
if (clock.game_type && clock.game_type !== 'nlhe') {
|
||||||
|
return `${base} -- ${clock.game_type.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Next level preview text. */
|
||||||
|
let nextLevelPreview = $derived.by(() => {
|
||||||
|
if (!clock.next_level_name) return null;
|
||||||
|
if (clock.next_level_is_break) {
|
||||||
|
const dur = clock.next_level_duration_seconds
|
||||||
|
? formatTime(clock.next_level_duration_seconds)
|
||||||
|
: '';
|
||||||
|
return `Next: BREAK${dur ? ` (${dur})` : ''}`;
|
||||||
|
}
|
||||||
|
const sb = clock.next_level_small_blind ?? 0;
|
||||||
|
const bb = clock.next_level_big_blind ?? 0;
|
||||||
|
const dur = clock.next_level_duration_seconds
|
||||||
|
? formatTime(clock.next_level_duration_seconds)
|
||||||
|
: '';
|
||||||
|
return `Next: ${formatChips(sb)}/${formatChips(bb)}${dur ? ` (${dur})` : ''}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="clock-display"
|
||||||
|
class:on-break={clock.is_break}
|
||||||
|
class:paused={clock.is_paused}
|
||||||
|
class:urgent={isUrgent}
|
||||||
|
role="timer"
|
||||||
|
aria-label="Tournament clock"
|
||||||
|
>
|
||||||
|
<!-- Hand-for-hand badge -->
|
||||||
|
{#if clock.is_hand_for_hand}
|
||||||
|
<div class="hfh-badge">HAND FOR HAND</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Break state -->
|
||||||
|
{#if clock.is_break}
|
||||||
|
<div class="break-label">BREAK</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Pause overlay -->
|
||||||
|
{#if clock.is_paused}
|
||||||
|
<div class="pause-overlay">PAUSED</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Main timer -->
|
||||||
|
<div class="timer-display">
|
||||||
|
<span class="timer timer-value">{formatTime(clock.remaining_seconds)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level label -->
|
||||||
|
{#if !clock.is_break}
|
||||||
|
<div class="level-label">{levelLabel}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Blinds -->
|
||||||
|
{#if !clock.is_break}
|
||||||
|
<div class="blinds-display">
|
||||||
|
<span class="blinds blinds-main">
|
||||||
|
SB: {formatChips(clock.small_blind)} / BB: {formatChips(clock.big_blind)}
|
||||||
|
</span>
|
||||||
|
{#if clock.bb_ante > 0}
|
||||||
|
<span class="blinds ante-info">BB Ante: {formatChips(clock.bb_ante)}</span>
|
||||||
|
{:else if clock.ante > 0}
|
||||||
|
<span class="blinds ante-info">Ante: {formatChips(clock.ante)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Next level preview -->
|
||||||
|
{#if nextLevelPreview}
|
||||||
|
<div class="next-level">
|
||||||
|
{nextLevelPreview}
|
||||||
|
{#if clock.chip_up_denomination}
|
||||||
|
<span class="chip-up">Chip-up: Remove {formatChips(clock.chip_up_denomination)}s</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clock-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-display.on-break {
|
||||||
|
background-color: color-mix(in srgb, var(--ctp-teal) 12%, var(--color-surface));
|
||||||
|
border-color: var(--ctp-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-display.paused {
|
||||||
|
background-color: color-mix(in srgb, var(--ctp-peach) 8%, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hand-for-hand badge */
|
||||||
|
.hfh-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background-color: color-mix(in srgb, var(--ctp-red) 20%, transparent);
|
||||||
|
color: var(--ctp-red);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
animation: pulse-hfh 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-hfh {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break label */
|
||||||
|
.break-label {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctp-teal);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pause overlay */
|
||||||
|
.pause-overlay {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: pulse-pause 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-pause {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer */
|
||||||
|
.timer-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-value {
|
||||||
|
font-size: clamp(3rem, 12vw, 6rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-display.on-break .timer-value {
|
||||||
|
color: var(--ctp-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-display.urgent .timer-value {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
animation: pulse-urgent 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-urgent {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level label */
|
||||||
|
.level-label {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinds */
|
||||||
|
.blinds-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blinds-main {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ante-info {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next level */
|
||||||
|
.next-level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-up {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: bigger clock */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.clock-display {
|
||||||
|
min-height: 260px;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blinds-main {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
268
frontend/src/lib/components/OvalTable.svelte
Normal file
268
frontend/src/lib/components/OvalTable.svelte
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* SVG-based top-down oval poker table component.
|
||||||
|
*
|
||||||
|
* Renders an oval table with numbered seat positions around the edge.
|
||||||
|
* Occupied seats show player name (truncated); empty seats show seat number.
|
||||||
|
* Supports 6-max through 10-max configurations.
|
||||||
|
* 48px touch targets for each seat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
interface SeatInfo {
|
||||||
|
seatNumber: number;
|
||||||
|
player: Player | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tableNumber: number;
|
||||||
|
tableId: string;
|
||||||
|
maxSeats: number;
|
||||||
|
seats: SeatInfo[];
|
||||||
|
dealerSeat: number | null;
|
||||||
|
selectedSeat: number | null;
|
||||||
|
onseattap?: (tableId: string, seatNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
tableNumber,
|
||||||
|
tableId,
|
||||||
|
maxSeats,
|
||||||
|
seats,
|
||||||
|
dealerSeat = null,
|
||||||
|
selectedSeat = null,
|
||||||
|
onseattap
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
/** SVG dimensions. */
|
||||||
|
const SVG_WIDTH = 280;
|
||||||
|
const SVG_HEIGHT = 200;
|
||||||
|
const CX = SVG_WIDTH / 2;
|
||||||
|
const CY = SVG_HEIGHT / 2;
|
||||||
|
const RX = 110; // oval horizontal radius
|
||||||
|
const RY = 70; // oval vertical radius
|
||||||
|
const SEAT_RADIUS = 18;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute seat positions around the oval.
|
||||||
|
* Seats are distributed evenly. Seat 1 starts at the bottom-center
|
||||||
|
* and proceeds clockwise.
|
||||||
|
*/
|
||||||
|
function seatPosition(index: number, total: number): { x: number; y: number } {
|
||||||
|
// Start from bottom (PI/2) and go clockwise (negative angle direction in SVG coords)
|
||||||
|
const startAngle = Math.PI / 2;
|
||||||
|
const angle = startAngle + (2 * Math.PI * index) / total;
|
||||||
|
const x = CX + (RX + SEAT_RADIUS + 6) * Math.cos(angle);
|
||||||
|
const y = CY - (RY + SEAT_RADIUS + 6) * Math.sin(angle);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate player name to fit in seat circle. */
|
||||||
|
function truncateName(name: string, maxLen: number = 6): string {
|
||||||
|
if (name.length <= maxLen) return name;
|
||||||
|
return name.slice(0, maxLen - 1) + '\u2026';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeatTap(seatNumber: number): void {
|
||||||
|
onseattap?.(tableId, seatNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find seat info by seat number. */
|
||||||
|
function getSeat(seatNumber: number): SeatInfo {
|
||||||
|
return seats.find((s) => s.seatNumber === seatNumber) ?? { seatNumber, player: null };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="oval-table-container">
|
||||||
|
<div class="table-label">Table {tableNumber}</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}"
|
||||||
|
class="oval-table-svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Table {tableNumber} seating layout"
|
||||||
|
>
|
||||||
|
<!-- Oval table surface -->
|
||||||
|
<ellipse
|
||||||
|
cx={CX}
|
||||||
|
cy={CY}
|
||||||
|
rx={RX}
|
||||||
|
ry={RY}
|
||||||
|
class="table-surface"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Table felt inner -->
|
||||||
|
<ellipse
|
||||||
|
cx={CX}
|
||||||
|
cy={CY}
|
||||||
|
rx={RX - 4}
|
||||||
|
ry={RY - 4}
|
||||||
|
class="table-felt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Table number in center -->
|
||||||
|
<text x={CX} y={CY + 4} class="table-number-text" text-anchor="middle">
|
||||||
|
T{tableNumber}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Seats -->
|
||||||
|
{#each Array(maxSeats) as _, i}
|
||||||
|
{@const seatNum = i + 1}
|
||||||
|
{@const pos = seatPosition(i, maxSeats)}
|
||||||
|
{@const seat = getSeat(seatNum)}
|
||||||
|
{@const isOccupied = seat.player !== null}
|
||||||
|
{@const isDealer = dealerSeat === seatNum}
|
||||||
|
{@const isSelected = selectedSeat === seatNum}
|
||||||
|
|
||||||
|
<!-- Seat circle (touch target) -->
|
||||||
|
<g
|
||||||
|
class="seat-group"
|
||||||
|
class:occupied={isOccupied}
|
||||||
|
class:empty={!isOccupied}
|
||||||
|
class:selected={isSelected}
|
||||||
|
onclick={() => handleSeatTap(seatNum)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleSeatTap(seatNum)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Seat {seatNum}{isOccupied ? `: ${seat.player?.name}` : ' (empty)'}"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={SEAT_RADIUS}
|
||||||
|
class="seat-circle"
|
||||||
|
/>
|
||||||
|
{#if isOccupied}
|
||||||
|
<!-- Player name -->
|
||||||
|
<text x={pos.x} y={pos.y - 3} class="seat-player-name" text-anchor="middle">
|
||||||
|
{truncateName(seat.player?.name ?? '')}
|
||||||
|
</text>
|
||||||
|
<!-- Seat number below name -->
|
||||||
|
<text x={pos.x} y={pos.y + 11} class="seat-number-small" text-anchor="middle">
|
||||||
|
{seatNum}
|
||||||
|
</text>
|
||||||
|
{:else}
|
||||||
|
<!-- Empty seat: just seat number -->
|
||||||
|
<text x={pos.x} y={pos.y + 4} class="seat-number" text-anchor="middle">
|
||||||
|
{seatNum}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Dealer button -->
|
||||||
|
{#if isDealer}
|
||||||
|
{@const dAngle = Math.PI / 2 + (2 * Math.PI * i) / maxSeats}
|
||||||
|
{@const dx = CX + (RX - 12) * Math.cos(dAngle)}
|
||||||
|
{@const dy = CY - (RY - 12) * Math.sin(dAngle)}
|
||||||
|
<circle cx={dx} cy={dy} r={8} class="dealer-button" />
|
||||||
|
<text x={dx} y={dy + 3.5} class="dealer-text" text-anchor="middle">D</text>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.oval-table-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oval-table-svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table surface */
|
||||||
|
.table-surface {
|
||||||
|
fill: var(--color-surface);
|
||||||
|
stroke: var(--color-border);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-felt {
|
||||||
|
fill: var(--ctp-green);
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-number-text {
|
||||||
|
fill: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seats */
|
||||||
|
.seat-group {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-group:focus-visible .seat-circle {
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-circle {
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transition: fill 100ms ease, stroke 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-group.occupied .seat-circle {
|
||||||
|
fill: var(--color-surface);
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-group.empty .seat-circle {
|
||||||
|
fill: var(--color-bg);
|
||||||
|
stroke: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-group.selected .seat-circle {
|
||||||
|
fill: var(--color-primary);
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-group.selected .seat-player-name,
|
||||||
|
.seat-group.selected .seat-number,
|
||||||
|
.seat-group.selected .seat-number-small {
|
||||||
|
fill: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-player-name {
|
||||||
|
fill: var(--color-text);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-number {
|
||||||
|
fill: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-number-small {
|
||||||
|
fill: var(--color-text-muted);
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dealer button */
|
||||||
|
.dealer-button {
|
||||||
|
fill: var(--ctp-yellow);
|
||||||
|
stroke: var(--ctp-peach);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dealer-text {
|
||||||
|
fill: var(--ctp-crust);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
196
frontend/src/lib/components/TableListView.svelte
Normal file
196
frontend/src/lib/components/TableListView.svelte
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Table list view — alternative to oval view for large tournaments.
|
||||||
|
*
|
||||||
|
* DataTable format showing tables with player counts and balance status.
|
||||||
|
* Expandable rows to see seated players.
|
||||||
|
* Designed for 10+ table tournaments where oval view gets crowded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Table, Player } from '$lib/stores/tournament.svelte';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tables: Table[];
|
||||||
|
players: Player[];
|
||||||
|
balanceMaxDiff: number;
|
||||||
|
onbreaktable?: (tableId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
tables,
|
||||||
|
players,
|
||||||
|
balanceMaxDiff,
|
||||||
|
onbreaktable
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
/** Expand tracking. */
|
||||||
|
let expandedTableId = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** Build table data with computed fields. */
|
||||||
|
let tableData = $derived(
|
||||||
|
tables.map((t) => ({
|
||||||
|
...t,
|
||||||
|
player_count: t.players.length,
|
||||||
|
balance: t.players.length > 0 ? balanceIndicator(t.players.length) : '-'
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'number', label: 'Table #', sortable: true, align: 'center' as const, width: '80px' },
|
||||||
|
{ key: 'player_count', label: 'Players', sortable: true, align: 'center' as const, width: '80px' },
|
||||||
|
{ key: 'seats', label: 'Seats', sortable: true, align: 'center' as const, width: '80px' },
|
||||||
|
{
|
||||||
|
key: 'balance',
|
||||||
|
label: 'Balance',
|
||||||
|
sortable: false,
|
||||||
|
align: 'center' as const,
|
||||||
|
width: '80px'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function balanceIndicator(playerCount: number): string {
|
||||||
|
if (balanceMaxDiff <= 1) return 'OK';
|
||||||
|
// Rough indicator: if this table is above or below average
|
||||||
|
const avg = tables.reduce((sum, t) => sum + t.players.length, 0) / tables.length;
|
||||||
|
const diff = Math.abs(playerCount - avg);
|
||||||
|
if (diff <= 1) return 'OK';
|
||||||
|
return diff >= 2 ? '!!' : '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get players seated at a specific table. */
|
||||||
|
function playersAtTable(tableId: string): Player[] {
|
||||||
|
const table = tables.find((t) => t.id === tableId);
|
||||||
|
if (!table) return [];
|
||||||
|
return table.players
|
||||||
|
.map((pid) => players.find((p) => p.id === pid))
|
||||||
|
.filter((p): p is Player => p !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(item: Record<string, unknown>): void {
|
||||||
|
const id = String(item['id']);
|
||||||
|
expandedTableId = expandedTableId === id ? null : id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="table-list-view">
|
||||||
|
<DataTable
|
||||||
|
{columns}
|
||||||
|
data={tableData}
|
||||||
|
sortable={true}
|
||||||
|
searchable={false}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage="No tables set up yet"
|
||||||
|
rowKey={(item) => String(item['id'])}
|
||||||
|
onrowclick={handleRowClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Expanded player list -->
|
||||||
|
{#if expandedTableId}
|
||||||
|
{@const expandedPlayers = playersAtTable(expandedTableId)}
|
||||||
|
{@const expandedTable = tables.find((t) => t.id === expandedTableId)}
|
||||||
|
<div class="expanded-panel">
|
||||||
|
<div class="expanded-header">
|
||||||
|
<span class="expanded-title">
|
||||||
|
Table {expandedTable?.number} - {expandedPlayers.length} players
|
||||||
|
</span>
|
||||||
|
{#if onbreaktable && tables.length > 1}
|
||||||
|
<button
|
||||||
|
class="break-btn touch-target"
|
||||||
|
onclick={() => onbreaktable?.(expandedTableId ?? '')}
|
||||||
|
>
|
||||||
|
Break Table
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if expandedPlayers.length > 0}
|
||||||
|
<ul class="player-list">
|
||||||
|
{#each expandedPlayers as player}
|
||||||
|
<li class="player-item">
|
||||||
|
<span class="player-name">{player.name}</span>
|
||||||
|
<span class="player-chips number">{player.chips.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-players">No players seated.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table-list-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-panel {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background-color: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chips {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-players {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -16,11 +16,21 @@ export interface ClockSnapshot {
|
||||||
small_blind: number;
|
small_blind: number;
|
||||||
big_blind: number;
|
big_blind: number;
|
||||||
ante: number;
|
ante: number;
|
||||||
|
bb_ante: number;
|
||||||
elapsed_seconds: number;
|
elapsed_seconds: number;
|
||||||
remaining_seconds: number;
|
remaining_seconds: number;
|
||||||
|
duration_seconds: number;
|
||||||
is_break: boolean;
|
is_break: boolean;
|
||||||
is_paused: boolean;
|
is_paused: boolean;
|
||||||
|
is_hand_for_hand: boolean;
|
||||||
next_break_in_seconds: number | null;
|
next_break_in_seconds: number | null;
|
||||||
|
next_level_name: string | null;
|
||||||
|
next_level_small_blind: number | null;
|
||||||
|
next_level_big_blind: number | null;
|
||||||
|
next_level_duration_seconds: number | null;
|
||||||
|
next_level_is_break: boolean;
|
||||||
|
chip_up_denomination: number | null;
|
||||||
|
game_type: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away';
|
export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away';
|
||||||
|
|
@ -52,11 +62,60 @@ export interface FinancialSummary {
|
||||||
total_buyin: number;
|
total_buyin: number;
|
||||||
total_rebuys: number;
|
total_rebuys: number;
|
||||||
total_addons: number;
|
total_addons: number;
|
||||||
|
total_reentries: number;
|
||||||
total_collected: number;
|
total_collected: number;
|
||||||
prize_pool: number;
|
prize_pool: number;
|
||||||
house_fee: number;
|
house_fee: number;
|
||||||
paid_positions: number;
|
paid_positions: number;
|
||||||
payouts: PayoutEntry[];
|
payouts: PayoutEntry[];
|
||||||
|
buyin_count: number;
|
||||||
|
rebuy_count: number;
|
||||||
|
addon_count: number;
|
||||||
|
reentry_count: number;
|
||||||
|
buyin_amount: number;
|
||||||
|
rebuy_amount: number;
|
||||||
|
addon_amount: number;
|
||||||
|
reentry_amount: number;
|
||||||
|
guarantee: number | null;
|
||||||
|
guarantee_shortfall: number;
|
||||||
|
season_reserve: number;
|
||||||
|
rounding_denomination: number;
|
||||||
|
rake_breakdown: RakeBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RakeBreakdown {
|
||||||
|
category: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
tournament_id: string;
|
||||||
|
player_id: string;
|
||||||
|
player_name: string;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
chips: number;
|
||||||
|
timestamp: number;
|
||||||
|
undone: boolean;
|
||||||
|
undone_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DealType = 'icm' | 'chip_chop' | 'even_chop' | 'custom' | 'partial_chop';
|
||||||
|
|
||||||
|
export interface DealProposal {
|
||||||
|
type: DealType;
|
||||||
|
players: DealPlayerEntry[];
|
||||||
|
amount_in_play: number;
|
||||||
|
amount_to_split: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealPlayerEntry {
|
||||||
|
player_id: string;
|
||||||
|
player_name: string;
|
||||||
|
chips: number;
|
||||||
|
proposed_payout: number;
|
||||||
|
original_payout: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayoutEntry {
|
export interface PayoutEntry {
|
||||||
|
|
@ -150,6 +209,24 @@ class TournamentState {
|
||||||
return this.players.length;
|
return this.players.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Busted (eliminated) players count. */
|
||||||
|
get bustedPlayers(): number {
|
||||||
|
return this.players.filter((p) => p.status === 'eliminated').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average chip stack among active players. */
|
||||||
|
get averageStack(): number {
|
||||||
|
const active = this.players.filter((p) => p.status === 'active');
|
||||||
|
if (active.length === 0) return 0;
|
||||||
|
const total = active.reduce((sum, p) => sum + p.chips, 0);
|
||||||
|
return Math.round(total / active.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total chips in play across all active players. */
|
||||||
|
get totalChips(): number {
|
||||||
|
return this.players.filter((p) => p.status === 'active').reduce((sum, p) => sum + p.chips, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/** Active tables count. */
|
/** Active tables count. */
|
||||||
get activeTables(): number {
|
get activeTables(): number {
|
||||||
return this.tables.filter((t) => t.players.length > 0).length;
|
return this.tables.filter((t) => t.players.length > 0).length;
|
||||||
|
|
@ -160,6 +237,19 @@ class TournamentState {
|
||||||
return this.balanceStatus?.is_balanced ?? true;
|
return this.balanceStatus?.is_balanced ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Transactions list. */
|
||||||
|
transactions = $state<Transaction[]>([]);
|
||||||
|
|
||||||
|
/** Handle transaction updates. */
|
||||||
|
private addOrUpdateTransaction(tx: Transaction): void {
|
||||||
|
const idx = this.transactions.findIndex((t) => t.id === tx.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.transactions[idx] = tx;
|
||||||
|
} else {
|
||||||
|
this.transactions = [tx, ...this.transactions];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// WebSocket message handler
|
// WebSocket message handler
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -246,6 +336,15 @@ class TournamentState {
|
||||||
this.addActivity(msg.data as ActivityEntry);
|
this.addActivity(msg.data as ActivityEntry);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
case 'transaction.new':
|
||||||
|
case 'transaction.updated':
|
||||||
|
this.addOrUpdateTransaction(msg.data as Transaction);
|
||||||
|
break;
|
||||||
|
case 'transaction.undone':
|
||||||
|
this.addOrUpdateTransaction(msg.data as Transaction);
|
||||||
|
break;
|
||||||
|
|
||||||
// Connection
|
// Connection
|
||||||
case 'connected':
|
case 'connected':
|
||||||
console.log('tournament: connected to server');
|
console.log('tournament: connected to server');
|
||||||
|
|
@ -266,6 +365,7 @@ class TournamentState {
|
||||||
this.activity = [];
|
this.activity = [];
|
||||||
this.rankings = [];
|
this.rankings = [];
|
||||||
this.balanceStatus = null;
|
this.balanceStatus = null;
|
||||||
|
this.transactions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -281,6 +381,7 @@ class TournamentState {
|
||||||
this.activity = snapshot.activity ?? [];
|
this.activity = snapshot.activity ?? [];
|
||||||
this.rankings = snapshot.rankings ?? [];
|
this.rankings = snapshot.rankings ?? [];
|
||||||
this.balanceStatus = snapshot.balance_status ?? null;
|
this.balanceStatus = snapshot.balance_status ?? null;
|
||||||
|
this.transactions = snapshot.transactions ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private addOrUpdatePlayer(player: Player): void {
|
private addOrUpdatePlayer(player: Player): void {
|
||||||
|
|
@ -320,6 +421,7 @@ interface FullSnapshot {
|
||||||
activity?: ActivityEntry[];
|
activity?: ActivityEntry[];
|
||||||
rankings?: PlayerRanking[];
|
rankings?: PlayerRanking[];
|
||||||
balance_status?: BalanceStatus;
|
balance_status?: BalanceStatus;
|
||||||
|
transactions?: Transaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton tournament state instance. */
|
/** Singleton tournament state instance. */
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,288 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tournament } from '$lib/stores/tournament.svelte';
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import ClockDisplay from '$lib/components/ClockDisplay.svelte';
|
||||||
|
import BlindInfo from '$lib/components/BlindInfo.svelte';
|
||||||
|
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
||||||
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overview tab -- the TD's primary workspace.
|
||||||
|
*
|
||||||
|
* Priority order (from CONTEXT.md):
|
||||||
|
* 1. Clock & current level (biggest element, ~40% viewport on mobile)
|
||||||
|
* 2. Time to next break
|
||||||
|
* 3. Player count (registered / remaining / busted)
|
||||||
|
* 4. Table balance status
|
||||||
|
* 5. Financial summary (prize pool, entries, rebuys)
|
||||||
|
* 6. Recent activity feed (last few actions)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Navigate to tables tab. */
|
||||||
|
function goToTables(): void {
|
||||||
|
window.location.hash = '';
|
||||||
|
window.location.pathname = '/tables';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to financials tab. */
|
||||||
|
function goToFinancials(): void {
|
||||||
|
window.location.hash = '';
|
||||||
|
window.location.pathname = '/financials';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format chip/currency values. */
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<h2>Overview</h2>
|
|
||||||
<p class="text-secondary">Tournament dashboard — detailed views coming in Plan N.</p>
|
|
||||||
|
|
||||||
{#if tournament.clock}
|
{#if tournament.clock}
|
||||||
<div class="stats-grid">
|
{@const clock = tournament.clock}
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">Players</span>
|
<!-- 1. Clock Display (biggest element) -->
|
||||||
<span class="stat-value number">{tournament.remainingPlayers}/{tournament.totalPlayers}</span>
|
<ClockDisplay {clock} />
|
||||||
|
|
||||||
|
<!-- 2. Time to break -->
|
||||||
|
<BlindInfo {clock} />
|
||||||
|
|
||||||
|
<!-- 3. Player Count Card -->
|
||||||
|
<div class="info-card player-card">
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-stat-main number">{tournament.remainingPlayers} / {tournament.totalPlayers}</span>
|
||||||
|
<span class="card-stat-label">remaining</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="card-row-secondary">
|
||||||
<span class="stat-label">Tables</span>
|
<span class="card-detail">{tournament.bustedPlayers} busted</span>
|
||||||
<span class="stat-value number">{tournament.activeTables}</span>
|
<span class="card-detail">Avg: <span class="chips">{formatNumber(tournament.averageStack)}</span></span>
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">Level</span>
|
|
||||||
<span class="stat-value number">{tournament.clock.level}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">Blinds</span>
|
|
||||||
<span class="stat-value blinds">{tournament.clock.small_blind}/{tournament.clock.big_blind}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if tournament.totalChips > 0}
|
||||||
|
<div class="card-muted">
|
||||||
|
Total chips: <span class="chips">{formatNumber(tournament.totalChips)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Table Balance Status -->
|
||||||
|
{#if tournament.balanceStatus}
|
||||||
|
{#if tournament.isBalanced}
|
||||||
|
<div class="info-card balance-card balanced">
|
||||||
|
<span class="balance-indicator balanced-dot"></span>
|
||||||
|
<span class="balance-text">Tables balanced</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="info-card balance-card unbalanced touch-target"
|
||||||
|
onclick={goToTables}
|
||||||
|
>
|
||||||
|
<span class="balance-indicator unbalanced-dot"></span>
|
||||||
|
<span class="balance-text">
|
||||||
|
Tables unbalanced
|
||||||
|
{#if tournament.balanceStatus.moves_needed.length > 0}
|
||||||
|
-- {tournament.balanceStatus.moves_needed.length} move{tournament.balanceStatus.moves_needed.length !== 1 ? 's' : ''} needed
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="card-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 5. Financial Summary Card -->
|
||||||
|
{#if tournament.financials}
|
||||||
|
{@const fin = tournament.financials}
|
||||||
|
<button
|
||||||
|
class="info-card finance-summary-card touch-target"
|
||||||
|
onclick={goToFinancials}
|
||||||
|
>
|
||||||
|
<div class="finance-row-main">
|
||||||
|
<span class="finance-label">Prize Pool</span>
|
||||||
|
<span class="currency finance-pool">{formatNumber(fin.prize_pool)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="finance-row-detail">
|
||||||
|
<span class="finance-detail">Entries: {fin.buyin_count ?? 0}</span>
|
||||||
|
<span class="finance-detail">Rebuys: {fin.rebuy_count ?? 0}</span>
|
||||||
|
<span class="finance-detail">Add-ons: {fin.addon_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
{#if fin.guarantee && fin.guarantee > 0 && fin.guarantee_shortfall > 0}
|
||||||
|
<div class="guarantee-warning">
|
||||||
|
Guarantee: {formatNumber(fin.guarantee)}
|
||||||
|
(house covers {formatNumber(fin.guarantee_shortfall)})
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="card-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 6. Activity Feed (scrollable, takes remaining space) -->
|
||||||
|
<ActivityFeed entries={tournament.activity} limit={15} />
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Skeleton loading state -->
|
||||||
|
<Loading variant="skeleton" rows={5} />
|
||||||
<p class="empty-state">No active tournament. Start or join a tournament to see the overview.</p>
|
<p class="empty-state">No active tournament. Start or join a tournament to see the overview.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-content {
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
/* Info cards */
|
||||||
font-size: var(--text-2xl);
|
.info-card {
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
/* Player card */
|
||||||
font-size: var(--text-xs);
|
.player-card .card-row {
|
||||||
text-transform: uppercase;
|
display: flex;
|
||||||
letter-spacing: 0.05em;
|
align-items: baseline;
|
||||||
color: var(--color-text-muted);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.card-stat-main {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-stat-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-secondary {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-detail {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-muted {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Balance card */
|
||||||
|
.balance-card {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.balance-card {
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.balance-card:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balanced-dot {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unbalanced-dot {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.unbalanced {
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chevron {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Finance summary card */
|
||||||
|
.finance-summary-card {
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-summary-card:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-pool {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-prize);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-row-detail {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-detail {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guarantee-warning {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-warning);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: var(--space-8) 0;
|
padding: var(--space-4) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,234 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Tables tab page.
|
||||||
|
*
|
||||||
|
* Grid of oval tables (default) with seated players, list view alternative,
|
||||||
|
* balancing panel, break table action, and seat move via tap-tap flow.
|
||||||
|
* All interactions use tap-tap pattern (no drag-and-drop in Phase 1).
|
||||||
|
*/
|
||||||
|
|
||||||
import { tournament } from '$lib/stores/tournament.svelte';
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import type { Player, BalanceMove } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import OvalTable from '$lib/components/OvalTable.svelte';
|
||||||
|
import TableListView from '$lib/components/TableListView.svelte';
|
||||||
|
import BalancingPanel from '$lib/components/BalancingPanel.svelte';
|
||||||
|
|
||||||
const columns = [
|
/** View mode: oval (default) or list. */
|
||||||
{ key: 'number', label: 'Table #', sortable: true, align: 'center' as const },
|
let viewMode = $state<'oval' | 'list'>('oval');
|
||||||
{ key: 'seats', label: 'Seats', sortable: true, align: 'center' as const },
|
|
||||||
{ key: 'player_count', label: 'Players', sortable: true, align: 'center' as const },
|
|
||||||
{ key: 'is_final_table', label: 'Final', hideMobile: true, sortable: true, align: 'center' as const, render: (t: Record<string, unknown>) => t['is_final_table'] ? 'Yes' : '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let tableData = $derived(
|
/** Tap-tap move state: first tap selects source, second tap selects destination. */
|
||||||
tournament.tables.map((t) => ({
|
let moveSource = $state<{ tableId: string; seatNumber: number } | null>(null);
|
||||||
...t,
|
let confirmingBreak = $state<string | null>(null);
|
||||||
player_count: t.players.length
|
let handForHand = $state(false);
|
||||||
}))
|
|
||||||
);
|
/** Build seat info for a specific table. */
|
||||||
|
function seatsForTable(tableId: string, maxSeats: number) {
|
||||||
|
const table = tournament.tables.find((t) => t.id === tableId);
|
||||||
|
if (!table) return [];
|
||||||
|
|
||||||
|
const seats: Array<{ seatNumber: number; player: Player | null }> = [];
|
||||||
|
for (let i = 1; i <= maxSeats; i++) {
|
||||||
|
// Find player assigned to this seat
|
||||||
|
const player = tournament.players.find(
|
||||||
|
(p) => p.table_id === tableId && p.seat === i
|
||||||
|
);
|
||||||
|
seats.push({ seatNumber: i, player: player ?? null });
|
||||||
|
}
|
||||||
|
return seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle seat tap for tap-tap move flow. */
|
||||||
|
function handleSeatTap(tableId: string, seatNumber: number): void {
|
||||||
|
if (!moveSource) {
|
||||||
|
// First tap: select source
|
||||||
|
const player = tournament.players.find(
|
||||||
|
(p) => p.table_id === tableId && p.seat === seatNumber
|
||||||
|
);
|
||||||
|
if (!player) {
|
||||||
|
toast.info('Select an occupied seat as source.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveSource = { tableId, seatNumber };
|
||||||
|
toast.info(`Selected ${player.name} (Table ${getTableNumber(tableId)}, Seat ${seatNumber}). Tap destination seat.`);
|
||||||
|
} else {
|
||||||
|
// Second tap: select destination
|
||||||
|
if (moveSource.tableId === tableId && moveSource.seatNumber === seatNumber) {
|
||||||
|
// Same seat: deselect
|
||||||
|
moveSource = null;
|
||||||
|
toast.info('Move cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destPlayer = tournament.players.find(
|
||||||
|
(p) => p.table_id === tableId && p.seat === seatNumber
|
||||||
|
);
|
||||||
|
if (destPlayer) {
|
||||||
|
toast.warning('Destination seat is occupied. Select an empty seat.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePlayer = tournament.players.find(
|
||||||
|
(p) => p.table_id === moveSource!.tableId && p.seat === moveSource!.seatNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Move confirmed: ${sourcePlayer?.name ?? 'Player'} to Table ${getTableNumber(tableId)}, Seat ${seatNumber}`
|
||||||
|
);
|
||||||
|
moveSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get table number from ID. */
|
||||||
|
function getTableNumber(tableId: string): number {
|
||||||
|
return tournament.tables.find((t) => t.id === tableId)?.number ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle break table action. */
|
||||||
|
function handleBreakTable(tableId: string): void {
|
||||||
|
confirmingBreak = tableId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm break table. */
|
||||||
|
function confirmBreakTable(): void {
|
||||||
|
if (!confirmingBreak) return;
|
||||||
|
const table = tournament.tables.find((t) => t.id === confirmingBreak);
|
||||||
|
const playerCount = table?.players.length ?? 0;
|
||||||
|
toast.success(`Table ${table?.number ?? '?'} broken. ${playerCount} players redistributed.`);
|
||||||
|
confirmingBreak = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel break table. */
|
||||||
|
function cancelBreakTable(): void {
|
||||||
|
confirmingBreak = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle suggest moves from balancing panel. */
|
||||||
|
function handleSuggestMoves(): void {
|
||||||
|
toast.info('Fetching balance suggestions...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle accept balance move. */
|
||||||
|
function handleAcceptMove(move: BalanceMove): void {
|
||||||
|
toast.success(`Move accepted: ${move.player_name} from Table ${move.from_table} to Table ${move.to_table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleView(): void {
|
||||||
|
viewMode = viewMode === 'oval' ? 'list' : 'oval';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHandForHand(): void {
|
||||||
|
handForHand = !handForHand;
|
||||||
|
toast.info(handForHand ? 'Hand-for-hand enabled.' : 'Hand-for-hand disabled.');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<h2>Tables</h2>
|
<div class="page-header">
|
||||||
<p class="text-secondary">Active tables and seating.</p>
|
<div>
|
||||||
|
<h2>Tables</h2>
|
||||||
|
<p class="text-secondary">
|
||||||
|
{tournament.tables.length} tables, {tournament.remainingPlayers} players seated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
{#if tournament.tables.length > 1}
|
||||||
|
<button
|
||||||
|
class="toggle-btn touch-target"
|
||||||
|
class:active={handForHand}
|
||||||
|
onclick={toggleHandForHand}
|
||||||
|
aria-pressed={handForHand}
|
||||||
|
>
|
||||||
|
H4H
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="toggle-btn touch-target"
|
||||||
|
onclick={toggleView}
|
||||||
|
aria-label="Switch to {viewMode === 'oval' ? 'list' : 'oval'} view"
|
||||||
|
>
|
||||||
|
{viewMode === 'oval' ? 'List' : 'Oval'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<!-- Move source indicator -->
|
||||||
{columns}
|
{#if moveSource}
|
||||||
data={tableData}
|
{@const srcPlayer = tournament.players.find(
|
||||||
sortable={true}
|
(p) => p.table_id === moveSource?.tableId && p.seat === moveSource?.seatNumber
|
||||||
searchable={false}
|
)}
|
||||||
loading={false}
|
<div class="move-banner">
|
||||||
emptyMessage="No tables set up yet"
|
<span>Moving: <strong>{srcPlayer?.name ?? 'Player'}</strong> (Table {getTableNumber(moveSource.tableId)}, Seat {moveSource.seatNumber})</span>
|
||||||
rowKey={(item) => String(item['id'])}
|
<button class="cancel-move-btn" onclick={() => { moveSource = null; }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Balancing panel -->
|
||||||
|
<BalancingPanel
|
||||||
|
balanceStatus={tournament.balanceStatus}
|
||||||
|
onacceptmove={handleAcceptMove}
|
||||||
|
onsuggestmoves={handleSuggestMoves}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Break table confirmation dialog -->
|
||||||
|
{#if confirmingBreak}
|
||||||
|
{@const breakTable = tournament.tables.find((t) => t.id === confirmingBreak)}
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<div class="confirm-content">
|
||||||
|
<p class="confirm-text">
|
||||||
|
Distribute {breakTable?.players.length ?? 0} players from Table {breakTable?.number ?? '?'} to remaining tables?
|
||||||
|
</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="confirm-yes touch-target" onclick={confirmBreakTable}>
|
||||||
|
Break Table
|
||||||
|
</button>
|
||||||
|
<button class="confirm-no touch-target" onclick={cancelBreakTable}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Table views -->
|
||||||
|
{#if viewMode === 'oval'}
|
||||||
|
{#if tournament.tables.length === 0}
|
||||||
|
<p class="empty-state">No tables set up yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-grid">
|
||||||
|
{#each tournament.tables as table (table.id)}
|
||||||
|
<div class="table-card">
|
||||||
|
<OvalTable
|
||||||
|
tableNumber={table.number}
|
||||||
|
tableId={table.id}
|
||||||
|
maxSeats={table.seats}
|
||||||
|
seats={seatsForTable(table.id, table.seats)}
|
||||||
|
dealerSeat={null}
|
||||||
|
selectedSeat={moveSource?.tableId === table.id ? moveSource.seatNumber : null}
|
||||||
|
onseattap={handleSeatTap}
|
||||||
|
/>
|
||||||
|
<div class="table-actions">
|
||||||
|
{#if tournament.tables.length > 1}
|
||||||
|
<button
|
||||||
|
class="table-action-btn touch-target"
|
||||||
|
onclick={() => handleBreakTable(table.id)}
|
||||||
|
>
|
||||||
|
Break
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<TableListView
|
||||||
|
tables={tournament.tables}
|
||||||
|
players={tournament.players}
|
||||||
|
balanceMaxDiff={tournament.balanceStatus?.max_diff ?? 0}
|
||||||
|
onbreaktable={handleBreakTable}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -37,16 +236,212 @@
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
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 {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
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;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Move banner */
|
||||||
|
.move-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-banner strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-move-btn {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table grid */
|
||||||
|
.table-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.table-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.table-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.table-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action-btn {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-error);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action-btn:hover {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.confirm-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-content {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-6);
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-text {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-yes {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background-color: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-yes:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-no {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-no:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue