feat: tablet-first UX overhaul with wizard improvements
- Tablet-first responsive scoreboard: whole player card is tap target - Per-step wizard backgrounds and live summary panel - Larger touch targets, safe-area handling, reduced-motion support - German UI error strings in App.tsx - Updated SOUL.md and README.md for current architecture - Cleanup: removed legacy components, .cursor/, UI_IMPROVEMENTS.md - Renamed package to bscscore, fixed TOTAL_WIZARD_STEPS
This commit is contained in:
@@ -4,7 +4,7 @@ Pure domain model primitives for BSC Score. Everything here is framework-agnosti
|
||||
|
||||
## Exports
|
||||
|
||||
- `types.ts` – canonical TypeScript shapes (`Game`, `StandardGame`, `EndlosGame`, `NewGameData`, etc.).
|
||||
- `types.ts` – canonical TypeScript shapes (`Game`, `StandardGame`, `NewGameData`, etc.).
|
||||
- `constants.ts` – configuration, validation copy, UI sizing tokens.
|
||||
- `validation.ts` – safe helpers for validating/sanitising player & game inputs.
|
||||
- `gameUtils.ts` – derived state utilities (duration, winner detection, type guards).
|
||||
|
||||
@@ -49,7 +49,7 @@ export const BREAKPOINTS = {
|
||||
|
||||
export const UI_CONSTANTS = {
|
||||
// Progress indicators
|
||||
TOTAL_WIZARD_STEPS: 5,
|
||||
TOTAL_WIZARD_STEPS: 7,
|
||||
|
||||
// Input styling
|
||||
INPUT_FONT_SIZE: '1.2rem',
|
||||
|
||||
@@ -105,4 +105,4 @@ export function validateGameData(data: any): boolean {
|
||||
data.raceTo &&
|
||||
parseInt(data.raceTo) > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,6 @@ export interface NewGameData {
|
||||
breakSecond?: number | '';
|
||||
}
|
||||
|
||||
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
|
||||
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | 'breakRule' | 'breakOrder' | null;
|
||||
|
||||
export type GameFilter = 'all' | 'active' | 'completed';
|
||||
@@ -1,473 +1,376 @@
|
||||
/* GameDetail-specific styles only. Shared utility classes are now in global CSS. */
|
||||
.screen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: var(--app-height);
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.screen.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
position: relative;
|
||||
}
|
||||
.screen-content {
|
||||
/* Scoreboard — primary screen, optimized for landscape tablets */
|
||||
|
||||
.gameDetail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 0;
|
||||
}
|
||||
.game-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-md);
|
||||
min-height: 0;
|
||||
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 24px;
|
||||
color: #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.game-header {
|
||||
.gameHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scores-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.player-score {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin: 0 8px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.player-score:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
|
||||
.gameHeaderInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
min-width: 0;
|
||||
}
|
||||
.player-score:first-child {
|
||||
background-color: #43a047;
|
||||
|
||||
.bscMark {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, #e65100 100%);
|
||||
color: #1a1a1a;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
box-shadow: 0 0 12px rgba(255, 152, 0, 0.35);
|
||||
}
|
||||
.player-score:nth-child(2) {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
.player-score:nth-child(3) {
|
||||
background-color: #333;
|
||||
}
|
||||
.player-name {
|
||||
font-size: 28px;
|
||||
|
||||
.gameTitle {
|
||||
font-size: clamp(1rem, 2.5vh, 1.5rem);
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
.statusBadge {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
background: var(--color-primary);
|
||||
color: #1a1a1a;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.scoresContainer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playerScore {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
border-radius: var(--radius-xl);
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 3px solid transparent;
|
||||
transition: border-color var(--transition-base), transform var(--transition-fast);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fff 0%, #f0f0f0 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
.player1 {
|
||||
background: linear-gradient(180deg, #3a5a3d 0%, var(--color-player-1) 100%);
|
||||
}
|
||||
|
||||
.game-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
.player2 {
|
||||
background: linear-gradient(180deg, #354d6a 0%, var(--color-player-2) 100%);
|
||||
}
|
||||
|
||||
.game-status.active {
|
||||
background: #4caf50;
|
||||
.player3 {
|
||||
background: linear-gradient(180deg, #444 0%, var(--color-player-3) 100%);
|
||||
}
|
||||
|
||||
.playerScore.completed {
|
||||
opacity: 0.85;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.playerScore:focus-visible {
|
||||
outline: 3px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.playerScore:active:not([aria-disabled="true"]) {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.playerScore.winner {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary), 0 0 28px rgba(255, 152, 0, 0.45), var(--shadow-lg);
|
||||
}
|
||||
|
||||
.playerScore.winner .playerName {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.playerNameRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playerName {
|
||||
font-size: clamp(1.1rem, 3vh, 1.75rem);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.game-status.completed {
|
||||
background: #ff9800;
|
||||
color: #222;
|
||||
.breakIndicator {
|
||||
display: inline-block;
|
||||
width: clamp(14px, 2.5vh, 22px);
|
||||
height: clamp(14px, 2.5vh, 22px);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
|
||||
animation: breakPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breakPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
box-shadow: 0 0 14px rgba(255, 255, 255, 0.95), 0 0 20px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.winnerBadge {
|
||||
font-size: clamp(1rem, 2.5vh, 1.5rem);
|
||||
color: var(--color-primary);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 80%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width var(--transition-slow);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 40vh;
|
||||
font-size: clamp(4.5rem, 30vh, 14rem);
|
||||
font-weight: 900;
|
||||
margin: 20px 0 30px 0;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 4px 16px rgba(0,0,0,0.6);
|
||||
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
display: block;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.score-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.score-button {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
|
||||
color: #222;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 8px;
|
||||
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.score-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4);
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ffb74d 100%);
|
||||
}
|
||||
|
||||
.score-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.score-button:disabled {
|
||||
background: #666;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.game-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.control-button {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
background: #333;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
|
||||
.score::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.35) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
.control-button.delete {
|
||||
background: #f44336;
|
||||
|
||||
.scoreTap::after {
|
||||
animation: scoreRipple 0.35s ease-out forwards;
|
||||
}
|
||||
.game-detail-controls {
|
||||
|
||||
.scoreBump {
|
||||
animation: scoreBump 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scoreRipple {
|
||||
0% { opacity: 0.6; transform: scale(0.4); }
|
||||
100% { opacity: 0; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
@keyframes scoreBump {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
|
||||
.gameDetailControls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
margin: 40px 0 0 0;
|
||||
padding-bottom: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.franky .player-name {
|
||||
font-weight: bold;
|
||||
color: #ff8c00; /* Example color */
|
||||
}
|
||||
.active-player {
|
||||
position: relative;
|
||||
border: 4px solid #ff9800;
|
||||
box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15);
|
||||
background: linear-gradient(90deg, #ff9800 0 8px, rgba(255,152,0,0.15) 8px 100%);
|
||||
color: #222 !important;
|
||||
animation: activePulse 1.2s infinite alternate;
|
||||
z-index: 2;
|
||||
}
|
||||
.active-player .player-name, .active-player .score {
|
||||
color: #222 !important;
|
||||
text-shadow: 0 2px 8px rgba(255,255,255,0.25);
|
||||
}
|
||||
.active-player::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 8px;
|
||||
background: #ff9800;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
@keyframes activePulse {
|
||||
0% { box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15); }
|
||||
100% { box-shadow: 0 0 48px 16px #ff9800, 0 0 0 16px rgba(255,152,0,0.22); }
|
||||
}
|
||||
.turn-indicator {
|
||||
margin: 20px 0;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.potted-balls-container {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.potted-balls-header {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.potted-balls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.potted-ball-btn {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
.potted-ball-btn:hover:not(:disabled) {
|
||||
background-color: #45a049;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.potted-ball-btn:disabled {
|
||||
background-color: #222;
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rerack-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.rerack-btn {
|
||||
padding: 0.8rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
background-color: #3a539b;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.rerack-btn:hover {
|
||||
background-color: #4a6fbf;
|
||||
|
||||
.gameDetailControls > * {
|
||||
flex: 1;
|
||||
min-width: min(160px, 100%);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.foul-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
.finishButton {
|
||||
border: 2px solid var(--color-border) !important;
|
||||
opacity: 0.85;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.foul-btn {
|
||||
flex-grow: 1;
|
||||
background-color: #ffc107; /* Amber */
|
||||
color: #212529;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
/* Landscape tablet — maximize score visibility */
|
||||
@media (orientation: landscape) and (min-width: 768px) {
|
||||
.gameDetail {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.scoresContainer {
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.playerScore {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.foul-btn:hover {
|
||||
background-color: #ffca2c;
|
||||
/* Narrow portrait — stack players */
|
||||
@media (max-width: 600px) {
|
||||
.scoresContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: clamp(3rem, 20vw, 6rem);
|
||||
}
|
||||
}
|
||||
|
||||
.foul-btn:disabled {
|
||||
background-color: #e0e0e0;
|
||||
color: #9e9e9e;
|
||||
cursor: not-allowed;
|
||||
/* Landscape phone — keep scores readable, compact chrome */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.gameDetail {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
padding-bottom: calc(var(--space-xs) + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
.gameHeader {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.bscMark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.gameTitle {
|
||||
font-size: clamp(0.85rem, 2.2vh, 1.1rem);
|
||||
}
|
||||
|
||||
.scoresContainer {
|
||||
flex-direction: row;
|
||||
gap: var(--space-xs);
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playerScore {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.playerNameRow {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.playerName {
|
||||
font-size: clamp(0.75rem, 2.2vh, 1rem);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 4px;
|
||||
margin-bottom: var(--space-xs);
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: clamp(2rem, min(36vh, 24vw), 4.5rem);
|
||||
padding: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.gameDetailControls {
|
||||
margin-top: var(--space-xs);
|
||||
gap: var(--space-xs);
|
||||
padding-bottom: calc(var(--space-xs) + var(--keyboard-offset));
|
||||
}
|
||||
|
||||
.gameDetailControls > * {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.foul-indicator {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background-color: #c0392b;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.breakIndicator {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.foul-warning {
|
||||
background-color: #f39c12;
|
||||
color: #000;
|
||||
.scoreTap::after,
|
||||
.scoreBump {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Game Log Styles */
|
||||
.game-log {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.game-log-table-container {
|
||||
margin: 2.5rem auto 0 auto;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: #181818;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.game-log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 1.05rem;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
}
|
||||
.game-log-table th, .game-log-table td {
|
||||
border: 1px solid #444;
|
||||
padding: 0.5rem 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.game-log-table th {
|
||||
background: #333;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.game-log-table tr:nth-child(even) td {
|
||||
background: #232323;
|
||||
}
|
||||
.game-log-table tr:nth-child(odd) td {
|
||||
background: #181818;
|
||||
}
|
||||
.game-log-table .log-player-col {
|
||||
background: #222;
|
||||
color: #ff9800;
|
||||
font-size: 1.15rem;
|
||||
border-bottom: 2px solid #ff9800;
|
||||
}
|
||||
|
||||
.turn-change-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 2.5rem 0 1.5rem 0;
|
||||
}
|
||||
.turn-change-btn {
|
||||
background: #ff9800;
|
||||
color: #222;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 900;
|
||||
padding: 2rem 4rem;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 32px rgba(255,152,0,0.18), 0 2px 8px rgba(0,0,0,0.12);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
letter-spacing: 1px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
.turn-change-btn:hover, .turn-change-btn:focus {
|
||||
background: #ffa726;
|
||||
color: #111;
|
||||
box-shadow: 0 6px 40px rgba(255,152,0,0.28), 0 2px 8px rgba(0,0,0,0.18);
|
||||
}
|
||||
.pending-foul-info {
|
||||
margin-left: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: #ffc107;
|
||||
font-weight: 700;
|
||||
}
|
||||
.selected {
|
||||
outline: 3px solid #ff9800 !important;
|
||||
background: #fff3e0 !important;
|
||||
color: #222 !important;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from './GameDetail.module.css';
|
||||
import { Button } from '@lib/ui/Button';
|
||||
import { getGameWinner, isStandardGame } from '@lib/domain/gameUtils';
|
||||
import type { Game, EndlosGame } from '@lib/domain/types';
|
||||
|
||||
interface GameDetailProps {
|
||||
@@ -12,93 +15,172 @@ interface GameDetailProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface AnimatedScoreProps {
|
||||
value: number;
|
||||
tapping: boolean;
|
||||
}
|
||||
|
||||
function AnimatedScore({ value, tapping }: AnimatedScoreProps) {
|
||||
const [bumping, setBumping] = useState(false);
|
||||
const prevValue = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevValue.current !== value) {
|
||||
setBumping(true);
|
||||
const timer = window.setTimeout(() => setBumping(false), 250);
|
||||
prevValue.current = value;
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const className = [
|
||||
styles.score,
|
||||
tapping ? styles.scoreTap : '',
|
||||
bumping ? styles.scoreBump : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Game detail view for a single game.
|
||||
*/
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
|
||||
if (!game) return null;
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => {
|
||||
const [tappingPlayer, setTappingPlayer] = useState<number | null>(null);
|
||||
|
||||
const handleScoreUpdate = (playerIndex: number, change: number) => {
|
||||
onUpdateScore(playerIndex, change);
|
||||
// Silent update; toast notifications removed
|
||||
};
|
||||
if (!game || !isStandardGame(game)) return null;
|
||||
|
||||
const standardGame = game;
|
||||
const isCompleted = standardGame.status === 'completed';
|
||||
const winner = getGameWinner(standardGame);
|
||||
|
||||
const isCompleted = game.status === 'completed';
|
||||
|
||||
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
|
||||
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
|
||||
const playerNames = [standardGame.player1, standardGame.player2, standardGame.player3].filter(Boolean);
|
||||
const scores = [standardGame.score1, standardGame.score2, standardGame.score3].filter((_, i) => playerNames[i]);
|
||||
|
||||
const order = standardGame.breakOrder;
|
||||
const breakerIdx = standardGame.currentBreakerIdx;
|
||||
|
||||
const titleParts = [
|
||||
standardGame.gameType,
|
||||
standardGame.raceTo && standardGame.raceTo !== Infinity
|
||||
? `Race to ${standardGame.raceTo}`
|
||||
: standardGame.raceTo === Infinity
|
||||
? 'Endlos'
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={styles['game-detail']}>
|
||||
<div className={styles['game-title']}>
|
||||
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''}
|
||||
</div>
|
||||
<div className={styles['scores-container']}>
|
||||
<div className={styles.gameDetail}>
|
||||
<header className={styles.gameHeader}>
|
||||
<div className={styles.gameHeaderInner}>
|
||||
<span className={styles.bscMark} aria-hidden="true">BSC</span>
|
||||
<div className={styles.gameTitle}>{titleParts.join(' · ')}</div>
|
||||
</div>
|
||||
{isCompleted && (
|
||||
<span className={styles.statusBadge} aria-label="Spiel abgeschlossen">Abgeschlossen</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className={styles.scoresContainer} aria-label="Punktestand">
|
||||
{playerNames.map((name, idx) => {
|
||||
const currentScore = scores[idx];
|
||||
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
|
||||
|
||||
const currentScore = scores[idx] ?? 0;
|
||||
const playerName = name as string;
|
||||
const progressPercentage = standardGame.raceTo && standardGame.raceTo !== Infinity
|
||||
? Math.min((currentScore / standardGame.raceTo) * 100, 100)
|
||||
: 0;
|
||||
const isBreaker = order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1;
|
||||
const isWinner = isCompleted && winner === playerName;
|
||||
const playerClass = [
|
||||
styles.playerScore,
|
||||
styles[`player${idx + 1}` as keyof typeof styles],
|
||||
isWinner ? styles.winner : '',
|
||||
isCompleted ? styles.completed : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const handleScore = () => {
|
||||
if (isCompleted) return;
|
||||
setTappingPlayer(idx);
|
||||
window.setTimeout(() => setTappingPlayer((current) => (current === idx ? null : current)), 350);
|
||||
onUpdateScore(idx + 1, 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
|
||||
key={name + idx}
|
||||
className={playerClass}
|
||||
key={playerName + idx}
|
||||
id={`score${idx + 1}`}
|
||||
onClick={handleScore}
|
||||
onKeyDown={(e) => {
|
||||
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
handleScore();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isCompleted ? -1 : 0}
|
||||
aria-label={`Punkt für ${playerName}. Aktueller Stand: ${currentScore}`}
|
||||
aria-disabled={isCompleted}
|
||||
>
|
||||
<span className={styles['player-name']}>
|
||||
{name}
|
||||
{(() => {
|
||||
const order = (game as any).breakOrder as number[] | undefined;
|
||||
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
|
||||
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
|
||||
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</span>
|
||||
<div className={styles['progress-bar']}>
|
||||
<div
|
||||
className={styles['progress-fill']}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
<div className={styles.playerNameRow}>
|
||||
<span className={styles.playerName}>{playerName}</span>
|
||||
{isBreaker && !isCompleted && (
|
||||
<span
|
||||
className={styles.breakIndicator}
|
||||
title="Break"
|
||||
aria-label={`${playerName} hat den Break`}
|
||||
/>
|
||||
)}
|
||||
{isWinner && (
|
||||
<span className={styles.winnerBadge} aria-label="Gewinner">★</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={styles['score']}
|
||||
id={`score${idx + 1}`}
|
||||
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
|
||||
onKeyDown={(e) => {
|
||||
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onUpdateScore(idx + 1, 1);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isCompleted ? -1 : 0}
|
||||
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`}
|
||||
>
|
||||
{scores[idx]}
|
||||
</span>
|
||||
{/* +/- buttons removed per issue #29. Tap score to +1; use Undo to revert. */}
|
||||
|
||||
{standardGame.raceTo && standardGame.raceTo !== Infinity && (
|
||||
<div className={styles.progressBar} aria-hidden="true">
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatedScore value={currentScore} tapping={tappingPlayer === idx} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles['game-detail-controls']}>
|
||||
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
|
||||
|
||||
<div className={styles.gameDetailControls}>
|
||||
<Button variant="secondary" size="medium" onClick={onBack} aria-label="Zurück zur Liste">
|
||||
Zurück
|
||||
</Button>
|
||||
{onUndo && (
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
onClick={() => {
|
||||
onUndo();
|
||||
}}
|
||||
aria-label="Rückgängig"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onUndo}
|
||||
aria-label="Letzten Punkt rückgängig machen"
|
||||
>
|
||||
↶ Rückgängig
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
disabled={isCompleted}
|
||||
onClick={onFinishGame}
|
||||
aria-label={isCompleted ? 'Spiel bereits abgeschlossen' : 'Spiel beenden'}
|
||||
className={styles.finishButton}
|
||||
>
|
||||
{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameDetail;
|
||||
export default GameDetail;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- `game: Game`
|
||||
- `onUpdateScore(playerIndex: number, delta: number): void`
|
||||
- `onFinishGame(): void`
|
||||
- `onUpdateGame?(game: EndlosGame): void`
|
||||
- `onUpdateGame?(game: Game): void`
|
||||
- `onUndo?(): void`
|
||||
- `onForfeit?(): void`
|
||||
- `onBack(): void`
|
||||
|
||||
@@ -1,160 +1,152 @@
|
||||
/* Only GameCompletionModal-specific styles. Shared modal styles are now in Modal.module.css */
|
||||
.final-scores {
|
||||
margin: 20px 0;
|
||||
.modalWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.final-score {
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.confettiPiece {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
animation: confettiFall 1.5s ease-out forwards;
|
||||
animation-delay: calc(var(--i, 0) * 0.06s);
|
||||
left: calc(8% + var(--i, 0) * 7%);
|
||||
}
|
||||
|
||||
.confettiPiece:nth-child(3n) {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.confettiPiece:nth-child(3n + 1) {
|
||||
background: #ffc107;
|
||||
}
|
||||
|
||||
.confettiPiece:nth-child(3n + 2) {
|
||||
background: #ffeb3b;
|
||||
}
|
||||
|
||||
@keyframes confettiFall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(280px) rotate(540deg) scale(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.finalScores {
|
||||
margin: var(--space-md) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.finalScore {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 0;
|
||||
margin-bottom: 8px;
|
||||
background: #333;
|
||||
border-radius: 8px;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
min-height: var(--touch-target-comfortable);
|
||||
}
|
||||
.final-score .player-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
|
||||
.finalScoreWinner {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
.final-score .score {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
.winner-announcement {
|
||||
text-align: center;
|
||||
margin: 20px 0 0 0;
|
||||
padding: 32px 16px 24px 16px; /* extra top padding to keep icons inside */
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
|
||||
border-radius: 16px;
|
||||
font-size: 1.2rem;
|
||||
color: #222;
|
||||
|
||||
.finalScore .playerName {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
|
||||
animation: celebrationPulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
overflow: visible; /* avoid clipping decorative icons */
|
||||
}
|
||||
|
||||
.winner-announcement::before {
|
||||
content: '🎉';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 20px;
|
||||
font-size: 24px;
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.winner-announcement::after {
|
||||
content: '🏆';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 20px;
|
||||
font-size: 24px;
|
||||
animation: bounce 1s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.winner-announcement h3 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
color: var(--color-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@keyframes celebrationPulse {
|
||||
.finalScore .score {
|
||||
font-size: clamp(1.75rem, 5vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
color: var(--color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.winnerAnnouncement {
|
||||
text-align: center;
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
|
||||
border-radius: var(--radius-lg);
|
||||
color: #1a1a1a;
|
||||
font-weight: 700;
|
||||
box-shadow: var(--shadow-md), 0 0 24px rgba(255, 152, 0, 0.35);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: winnerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes winnerGlow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
|
||||
box-shadow: var(--shadow-md), 0 0 20px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(255, 152, 0, 0.4);
|
||||
box-shadow: var(--shadow-md), 0 0 32px rgba(255, 152, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 18px 0;
|
||||
font-size: 1.1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: #333;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn--warning {
|
||||
background: #f44336;
|
||||
}
|
||||
.btn:not(.btn--warning):hover {
|
||||
background: #444;
|
||||
}
|
||||
.btn--warning:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.btn {
|
||||
font-size: 1rem;
|
||||
padding: 14px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
.winnerAnnouncement h3 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.1rem, 3vw, 1.6rem);
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
font-size: 1.3rem;
|
||||
color: #495057;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.player-stats {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.player-name-stats {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.95rem;
|
||||
color: #6c757d;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
color: #212529;
|
||||
@media (min-width: 480px) {
|
||||
.modalActions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modalActions > * {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.confettiPiece {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.winnerAnnouncement {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
import modalStyles from '@lib/ui/Modal.module.css';
|
||||
import styles from './GameCompletionModal.module.css';
|
||||
import { Button } from '@lib/ui/Button';
|
||||
import type { Game } from '@lib/domain/types';
|
||||
import { getGamePlayers, isEndlosGame } from '@lib/domain/gameUtils';
|
||||
|
||||
interface GameCompletionModalProps {
|
||||
open: boolean;
|
||||
@@ -17,47 +19,72 @@ interface GameCompletionModalProps {
|
||||
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
|
||||
if (!open || !game) return null;
|
||||
|
||||
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
|
||||
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
|
||||
let maxScore, winners, winnerText;
|
||||
const players = getGamePlayers(game);
|
||||
const playerNames = players.map((player) => player.name);
|
||||
const scores = players.map((player) => player.score);
|
||||
const maxScore = Math.max(...scores);
|
||||
const winners = playerNames.filter((name, idx) => (scores[idx] ?? 0) === maxScore);
|
||||
let winnerText: string;
|
||||
|
||||
if (game.forfeitedBy) {
|
||||
if (isEndlosGame(game) && game.forfeitedBy) {
|
||||
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
|
||||
} else {
|
||||
maxScore = Math.max(...scores);
|
||||
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
|
||||
winnerText = winners.length > 1
|
||||
? `Unentschieden zwischen ${winners.join(' und ')}`
|
||||
: `${winners[0]} hat gewonnen!`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
|
||||
<div className={modalStyles['modal-content']}>
|
||||
<div
|
||||
id="game-completion-modal"
|
||||
className={`${modalStyles.modal} ${modalStyles.show}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="completion-modal-title"
|
||||
>
|
||||
<div className={`${modalStyles['modal-content']} ${styles.modalWrapper}`}>
|
||||
<div className={styles.confetti} aria-hidden="true">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<span key={i} className={styles.confettiPiece} style={{ '--i': i } as Record<string, number>} />
|
||||
))}
|
||||
</div>
|
||||
<div className={modalStyles['modal-header']}>
|
||||
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
|
||||
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
|
||||
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen" type="button">×</button>
|
||||
</div>
|
||||
<div className={modalStyles['modal-body']}>
|
||||
<div className={styles['final-scores']}>
|
||||
{playerNames.map((name, idx) => (
|
||||
<div className={styles['final-score']} key={name + idx}>
|
||||
<span className={styles['player-name']}>{name}</span>
|
||||
<span className={styles['score']}>{scores[idx]}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.finalScores}>
|
||||
{playerNames.map((name, idx) => {
|
||||
const isWinner = !(isEndlosGame(game) && game.forfeitedBy) && winners.includes(name) && winners.length === 1;
|
||||
return (
|
||||
<div
|
||||
className={`${styles.finalScore} ${isWinner ? styles.finalScoreWinner : ''}`}
|
||||
key={`${name}-${idx}`}
|
||||
>
|
||||
<span className={styles.playerName}>{name}</span>
|
||||
<span className={styles.score}>{scores[idx] ?? 0}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.winnerAnnouncement}>
|
||||
<h3>{winnerText}</h3>
|
||||
</div>
|
||||
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
|
||||
|
||||
</div>
|
||||
<div className={modalStyles['modal-footer']}>
|
||||
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
|
||||
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
|
||||
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
|
||||
<div className={styles.modalActions}>
|
||||
<Button variant="primary" size="large" onClick={onRematch} aria-label="Rematch starten">
|
||||
Rematch
|
||||
</Button>
|
||||
<Button variant="secondary" size="large" onClick={onConfirm} aria-label="Spiel bestätigen und abschließen">
|
||||
Bestätigen
|
||||
</Button>
|
||||
<Button variant="secondary" size="large" onClick={onClose} aria-label="Abbrechen">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameCompletionModal;
|
||||
export default GameCompletionModal;
|
||||
|
||||
@@ -1,284 +1,234 @@
|
||||
/* GameList-specific styles using design system tokens */
|
||||
.screen.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: var(--app-height);
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-size: var(--font-size-xxl);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-list {
|
||||
.gameList {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Filter buttons with improved symmetry */
|
||||
.filter-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
.newGameWrapper {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.filterButtons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin: var(--space-lg) 0 var(--space-md) 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
padding: var(--space-md) 0;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
min-height: var(--touch-target-comfortable);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.filterButtons > * {
|
||||
flex: 1 1 auto;
|
||||
min-width: min(160px, calc(50% - var(--space-sm)));
|
||||
min-height: var(--touch-target-large);
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
background: var(--color-secondary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.filter-button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Games container with improved spacing */
|
||||
.games-container {
|
||||
.gamesContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
padding-bottom: var(--space-md);
|
||||
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Game item with better symmetry and spacing */
|
||||
.game-item {
|
||||
.gameItem {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.gameItem:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.gameItem.active {
|
||||
border-left: 5px solid var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.gameItem.completed {
|
||||
border-left: 5px solid var(--color-text-muted);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.gameInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.gameInfo:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.gameMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statusChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: var(--touch-target-min);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
animation: livePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: var(--color-primary);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.statusCompleted {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.game-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-primary);
|
||||
.gameType {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.game-item.active {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.game-item.completed {
|
||||
background: var(--color-surface);
|
||||
opacity: 0.8;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Game info with improved layout */
|
||||
.game-info {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-type {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
background: var(--color-secondary);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.player-names {
|
||||
.playerNames {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.game-scores {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
.gameScores {
|
||||
font-size: var(--font-size-xxl);
|
||||
font-weight: 800;
|
||||
color: var(--color-primary);
|
||||
min-width: 120px;
|
||||
background: var(--color-background);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Delete button with improved touch target */
|
||||
.delete-button {
|
||||
width: var(--touch-target-comfortable);
|
||||
height: var(--touch-target-comfortable);
|
||||
border: none;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: var(--font-size-lg);
|
||||
.deleteWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-button::before {
|
||||
content: '🗑️';
|
||||
font-size: var(--font-size-lg);
|
||||
.deleteButton {
|
||||
min-width: 100px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: #cc0000;
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.delete-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.empty-state {
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: var(--space-xxl);
|
||||
padding: var(--space-xxl) var(--space-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-lg);
|
||||
background: var(--color-surface);
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--color-border);
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
font-size: var(--font-size-xxxl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-lg) 0 var(--space-md) 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* Tablet-specific improvements */
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.screen-content {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.emptyBall {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
margin-bottom: var(--space-md);
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
margin-top: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.filterButtons {
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-xl) 0 var(--space-lg) 0;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: var(--space-lg) 0;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
}
|
||||
|
||||
.game-item {
|
||||
|
||||
.gameInfo {
|
||||
padding: var(--space-xl);
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.game-info {
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.game-type {
|
||||
font-size: var(--font-size-xl);
|
||||
min-width: 150px;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.player-names {
|
||||
|
||||
.playerNames {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.game-scores {
|
||||
font-size: var(--font-size-xxl);
|
||||
min-width: 150px;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
|
||||
.gameScores {
|
||||
font-size: var(--font-size-xxxl);
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: var(--font-size-xl);
|
||||
padding: var(--space-xxl) var(--space-xl);
|
||||
|
||||
.deleteButton {
|
||||
min-width: 120px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.screen-content {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.game-info {
|
||||
@media (max-width: 600px) {
|
||||
.gameItem {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-type,
|
||||
.game-scores {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filterButtons > * {
|
||||
min-width: calc(33% - var(--space-sm));
|
||||
}
|
||||
|
||||
.deleteWrapper {
|
||||
padding: 0 var(--space-lg) var(--space-lg);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.liveDot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.gameItem:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,25 +28,17 @@ export default function GameList({
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
const getPlayerNames = (game: Game): string => {
|
||||
if ('players' in game) {
|
||||
return game.players.map(p => p.name).join(' vs ');
|
||||
} else {
|
||||
const standardGame = game as StandardGame;
|
||||
return standardGame.player3
|
||||
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
|
||||
: `${standardGame.player1} vs ${standardGame.player2}`;
|
||||
}
|
||||
const g = game as StandardGame;
|
||||
return g.player3
|
||||
? `${g.player1} vs ${g.player2} vs ${g.player3}`
|
||||
: `${g.player1} vs ${g.player2}`;
|
||||
};
|
||||
|
||||
const getScores = (game: Game): string => {
|
||||
if ('players' in game) {
|
||||
return game.players.map(p => p.score).join(' - ');
|
||||
} else {
|
||||
const standardGame = game as StandardGame;
|
||||
return standardGame.player3
|
||||
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
|
||||
: `${standardGame.score1} - ${standardGame.score2}`;
|
||||
}
|
||||
const g = game as StandardGame;
|
||||
return g.player3
|
||||
? `${g.score1} – ${g.score2} – ${g.score3}`
|
||||
: `${g.score1} – ${g.score2}`;
|
||||
};
|
||||
|
||||
const filterButtons = [
|
||||
@@ -56,39 +48,44 @@ export default function GameList({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles['game-list']}>
|
||||
<div className={styles['filter-buttons']}>
|
||||
<div className={styles.gameList}>
|
||||
<div className={styles.filterButtons} role="group" aria-label="Spiele filtern">
|
||||
{filterButtons.map(({ key, label, ariaLabel }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={filter === key ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
size="medium"
|
||||
onClick={() => setFilter(key)}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={filter === key}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles['games-container']}>
|
||||
<div className={styles.gamesContainer}>
|
||||
{filteredGames.length === 0 ? (
|
||||
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
|
||||
<div className={styles.emptyState} role="status">
|
||||
<div className={styles.emptyBall} aria-hidden="true">🎱</div>
|
||||
<p>Keine Spiele vorhanden</p>
|
||||
<p className={styles.emptyHint}>Tippe oben auf „+ Neues Spiel"</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredGames.map(game => {
|
||||
const playerNames = getPlayerNames(game);
|
||||
const scores = getScores(game);
|
||||
const isActive = game.status === 'active';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={game.id}
|
||||
variant="elevated"
|
||||
className={
|
||||
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
|
||||
}
|
||||
padding="none"
|
||||
className={`${styles.gameItem} ${isActive ? styles.active : styles.completed}`}
|
||||
>
|
||||
<div
|
||||
className={styles['game-info']}
|
||||
className={styles.gameInfo}
|
||||
onClick={() => onShowGameDetail(game.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -101,24 +98,33 @@ export default function GameList({
|
||||
aria-label={`Details für Spiel ${playerNames}`}
|
||||
aria-describedby={`game-${game.id}-description`}
|
||||
>
|
||||
<div className={styles['game-type']}>
|
||||
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
|
||||
<div className={styles.gameMeta}>
|
||||
<span className={`${styles.statusChip} ${isActive ? styles.statusActive : styles.statusCompleted}`}>
|
||||
{isActive && <span className={styles.liveDot} aria-hidden="true" />}
|
||||
{isActive ? 'Live' : 'Fertig'}
|
||||
</span>
|
||||
<div className={styles.gameType}>
|
||||
{game.gameType}{game.raceTo && game.raceTo !== Infinity ? ` · ${game.raceTo}` : game.raceTo === Infinity ? ' · Endlos' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['player-names']}>{playerNames}</div>
|
||||
<div className={styles['game-scores']}>{scores}</div>
|
||||
<div className={styles.playerNames}>{playerNames}</div>
|
||||
<div className={styles.gameScores}>{scores}</div>
|
||||
<div id={`game-${game.id}-description`} className="sr-only">
|
||||
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
|
||||
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
|
||||
{isActive ? 'Aktiv' : 'Abgeschlossen'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
onClick={() => onDeleteGame(game.id)}
|
||||
aria-label={`Spiel löschen: ${playerNames}`}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
<div className={styles.deleteWrapper}>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="medium"
|
||||
onClick={() => onDeleteGame(game.id)}
|
||||
aria-label={`Spiel löschen: ${playerNames}`}
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
@@ -126,4 +132,4 @@ export default function GameList({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ Single component `GameList` renders the scoreboard overview with filter tabs.
|
||||
## Behaviour
|
||||
|
||||
- Sorts games by `createdAt` (desc) and filters according to `filter`.
|
||||
- Derives player names/scores for both `StandardGame` and `EndlosGame`.
|
||||
- Derives player names/scores for `StandardGame`.
|
||||
- Uses `@lib/ui` primitives (`Button`, `Card`) for visuals.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -122,20 +122,109 @@
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
.new-game-form {
|
||||
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
|
||||
.wizardShell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: var(--space-xl) auto 0 auto;
|
||||
background: var(--color-surface);
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.new-game-form {
|
||||
width: min(94vw, 720px);
|
||||
max-height: 100%;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
flex: 1;
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
animation: wizardStepIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
/* Per-step wizard backgrounds — visually distinct per step */
|
||||
.new-game-form.step-player1 {
|
||||
background: linear-gradient(180deg, #3a5a3d 0%, #2e4631 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-player2 {
|
||||
background: linear-gradient(180deg, #354d6a 0%, #2a3b52 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-player3 {
|
||||
background: linear-gradient(180deg, #444 0%, #333 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-gameType {
|
||||
background: linear-gradient(180deg, #5a2323 0%, #4a1c1c 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-raceTo {
|
||||
background: linear-gradient(180deg, #3d245e 0%, #2d1b4e 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-breakRule {
|
||||
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form.step-breakOrder {
|
||||
background: linear-gradient(180deg, #5a3a10 0%, #3d260a 100%);
|
||||
border-color: rgba(255, 152, 0, 0.25);
|
||||
}
|
||||
|
||||
.new-game-form[class*="step-"] .form-footer {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-top-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.new-game-form[class*="step-"] .wizard-summary {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.new-game-form[class*="step-"] .screen-title {
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.new-game-form[class*="step-"] .player-input {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.new-game-form[class*="step-"] .name-input,
|
||||
.new-game-form[class*="step-"] .game-type-btn,
|
||||
.new-game-form[class*="step-"] .race-to-btn,
|
||||
.new-game-form[class*="step-"] .quick-pick-btn {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@keyframes wizardStepIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-header {
|
||||
@@ -143,6 +232,55 @@
|
||||
padding: clamp(0.4rem, 1.5vh, 1.25rem) var(--space-lg) clamp(0.2rem, 0.8vh, 0.75rem) var(--space-lg);
|
||||
}
|
||||
|
||||
.wizard-summary {
|
||||
margin-top: clamp(0.25rem, 0.8vh, 0.5rem);
|
||||
margin-bottom: clamp(0.25rem, 0.8vh, 0.5rem);
|
||||
padding: clamp(0.5rem, 1.2vh, 0.75rem) var(--space-md);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.wizard-summary-hint {
|
||||
margin: 0;
|
||||
font-size: clamp(0.85rem, 2.2vw, 0.95rem);
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted, #aaa);
|
||||
}
|
||||
|
||||
.wizard-summary-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(0.2rem, 0.6vh, 0.35rem);
|
||||
}
|
||||
|
||||
.wizard-summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.5rem;
|
||||
min-height: 1.35em;
|
||||
}
|
||||
|
||||
.wizard-summary-label {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
font-size: clamp(0.8rem, 2vw, 0.9rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #aaa);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.wizard-summary-value {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
font-size: clamp(0.95rem, 2.5vw, 1.05rem);
|
||||
line-height: 1.35;
|
||||
color: var(--color-text, #eee);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -160,7 +298,7 @@
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + var(--keyboard-offset)) var(--space-lg);
|
||||
padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + var(--safe-area-bottom) + var(--keyboard-offset)) var(--space-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.progress-indicator {
|
||||
@@ -171,19 +309,24 @@
|
||||
margin-bottom: clamp(0.5rem, 2vh, 1.5rem);
|
||||
}
|
||||
.progress-dot {
|
||||
width: clamp(10px, 2vh, 16px);
|
||||
height: clamp(10px, 2vh, 16px);
|
||||
width: clamp(12px, 2vh, 18px);
|
||||
height: clamp(12px, 2vh, 18px);
|
||||
border-radius: 50%;
|
||||
background: var(--color-border);
|
||||
opacity: 0.4;
|
||||
transition: all var(--transition-base);
|
||||
transition: background var(--transition-slow), opacity var(--transition-slow), transform var(--transition-slow), box-shadow var(--transition-slow);
|
||||
position: relative;
|
||||
}
|
||||
.progress-dot.completed {
|
||||
background: var(--color-primary);
|
||||
opacity: 0.55;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.progress-dot.active {
|
||||
background: var(--color-primary);
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 4px var(--color-primary-light);
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 0 0 4px var(--color-primary-light), 0 0 12px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
.quick-pick-container {
|
||||
flex: 1;
|
||||
@@ -194,24 +337,35 @@
|
||||
}
|
||||
|
||||
.quick-pick-btn {
|
||||
min-width: 60px;
|
||||
min-height: 32px;
|
||||
font-size: clamp(0.75rem, 2vw, 1rem);
|
||||
min-width: 80px;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
font-size: var(--font-size-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 2px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.7rem;
|
||||
transition: all var(--transition-base);
|
||||
font-weight: 500;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.quick-pick-btn:hover, .quick-pick-btn:focus {
|
||||
background: var(--color-secondary-hover);
|
||||
.quick-pick-btn:active:not(.selected) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.quick-pick-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
.choice-btn {
|
||||
min-width: 160px;
|
||||
min-height: var(--touch-target-large);
|
||||
font-size: var(--font-size-lg);
|
||||
flex: 1;
|
||||
}
|
||||
.arrow-nav {
|
||||
display: flex;
|
||||
@@ -228,34 +382,34 @@
|
||||
}
|
||||
.arrow-btn {
|
||||
font-size: 28px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: var(--touch-target-comfortable);
|
||||
height: var(--touch-target-comfortable);
|
||||
border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border: 2px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
transition: background-color var(--transition-base), border-color var(--transition-base);
|
||||
font-weight: bold;
|
||||
font-family: inherit;
|
||||
touch-action: manipulation;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arrow-btn:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.arrow-btn:hover, .arrow-btn:focus {
|
||||
background: var(--color-secondary-hover);
|
||||
.arrow-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
outline: none;
|
||||
}
|
||||
.arrow-btn:active {
|
||||
transform: translateY(0);
|
||||
.arrow-btn:active:not(:disabled) {
|
||||
background: var(--color-secondary-hover);
|
||||
}
|
||||
.clear-input-btn {
|
||||
position: absolute;
|
||||
@@ -277,10 +431,16 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.clear-input-btn:hover, .clear-input-btn:focus {
|
||||
.clear-input-btn:focus-visible {
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary);
|
||||
outline: none;
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.clear-input-btn:active {
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-hover);
|
||||
}
|
||||
.name-input-wrapper {
|
||||
position: relative;
|
||||
@@ -302,8 +462,12 @@
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.quick-pick-action-btn:hover,
|
||||
.quick-pick-action-btn:focus {
|
||||
.quick-pick-action-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quick-pick-action-btn:active {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
@@ -331,25 +495,31 @@
|
||||
background: var(--color-background);
|
||||
border: 2px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all var(--transition-base);
|
||||
min-height: var(--touch-target-comfortable);
|
||||
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
min-height: var(--touch-target-large);
|
||||
font-family: inherit;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.game-type-btn:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.game-type-btn:active:not(.selected) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.game-type-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.game-type-btn.selected {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.race-to-selection {
|
||||
display: grid;
|
||||
@@ -362,35 +532,43 @@
|
||||
background: var(--color-background);
|
||||
border: 2px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
padding: var(--space-lg) var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all var(--transition-base);
|
||||
min-height: 80px;
|
||||
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
min-height: var(--touch-target-large);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: inherit;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.race-to-btn:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-1px);
|
||||
.race-to-btn:active:not(.selected) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.race-to-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.race-to-btn.selected {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
/* Match selected styling for quick pick buttons used in BreakRuleStep */
|
||||
.quick-pick-btn.selected {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
|
||||
font-weight: 700;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.custom-race-to {
|
||||
display: flex;
|
||||
@@ -414,32 +592,19 @@
|
||||
.endlos-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.player1-input.player-input {
|
||||
border-color: var(--color-success);
|
||||
background: linear-gradient(135deg, var(--color-success) 0%, rgba(76, 175, 80, 0.1) 100%);
|
||||
}
|
||||
.player2-input.player-input {
|
||||
border-color: #1565c0;
|
||||
background: linear-gradient(135deg, #1565c0 0%, rgba(21, 101, 192, 0.1) 100%);
|
||||
}
|
||||
.player1-input.player-input,
|
||||
.player2-input.player-input,
|
||||
.player3-input.player-input {
|
||||
border-color: var(--color-secondary);
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, rgba(51, 51, 51, 0.1) 100%);
|
||||
}
|
||||
.player1-input.player-input input,
|
||||
.player2-input.player-input input,
|
||||
.player3-input.player-input input {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border: 1px solid #ccc;
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.screen-content {
|
||||
padding: var(--space-xl);
|
||||
.wizardShell {
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
.new-game-form {
|
||||
max-width: 700px;
|
||||
padding: var(--space-xxl) var(--space-xl) var(--space-xl) var(--space-xl);
|
||||
min-height: min(82vh, 680px);
|
||||
max-height: 100%;
|
||||
}
|
||||
.screen-title {
|
||||
font-size: var(--font-size-xxxl);
|
||||
@@ -461,13 +626,20 @@
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
}
|
||||
@media (min-height: 720px) {
|
||||
.new-game-form {
|
||||
min-height: min(88vh, 720px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.screen-content {
|
||||
padding: var(--space-md);
|
||||
.wizardShell {
|
||||
padding: var(--space-xs) 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.new-game-form {
|
||||
margin: var(--space-lg) auto 0 auto;
|
||||
padding: var(--space-sm);
|
||||
width: min(96vw, 720px);
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.game-type-selection {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -479,12 +651,23 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.arrow-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: var(--touch-target-min);
|
||||
height: var(--touch-target-min);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-game-form {
|
||||
animation: none;
|
||||
}
|
||||
.game-type-btn.selected,
|
||||
.race-to-btn.selected,
|
||||
.quick-pick-btn.selected {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.screen-title {
|
||||
margin-bottom: 0.35rem;
|
||||
@@ -492,6 +675,13 @@
|
||||
.progress-indicator {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.wizard-summary {
|
||||
padding: 0.35rem var(--space-sm);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.wizard-summary-value {
|
||||
font-size: clamp(0.9rem, 2.2vw, 1rem);
|
||||
}
|
||||
.player-input {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
@@ -501,6 +691,52 @@
|
||||
}
|
||||
.form-footer {
|
||||
padding-top: 0.3rem;
|
||||
padding-bottom: calc(0.35rem + var(--keyboard-offset));
|
||||
padding-bottom: calc(0.35rem + var(--safe-area-bottom) + var(--keyboard-offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-height: 500px) {
|
||||
.wizardShell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.new-game-form {
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
width: min(98vw, 720px);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
padding: var(--space-xs) var(--space-md) var(--space-xs);
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-size: clamp(1rem, 3.5vh, 1.25rem);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.wizard-summary {
|
||||
margin-top: 0.15rem;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0.25rem var(--space-sm);
|
||||
}
|
||||
|
||||
.choice-heading {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: clamp(0.95rem, 3vh, 1.1rem);
|
||||
}
|
||||
|
||||
.quick-picks-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.quick-pick-btn.choice-btn {
|
||||
min-height: var(--touch-target-min);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
padding: var(--space-xs) var(--space-md) calc(var(--space-xs) + var(--safe-area-bottom) + var(--keyboard-offset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: var(--space-lg);
|
||||
padding-top: calc(var(--space-lg) + var(--safe-area-top));
|
||||
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
background: #2c2c2c;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(var(--app-height) - var(--space-xl) * 2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modalHeader h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xxl);
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
min-width: var(--touch-target-min);
|
||||
min-height: var(--touch-target-min);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.closeButton:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.playerList {
|
||||
@@ -50,21 +64,33 @@
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-sm);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.playerItem {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
border: 2px solid var(--color-border);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
font-size: 1.2rem;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
touch-action: manipulation;
|
||||
transition: border-color var(--transition-base), background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.playerItem:hover {
|
||||
background: #555;
|
||||
}
|
||||
.playerItem:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.playerItem:active {
|
||||
background: var(--color-secondary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,23 @@ export function ProgressIndicator({
|
||||
const activeIndex = Math.min(Math.max(currentStep, 1), totalSteps) - 1;
|
||||
|
||||
return (
|
||||
<div className={styles['progress-indicator']} style={style}>
|
||||
<div
|
||||
className={styles['progress-indicator']}
|
||||
style={style}
|
||||
role="progressbar"
|
||||
aria-valuenow={currentStep}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={totalSteps}
|
||||
aria-label={`Schritt ${currentStep} von ${totalSteps}`}
|
||||
>
|
||||
{Array.from({ length: totalSteps }, (_, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
const className = isActive
|
||||
? `${styles['progress-dot']} ${styles['active']}`
|
||||
: styles['progress-dot'];
|
||||
const isCompleted = index < activeIndex;
|
||||
const className = [
|
||||
styles['progress-dot'],
|
||||
isCompleted ? styles.completed : '',
|
||||
isActive ? styles.active : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return <span key={index} className={className} />;
|
||||
})}
|
||||
|
||||
@@ -1,66 +1,81 @@
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
padding: var(--space-lg);
|
||||
padding-top: calc(var(--space-lg) + var(--safe-area-top));
|
||||
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: min(420px, 100%);
|
||||
background: var(--color-surface);
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
font-size: max(16px, var(--font-size-base));
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
justify-content: stretch;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.actionButton:hover,
|
||||
.actionButton:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
.modalActionButton {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.modalOverlay {
|
||||
padding: 16px;
|
||||
padding-top: calc(16px + var(--safe-area-top));
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modalActionButton {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from './NameEntryModal.module.css';
|
||||
import newGameStyles from '../NewGame.module.css';
|
||||
import { Button } from '@lib/ui/Button';
|
||||
|
||||
interface NameEntryModalProps {
|
||||
open: boolean;
|
||||
@@ -37,6 +38,11 @@ export function NameEntryModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
const validationError = onConfirm(name);
|
||||
if (validationError) setError(validationError);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose} role="dialog" aria-modal="true" aria-labelledby={`${inputId}-title`}>
|
||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -52,7 +58,7 @@ export function NameEntryModal({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
spellCheck={false}
|
||||
spellcheck={false}
|
||||
enterKeyHint={enterKeyHint}
|
||||
onInput={(e: Event) => {
|
||||
setName((e.target as HTMLInputElement).value);
|
||||
@@ -61,27 +67,19 @@ export function NameEntryModal({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const validationError = onConfirm(name);
|
||||
if (validationError) setError(validationError);
|
||||
handleConfirm();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button type="button" className={styles.actionButton} onClick={onClose}>
|
||||
<Button variant="secondary" size="medium" onClick={onClose} className={styles.modalActionButton}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionButton} ${styles.confirmButton}`}
|
||||
onClick={() => {
|
||||
const validationError = onConfirm(name);
|
||||
if (validationError) setError(validationError);
|
||||
}}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" size="medium" onClick={handleConfirm} className={styles.modalActionButton}>
|
||||
Übernehmen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
} from '@lib/domain/constants';
|
||||
import { PlayerSelectModal } from '../steps/PlayerSelectModal';
|
||||
import { NameEntryModal } from './NameEntryModal';
|
||||
import type { NewGameData, NewGameStep } from '@lib/domain/types';
|
||||
import { WizardNav } from './WizardNav';
|
||||
import { WizardStepForm } from './WizardStepForm';
|
||||
import { WizardSummary } from './WizardSummary';
|
||||
|
||||
interface PlayerNameStepProps {
|
||||
stepNumber: number;
|
||||
@@ -19,6 +21,7 @@ interface PlayerNameStepProps {
|
||||
placeholder: string;
|
||||
inputId: string;
|
||||
playerInputClassName: string;
|
||||
stepClassName?: string;
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
@@ -31,6 +34,8 @@ interface PlayerNameStepProps {
|
||||
showMorePlayersModal?: boolean;
|
||||
prefillNameEntry?: boolean;
|
||||
onCreateTemporaryName?: (name: string) => void;
|
||||
data?: NewGameData;
|
||||
currentStep?: NewGameStep;
|
||||
}
|
||||
|
||||
export function PlayerNameStep({
|
||||
@@ -40,6 +45,7 @@ export function PlayerNameStep({
|
||||
placeholder,
|
||||
inputId,
|
||||
playerInputClassName,
|
||||
stepClassName,
|
||||
playerNameHistory,
|
||||
onNext,
|
||||
onCancel,
|
||||
@@ -52,6 +58,8 @@ export function PlayerNameStep({
|
||||
showMorePlayersModal = false,
|
||||
prefillNameEntry = true,
|
||||
onCreateTemporaryName,
|
||||
data,
|
||||
currentStep,
|
||||
}: PlayerNameStepProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -122,7 +130,13 @@ export function PlayerNameStep({
|
||||
ariaLabel={ARIA_LABELS.PLAYER_INPUT(label)}
|
||||
title={title}
|
||||
currentStep={stepNumber}
|
||||
stepClassName={stepClassName}
|
||||
onSubmit={handleSubmit}
|
||||
summary={
|
||||
data && currentStep ? (
|
||||
<WizardSummary data={data} currentStep={currentStep} />
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
<WizardNav
|
||||
onBack={onCancel}
|
||||
|
||||
@@ -9,8 +9,10 @@ interface WizardStepFormProps {
|
||||
title: string;
|
||||
currentStep: number;
|
||||
onSubmit?: (e: Event) => void;
|
||||
summary?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
footer: ComponentChildren;
|
||||
stepClassName?: string;
|
||||
}
|
||||
|
||||
export function WizardStepForm({
|
||||
@@ -18,19 +20,29 @@ export function WizardStepForm({
|
||||
title,
|
||||
currentStep,
|
||||
onSubmit,
|
||||
summary,
|
||||
children,
|
||||
footer,
|
||||
stepClassName,
|
||||
}: WizardStepFormProps) {
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={onSubmit} aria-label={ariaLabel} autoComplete="off">
|
||||
<div className={styles['form-header']}>
|
||||
<div className={styles['screen-title']}>{title}</div>
|
||||
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
|
||||
</div>
|
||||
<div className={styles.wizardShell}>
|
||||
<form
|
||||
className={`${styles['new-game-form']} ${stepClassName ?? ''}`}
|
||||
onSubmit={onSubmit}
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className={styles['form-header']}>
|
||||
<div className={styles['screen-title']}>{title}</div>
|
||||
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
|
||||
{summary}
|
||||
</div>
|
||||
|
||||
<div className={styles['form-content']}>{children}</div>
|
||||
<div className={styles['form-content']}>{children}</div>
|
||||
|
||||
<div className={styles['form-footer']}>{footer}</div>
|
||||
</form>
|
||||
<div className={styles['form-footer']}>{footer}</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { h } from 'preact';
|
||||
import type { NewGameData, NewGameStep } from '@lib/domain/types';
|
||||
import { GAME_TYPES, RACE_TO_INFINITY } from '@lib/domain/constants';
|
||||
import styles from '../NewGame.module.css';
|
||||
|
||||
const STEP_ORDER: NewGameStep[] = [
|
||||
'player1',
|
||||
'player2',
|
||||
'player3',
|
||||
'gameType',
|
||||
'raceTo',
|
||||
'breakRule',
|
||||
'breakOrder',
|
||||
];
|
||||
|
||||
function stepIndex(step: NewGameStep): number {
|
||||
if (!step) return -1;
|
||||
return STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
function isPast(current: NewGameStep, target: NewGameStep): boolean {
|
||||
return stepIndex(current) > stepIndex(target);
|
||||
}
|
||||
|
||||
function getPlayerNames(data: NewGameData): string[] {
|
||||
return [data.player1, data.player2, data.player3].filter(Boolean);
|
||||
}
|
||||
|
||||
function getPriorPlayerSummary(data: NewGameData, currentStep: NewGameStep): string | null {
|
||||
if (currentStep === 'player1') return null;
|
||||
|
||||
let names: string[] = [];
|
||||
if (currentStep === 'player2') {
|
||||
names = data.player1 ? [data.player1] : [];
|
||||
} else if (currentStep === 'player3') {
|
||||
names = [data.player1, data.player2].filter(Boolean);
|
||||
} else if (isPast(currentStep, 'player3')) {
|
||||
names = getPlayerNames(data);
|
||||
}
|
||||
|
||||
return names.length > 0 ? names.join(' vs ') : null;
|
||||
}
|
||||
|
||||
function getGameTypeLabel(gameType: NewGameData['gameType']): string | null {
|
||||
if (!gameType) return null;
|
||||
const match = GAME_TYPES.find((item) => item.value === gameType);
|
||||
return match?.label ?? gameType;
|
||||
}
|
||||
|
||||
function formatRaceTo(raceTo: string): string | null {
|
||||
if (!raceTo) return null;
|
||||
if (raceTo === RACE_TO_INFINITY || raceTo === 'Infinity') return 'Endlos';
|
||||
return `Race to ${raceTo}`;
|
||||
}
|
||||
|
||||
function formatBreakRule(rule: NewGameData['breakRule']): string | null {
|
||||
if (rule === 'winnerbreak') return 'Winnerbreak';
|
||||
if (rule === 'wechselbreak') return 'Wechselbreak';
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBreakOrder(data: NewGameData, breakFirst?: number): string | null {
|
||||
const idx =
|
||||
breakFirst ??
|
||||
(typeof data.breakFirst === 'number' ? data.breakFirst : 0);
|
||||
if (!idx) return null;
|
||||
|
||||
const name = getPlayerNames(data)[idx - 1];
|
||||
return name ? `${name} breakt zuerst` : null;
|
||||
}
|
||||
|
||||
interface SummaryItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface WizardSummaryProps {
|
||||
data: NewGameData;
|
||||
currentStep: NewGameStep;
|
||||
breakFirst?: number;
|
||||
}
|
||||
|
||||
export function WizardSummary({ data, currentStep, breakFirst }: WizardSummaryProps) {
|
||||
const items: SummaryItem[] = [];
|
||||
|
||||
const players = getPriorPlayerSummary(data, currentStep);
|
||||
if (players) {
|
||||
items.push({ label: 'Spieler', value: players });
|
||||
}
|
||||
|
||||
if (isPast(currentStep, 'gameType')) {
|
||||
const gameType = getGameTypeLabel(data.gameType);
|
||||
if (gameType) items.push({ label: 'Spielart', value: gameType });
|
||||
}
|
||||
|
||||
if (isPast(currentStep, 'raceTo')) {
|
||||
const raceTo = formatRaceTo(data.raceTo);
|
||||
if (raceTo) items.push({ label: 'Race to', value: raceTo });
|
||||
}
|
||||
|
||||
if (isPast(currentStep, 'breakRule')) {
|
||||
const breakRule = formatBreakRule(data.breakRule);
|
||||
if (breakRule) items.push({ label: 'Break-Regel', value: breakRule });
|
||||
}
|
||||
|
||||
if (currentStep === 'breakOrder') {
|
||||
const breakOrder = formatBreakOrder(data, breakFirst);
|
||||
if (breakOrder) items.push({ label: 'Break-Reihenfolge', value: breakOrder });
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
if (currentStep === 'player1') {
|
||||
return (
|
||||
<div className={styles['wizard-summary']} aria-live="polite">
|
||||
<p className={styles['wizard-summary-hint']}>Spieler und Einstellungen werden hier angezeigt.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['wizard-summary']} aria-label="Bisherige Auswahl" aria-live="polite">
|
||||
<dl className={styles['wizard-summary-list']}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className={styles['wizard-summary-row']}>
|
||||
<dt className={styles['wizard-summary-label']}>{item.label}</dt>
|
||||
<dd className={styles['wizard-summary-value']}>{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import type { BreakRule } from '@lib/domain/types';
|
||||
import type { NewGameData } from '@lib/domain/types';
|
||||
import { WizardNav } from '../components/WizardNav';
|
||||
import { WizardStepForm } from '../components/WizardStepForm';
|
||||
import { WizardSummary } from '../components/WizardSummary';
|
||||
|
||||
interface BreakOrderStepProps {
|
||||
players: string[];
|
||||
@@ -12,9 +14,10 @@ interface BreakOrderStepProps {
|
||||
onCancel: () => void;
|
||||
initialFirst?: number;
|
||||
initialSecond?: number;
|
||||
data: NewGameData;
|
||||
}
|
||||
|
||||
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
|
||||
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond, data }: BreakOrderStepProps) => {
|
||||
const playerCount = players.filter(Boolean).length;
|
||||
const [first, setFirst] = useState<number>(initialFirst);
|
||||
const [second, setSecond] = useState<number | undefined>(initialSecond);
|
||||
@@ -60,7 +63,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
|
||||
ariaLabel="Break-Reihenfolge wählen"
|
||||
title="Wer hat den ersten Anstoss?"
|
||||
currentStep={7}
|
||||
stepClassName={styles['step-breakOrder']}
|
||||
onSubmit={handleSubmit}
|
||||
summary={<WizardSummary data={data} currentStep="breakOrder" breakFirst={first} />}
|
||||
footer={
|
||||
<WizardNav
|
||||
onBack={onCancel}
|
||||
@@ -76,10 +81,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
|
||||
<button
|
||||
key={`first-${idx}`}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
|
||||
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
|
||||
onClick={() => handleFirst(idx + 1)}
|
||||
aria-label={`Zuerst: ${name}`}
|
||||
style={{ minWidth: 160, minHeight: 64 }}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
@@ -94,10 +98,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
|
||||
<button
|
||||
key={`second-${idx}`}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
|
||||
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
|
||||
onClick={() => handleSecond(idx + 1)}
|
||||
aria-label={`Zweites Break: ${name}`}
|
||||
style={{ minWidth: 160, minHeight: 64 }}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
|
||||
@@ -2,16 +2,19 @@ import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import type { BreakRule } from '@lib/domain/types';
|
||||
import type { NewGameData } from '@lib/domain/types';
|
||||
import { WizardNav } from '../components/WizardNav';
|
||||
import { WizardStepForm } from '../components/WizardStepForm';
|
||||
import { WizardSummary } from '../components/WizardSummary';
|
||||
|
||||
interface BreakRuleStepProps {
|
||||
onNext: (rule: BreakRule) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: BreakRule;
|
||||
data: NewGameData;
|
||||
}
|
||||
|
||||
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
|
||||
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak', data }: BreakRuleStepProps) => {
|
||||
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
|
||||
|
||||
const handleSelect = (nextRule: BreakRule) => {
|
||||
@@ -29,7 +32,9 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
|
||||
ariaLabel="Break-Regel wählen"
|
||||
title="Break-Regel wählen"
|
||||
currentStep={6}
|
||||
stepClassName={styles['step-breakRule']}
|
||||
onSubmit={handleSubmit}
|
||||
summary={<WizardSummary data={data} currentStep="breakRule" />}
|
||||
footer={<WizardNav onBack={onCancel} />}
|
||||
>
|
||||
<div className={styles['quick-picks-row']}>
|
||||
@@ -40,10 +45,9 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
|
||||
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
|
||||
onClick={() => handleSelect(opt.key as BreakRule)}
|
||||
aria-label={`Break-Regel wählen: ${opt.label}`}
|
||||
style={{ minWidth: 160, minHeight: 64 }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -2,16 +2,19 @@ import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import { GAME_TYPES } from '@lib/domain/constants';
|
||||
import type { NewGameData } from '@lib/domain/types';
|
||||
import { WizardNav } from '../components/WizardNav';
|
||||
import { WizardStepForm } from '../components/WizardStepForm';
|
||||
import { WizardSummary } from '../components/WizardSummary';
|
||||
|
||||
interface GameTypeStepProps {
|
||||
onNext: (type: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
data: NewGameData;
|
||||
}
|
||||
|
||||
export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
|
||||
export const GameTypeStep = ({ onNext, onCancel, initialValue = '', data }: GameTypeStepProps) => {
|
||||
const [gameType, setGameType] = useState(initialValue);
|
||||
|
||||
const handleSelect = (selectedType: string) => {
|
||||
@@ -31,7 +34,9 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
|
||||
ariaLabel="Spielart auswählen"
|
||||
title="Spielart auswählen"
|
||||
currentStep={4}
|
||||
stepClassName={styles['step-gameType']}
|
||||
onSubmit={handleSubmit}
|
||||
summary={<WizardSummary data={data} currentStep="gameType" />}
|
||||
footer={<WizardNav onBack={onCancel} nextDisabled={!gameType} />}
|
||||
>
|
||||
<div className={styles['game-type-selection']}>
|
||||
|
||||
@@ -8,15 +8,34 @@ interface PlayerSelectModalProps {
|
||||
}
|
||||
|
||||
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
|
||||
<div className={modalStyles.modalOverlay} onClick={onClose}>
|
||||
<div
|
||||
className={modalStyles.modalOverlay}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="player-select-title"
|
||||
>
|
||||
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div className={modalStyles.modalHeader}>
|
||||
<h3>Alle Spieler</h3>
|
||||
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
|
||||
<h3 id="player-select-title">Alle Spieler</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={modalStyles.playerList}>
|
||||
{players.map(player => (
|
||||
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
|
||||
<button
|
||||
key={player}
|
||||
type="button"
|
||||
className={modalStyles.playerItem}
|
||||
onClick={() => onSelect(player)}
|
||||
aria-label={`Spieler wählen: ${player}`}
|
||||
>
|
||||
{player}
|
||||
</button>
|
||||
))}
|
||||
@@ -24,5 +43,3 @@ export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectMo
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -6,17 +6,20 @@ import {
|
||||
RACE_TO_DEFAULT,
|
||||
RACE_TO_INFINITY,
|
||||
} from '@lib/domain/constants';
|
||||
import type { NewGameData } from '@lib/domain/types';
|
||||
import { WizardNav } from '../components/WizardNav';
|
||||
import { WizardStepForm } from '../components/WizardStepForm';
|
||||
import { WizardSummary } from '../components/WizardSummary';
|
||||
|
||||
interface RaceToStepProps {
|
||||
onNext: (raceTo: string | number) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string | number;
|
||||
gameType?: string;
|
||||
data: NewGameData;
|
||||
}
|
||||
|
||||
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
|
||||
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType, data }: RaceToStepProps) => {
|
||||
const quickPicks = [...RACE_TO_QUICK_PICKS];
|
||||
const defaultValue = RACE_TO_DEFAULT;
|
||||
const [raceTo, setRaceTo] = useState<string | number>(
|
||||
@@ -57,7 +60,9 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
|
||||
ariaLabel="Race To auswählen"
|
||||
title="Race To auswählen"
|
||||
currentStep={5}
|
||||
stepClassName={styles['step-raceTo']}
|
||||
onSubmit={handleSubmit}
|
||||
summary={<WizardSummary data={data} currentStep="raceTo" />}
|
||||
footer={<WizardNav onBack={onCancel} nextDisabled={String(raceTo).trim() === ''} />}
|
||||
>
|
||||
<div className={styles['endlos-container']}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* Use CSS custom properties from global design system */
|
||||
.button {
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: var(--transition-base);
|
||||
font-family: inherit;
|
||||
transition: background-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast), opacity var(--transition-base);
|
||||
touch-action: manipulation;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -12,25 +12,9 @@
|
||||
gap: var(--space-sm);
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover::before {
|
||||
transform: translateX(100%);
|
||||
-webkit-user-select: none;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
@@ -38,53 +22,47 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
.button:active:not(.disabled) {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: linear-gradient(180deg, #ffb74d 0%, var(--color-primary) 100%);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px rgba(255, 152, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.primary:hover:not(.disabled) {
|
||||
.primary:not(.disabled):active {
|
||||
background: var(--color-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-secondary);
|
||||
background: linear-gradient(180deg, #3a3a3a 0%, var(--color-secondary) 100%);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.secondary:hover:not(.disabled) {
|
||||
.secondary:not(.disabled):active {
|
||||
background: var(--color-secondary-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: linear-gradient(180deg, #ef5350 0%, var(--color-danger) 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danger:hover:not(.disabled) {
|
||||
background: #ef5350;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
.danger:not(.disabled):active {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
/* Sizes with improved touch targets for tablets */
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: var(--touch-target-min);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.medium {
|
||||
@@ -96,39 +74,41 @@
|
||||
|
||||
.large {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
font-size: var(--font-size-lg);
|
||||
min-height: 56px;
|
||||
font-size: var(--font-size-xl);
|
||||
min-height: var(--touch-target-large);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Tablet-specific size adjustments */
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
/* Tablet */
|
||||
@media (min-width: 768px) {
|
||||
.small {
|
||||
min-height: var(--touch-target-comfortable);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
|
||||
.medium {
|
||||
min-height: var(--touch-target-comfortable);
|
||||
min-height: var(--touch-target-large);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
|
||||
.large {
|
||||
min-height: 64px;
|
||||
min-height: 72px;
|
||||
padding: var(--space-xl) var(--space-xxl);
|
||||
font-size: var(--font-size-xl);
|
||||
font-size: var(--font-size-xxl);
|
||||
}
|
||||
}
|
||||
|
||||
/* States */
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.disabled:hover::before {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button:active:not(.disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export function Button({
|
||||
disabled = false,
|
||||
children,
|
||||
onClick,
|
||||
className = '',
|
||||
'aria-label': ariaLabel,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
@@ -16,6 +17,7 @@ export function Button({
|
||||
styles[variant],
|
||||
styles[size],
|
||||
disabled && styles.disabled,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
|
||||
+22
-15
@@ -1,21 +1,22 @@
|
||||
.card {
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast);
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.default {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.elevated {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.outlined {
|
||||
background: transparent;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
@@ -23,11 +24,12 @@
|
||||
border: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
.clickable:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.clickable:focus-visible {
|
||||
@@ -35,19 +37,24 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Padding variants */
|
||||
.padding-none {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.padding-small {
|
||||
padding: 8px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.padding-medium {
|
||||
padding: 16px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.padding-large {
|
||||
padding: 24px;
|
||||
}
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clickable:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-md);
|
||||
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -26,26 +27,34 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
animation: screenFadeIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
/* Tablet optimizations */
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
@keyframes screenFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.screen {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
max-width: 900px;
|
||||
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* Large tablet and small desktop */
|
||||
@media (min-width: 1025px) {
|
||||
.content {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.content {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
padding-bottom: calc(var(--space-sm) + var(--safe-area-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+97
-31
@@ -1,66 +1,132 @@
|
||||
/* Consolidated modal styles for all modals */
|
||||
/* Consolidated modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
padding: var(--space-lg);
|
||||
padding-top: calc(var(--space-lg) + var(--safe-area-top));
|
||||
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
|
||||
padding-left: calc(var(--space-lg) + var(--safe-area-left));
|
||||
padding-right: calc(var(--space-lg) + var(--safe-area-right));
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
background-color: var(--color-surface);
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
width: min(500px, 100%);
|
||||
max-height: calc(var(--app-height) - var(--space-xl) * 2);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
font-size: 24px;
|
||||
font-size: var(--font-size-xxl);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: var(--touch-target-min);
|
||||
min-height: var(--touch-target-min);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.close-button:hover {
|
||||
color: white;
|
||||
|
||||
.close-button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.modal-footer {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: 8px 16px;
|
||||
min-height: var(--touch-target-large);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
font-size: var(--font-size-lg);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
touch-action: manipulation;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.modal-button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.modal-button.cancel {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-button.confirm {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
background-color: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-button.confirm:active {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.modal-button.cancel:active {
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
.modal-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -19,14 +19,14 @@ const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
|
||||
<div className={styles['modal-content']}>
|
||||
<div className={styles['modal-header']}>
|
||||
<span className={styles['modal-title']} id="modal-title">{title}</span>
|
||||
<button className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
|
||||
<button type="button" className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div className={styles['modal-body']}>
|
||||
<div className={styles['modal-message']}>{message}</div>
|
||||
</div>
|
||||
<div className={styles['modal-footer']}>
|
||||
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
|
||||
<button className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
|
||||
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
|
||||
<button type="button" className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,13 @@ const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => {
|
||||
<div className={styles['modal-content']}>
|
||||
<div className={styles['modal-header']}>
|
||||
<span className={styles['modal-title']} id="validation-modal-title">Fehler</span>
|
||||
<button className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
|
||||
<button type="button" className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div className={styles['modal-body']}>
|
||||
<div className={styles['modal-message']}>{message}</div>
|
||||
</div>
|
||||
<div className={styles['modal-footer']}>
|
||||
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
|
||||
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user