23 Commits

Author SHA1 Message Date
Frank Schwenk
6da7a5f4e2 Merge feature/winnerbreak-wechselbreak-#28 into main (Refs #28) 2025-10-30 14:07:38 +01:00
Frank Schwenk
d22bbdb3dc fix(#28): preselect Winnerbreak on break rule step
- Default selection set to 'winnerbreak' so right arrow is available immediately
- Fallback to stored lastBreakRule or Winnerbreak

Refs #28
2025-10-30 14:04:11 +01:00
Frank Schwenk
3e2264ad9d fix(#28): disable BreakRule right arrow until selection made
- BreakRuleStep now starts with no selection when not prefilled
- Right arrow disabled and dimmed until a rule is chosen (like game type)
- Keeps auto-advance on clicking a rule

Refs #28
2025-10-30 13:57:05 +01:00
Frank Schwenk
77173718c1 fix(#28): add right arrow to BreakRule step and correct back nav
- BreakRule step now shows a right arrow that advances using current selection
- Back navigation from BreakRule returns to Race To instead of exiting to list

Refs #28
2025-10-30 13:47:13 +01:00
Frank Schwenk
ed90b47348 fix(#28): use right arrow on Race To step and fix type issue
- Replace checkmark with right arrow on Race To submit button
- Cast raceTo to string for parseInt in quick-pick highlight

Refs #28
2025-10-30 13:44:20 +01:00
Frank Schwenk
b152575e61 fix(#28): show progress indicator on all steps with 7-step count
- Added indicator to BreakRule and BreakOrder steps
- Updated all steps to display 7 dots and correct active state

Refs #28
2025-10-30 12:32:13 +01:00
Frank Schwenk
f88db204f7 feat(#28): persist last-used break settings in localStorage
- Preload BreakRule and BreakOrder steps from localStorage defaults
- Save selections (rule, first, second) when chosen to speed future setup

Refs #28
2025-10-30 12:27:01 +01:00
Frank Schwenk
bc1bc4b446 fix(#28): finalize game creation on first-break selection
- NewGameScreen now calls onCreateGame after BreakOrder step instead of stopping
- Ensures flow creates game and navigates to detail after selecting breaker

Refs #28
2025-10-30 12:15:38 +01:00
Frank Schwenk
75fc0668bb fix(#28): remove duplicate rendering of BreakRule/BreakOrder steps
- NewGameScreen accidentally rendered break steps twice; cleaned up conditionals

Refs #28
2025-10-30 12:13:59 +01:00
Frank Schwenk
d3083c8c68 chore(#28): use descriptive titles for each creation step
- Player 1/2/3: “Name Spieler X”
- Game type: “Spielart auswählen”
- Race to: “Race To auswählen”
- Break rule: “Break-Regel wählen”
- First break: “Wer bricht zuerst?”

Refs #28
2025-10-30 12:07:17 +01:00
Frank Schwenk
2e0855e781 chore(#28): restore new game step titles without step counters
- Reintroduce 'Neues Spiel' titles on all creation steps
- Remove only the 'Schritt X/Y' portions as requested

Refs #28
2025-10-30 12:03:20 +01:00
Frank Schwenk
81c7c9579b feat(#28): reorder new game steps and remove step labels
- Order: player1 → player2 → player3 → game type → race to → break type → first break
- Removed all step title labels from forms; kept progress dots only
- Adjusted navigation and back behavior accordingly

Refs #28
2025-10-30 12:01:39 +01:00
Frank Schwenk
dc1d9a23a9 feat(#28): add continue arrow and auto-advance in BreakOrder step
- Auto-advance when selecting first breaker for Winnerbreak and 2-player Wechselbreak
- Keep auto-advance after choosing second for 3-player Wechselbreak
- Add explicit right arrow to continue manually based on current selection

Refs #28
2025-10-30 11:51:36 +01:00
Frank Schwenk
a11d41f934 feat(#28): place breaker indicator inline to the right of player name
- Indicator now renders after the name, inline, sized to 1em height
- Tooltip/aria-label remains “Break”

Refs #28
2025-10-30 11:47:46 +01:00
Frank Schwenk
1bd9919b6b feat(#28): add break rule & order to games and UI
- Types: add BreakRule, break metadata to StandardGame, extend NewGameData/steps
- NewGame: add BreakRule and BreakOrder steps with auto-advance
- NewGameScreen: wire new steps into flow
- GameService: set up defaults, persist break order, compute next breaker on +1
- GameDetail: show breaker indicator; treat -1 as undo equivalent

Backfill defaults for existing games via service logic.

Refs #28
2025-10-30 11:34:26 +01:00
Frank Schwenk
147906af59 refactor: remove toast notifications from game detail
- Delete Toast component and styles
- Remove all toast usage from GameDetail

Simplifies UX and eliminates transient notifications.

Refs #26
2025-10-30 11:05:27 +01:00
Frank Schwenk
634d012097 fix(ui): prevent winner modal icons from clipping
Increase top padding, set overflow visible, and position decorative icons inside container so emojis are fully visible.

Refs #26
2025-10-30 10:50:24 +01:00
Frank Schwenk
301d5b131c feat(ux): auto-advance on game type and race selection
- Step 4: selecting a game type immediately advances to step 5
- Step 5: selecting Endlos or a quick-pick number immediately finalizes race-to

Improves flow by removing an extra click. No changes to validation.

Refs #26
2025-10-30 10:41:38 +01:00
Frank Schwenk
4c8b0cfed7 refactor(css): remove global :focus outline styles in index.css\n\nFocus rings were visually distracting for this app context; removed the global *:focus rule. Component-level focus where needed can be handled locally.\n\nRefs #26 2025-10-30 10:36:47 +01:00
Frank Schwenk
31ed600c97 fix(build): remove stray CSS brace causing esbuild minify warning
Fix unexpected '}' in src/styles/index.css after .btn--secondary:hover.\nEliminates Vite/esbuild css-syntax-error during production build.\n\nRefs #26
2025-10-30 10:34:30 +01:00
Frank Schwenk
d016868ff2 feat(ux): autofocus name inputs on new game steps\n\nFocus Player 1/2/3 inputs on mount and place caret at end for faster entry.\n\nNo behavior changes beyond focus; adheres to accessibility with native focus.\n\nRefs #26 2025-10-30 10:15:30 +01:00
Frank Schwenk
89300bc021 fix(ui): align delete icon inline in game list
Apply game-item grid layout to game list rows and switch to two-column layout (1fr auto) so the delete action has a fixed-width slot on the right.

Keeps existing delete button and accessibility attributes; prevents layout stretch.
No behavioral changes beyond layout; click targets unchanged.

Refs #26
2025-10-30 10:04:11 +01:00
Frank Schwenk
de502741e7 Merge github/main into main (resolve .gitea to upstream) 2025-10-30 09:44:14 +01:00
12 changed files with 305 additions and 244 deletions

2
.gitea
View File

@@ -1 +1 @@
https://gitea.schwenk.online/froxxxy/bscscore/issues/26
https://gitea.schwenk.online/froxxxy/bscscore/issues/26

View File

@@ -26,7 +26,7 @@
.winner-announcement {
text-align: center;
margin: 20px 0 0 0;
padding: 24px 16px;
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;
@@ -35,13 +35,13 @@
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
animation: celebrationPulse 2s ease-in-out infinite;
position: relative;
overflow: hidden;
overflow: visible; /* avoid clipping decorative icons */
}
.winner-announcement::before {
content: '🎉';
position: absolute;
top: -10px;
top: 6px;
left: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite;
@@ -50,7 +50,7 @@
.winner-announcement::after {
content: '🏆';
position: absolute;
top: -10px;
top: 6px;
right: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite 0.5s;

View File

@@ -1,7 +1,6 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import Toast from './Toast';
import type { Game, EndlosGame } from '../types/game';
interface GameDetailProps {
@@ -18,23 +17,11 @@ interface GameDetailProps {
* Game detail view for a single game.
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'error' | 'info' }>({
show: false,
message: '',
type: 'info'
});
if (!game) return null;
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ show: true, message, type });
};
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
const playerName = [game.player1, game.player2, game.player3][playerIndex - 1];
const action = change > 0 ? 'Punkt hinzugefügt' : 'Punkt abgezogen';
showToast(`${action} für ${playerName}`, 'success');
// Silent update; toast notifications removed
};
@@ -58,7 +45,17 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>{name}</span>
<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']}
@@ -85,7 +82,7 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, -1)}
onClick={() => (onUndo ? onUndo() : undefined)}
aria-label={`Punkt abziehen für ${name}`}
title={`Punkt abziehen für ${name}`}
>-</button>
@@ -108,7 +105,6 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
className="btn btn--secondary"
onClick={() => {
onUndo();
showToast('Letzte Aktion rückgängig gemacht', 'info');
}}
aria-label="Rückgängig"
>
@@ -117,12 +113,6 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div>
<Toast
show={toast.show}
message={toast.message}
type={toast.type}
onClose={() => setToast({ show: false, message: '', type: 'info' })}
/>
</div>
);
};

View File

@@ -76,7 +76,7 @@
/* Game item with better symmetry and spacing */
.game-item {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);

View File

@@ -82,7 +82,9 @@ export default function GameList({
<Card
key={game.id}
variant="elevated"
className={game.status === 'completed' ? styles['completed'] : styles['active']}
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
>
<div
className={styles['game-info']}

View File

@@ -14,6 +14,7 @@ import {
FORM_CONFIG,
ERROR_STYLES
} from '../utils/constants';
import type { BreakRule } from '../types/game';
interface PlayerSelectModalProps {
players: string[];
@@ -56,6 +57,17 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player1) {
setFilteredNames(playerNameHistory);
@@ -115,13 +127,15 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt {WIZARD_STEPS.PLAYER1}/{UI_CONSTANTS.TOTAL_WIZARD_STEPS}</div>
<div className={styles['screen-title']}>Name Spieler 1</div>
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
@@ -296,6 +310,17 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player2) {
setFilteredNames(playerNameHistory);
@@ -331,13 +356,15 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 2/5</div>
<div className={styles['screen-title']}>Name Spieler 2</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input'] + ' ' + styles['player2-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
@@ -430,6 +457,17 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player3) {
setFilteredNames(playerNameHistory);
@@ -464,13 +502,15 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }:
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 3/5</div>
<div className={styles['screen-title']}>Name Spieler 3 (optional)</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input'] + ' ' + styles['player3-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
@@ -579,6 +619,8 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps
const handleSelect = (selectedType: string) => {
setGameType(selectedType);
// Auto-advance to next step on selection
onNext(selectedType);
};
const handleSubmit = (e: Event) => {
@@ -590,13 +632,15 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 4/5</div>
<div className={styles['screen-title']}>Spielart auswählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['game-type-selection']}>
{gameTypes.map(type => (
@@ -673,7 +717,11 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToSte
const handleQuickPick = (value: number) => {
// For endlos (endless) games, use Infinity to prevent automatic completion
setRaceTo(value === 0 ? 'Infinity' : value);
const selected = value === 0 ? 'Infinity' : value;
setRaceTo(selected);
// Auto-advance to the next step (finalize) when a quick pick is chosen
const raceToValue = selected === 'Infinity' ? Infinity : (parseInt(String(selected), 10) || 0);
onNext(raceToValue);
};
const handleInputChange = (e: Event) => {
@@ -690,13 +738,15 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToSte
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 5/5</div>
<div className={styles['screen-title']}>Race To auswählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['endlos-container']}>
<button
@@ -712,7 +762,7 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToSte
<button
key={value}
type="button"
className={`${styles['race-to-btn']} ${parseInt(raceTo, 10) === value ? styles.selected : ''}`}
className={`${styles['race-to-btn']} ${parseInt(String(raceTo), 10) === value ? styles.selected : ''}`}
onClick={() => handleQuickPick(value)}
>
{value}
@@ -743,15 +793,143 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToSte
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Fertigstellen"
aria-label="Weiter"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode checkmark */}
&#10003;
{/* Unicode right arrow */}
&#8594;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule | '';
}
const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue as BreakRule);
return (
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
<div className={styles['screen-title']}>Break-Regel wählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
{[
{ key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' },
].map(opt => (
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => { setRule(opt.key as BreakRule); onNext(opt.key as BreakRule); }}
aria-label={`Break-Regel wählen: ${opt.label}`}
>
{opt.label}
</button>
))}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button type="button" className={styles['arrow-btn']} aria-label="Weiter" onClick={() => onNext(rule)} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8594;
</button>
</div>
</form>
);
};
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
}
const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number>(initialSecond ?? (playerCount >= 2 ? 2 : 1));
const handleFirst = (idx: number) => {
setFirst(idx);
// Auto-advance cases: winnerbreak (any players) OR wechselbreak with 2 players
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
onNext(first, idx);
};
return (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
</div>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer hat den ersten Anstoss?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`first-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleFirst(idx + 1)} aria-label={`Zuerst: ${name}`}>{name}</button>
))}
</div>
{rule === 'wechselbreak' && playerCount === 3 && (
<>
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer bricht als Zweites?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`second-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleSecond(idx + 1)} aria-label={`Zweites Break: ${name}`}>{name}</button>
))}
</div>
</>
)}
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Weiter"
onClick={() => {
if (rule === 'wechselbreak' && playerCount === 3) {
handleSecond(second);
} else {
onNext(first);
}
}}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8594;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep };

View File

@@ -1,134 +0,0 @@
.toast {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
min-width: 300px;
max-width: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
transform: translateX(100%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
.toast.hide {
transform: translateX(100%);
opacity: 0;
}
.toastContent {
display: flex;
align-items: center;
padding: 16px 20px;
gap: 12px;
}
.toastIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.toastMessage {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.toastClose {
background: none;
border: none;
font-size: 18px;
color: #666;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
flex-shrink: 0;
}
.toastClose:hover {
background: #f0f0f0;
color: #333;
}
/* Toast types */
.toast.success {
border-left: 4px solid #4caf50;
}
.toast.success .toastIcon {
background: #4caf50;
color: #fff;
}
.toast.error {
border-left: 4px solid #f44336;
}
.toast.error .toastIcon {
background: #f44336;
color: #fff;
}
.toast.info {
border-left: 4px solid #2196f3;
}
.toast.info .toastIcon {
background: #2196f3;
color: #fff;
}
/* Animation keyframes */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.toast.show {
animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.hide {
animation: slideOutRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -1,59 +0,0 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from './Toast.module.css';
type ToastType = 'success' | 'error' | 'info';
interface ToastProps {
show: boolean;
message: string;
type?: ToastType;
onClose: () => void;
duration?: number;
}
/**
* Toast notification component for user feedback
*/
const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }: ToastProps) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (show) {
setIsVisible(true);
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, duration);
return () => clearTimeout(timer);
}
}, [show, duration, onClose]);
if (!show && !isVisible) return null;
return (
<div className={`${styles.toast} ${styles[type]} ${isVisible ? styles.show : styles.hide}`}>
<div className={styles.toastContent}>
<div className={styles.toastIcon}>
{type === 'success' && '✓'}
{type === 'error' && '✕'}
{type === 'info' && ''}
</div>
<span className={styles.toastMessage}>{message}</span>
<button
className={styles.toastClose}
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
aria-label="Schließen"
>
×
</button>
</div>
</div>
);
};
export default Toast;

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from '../NewGame';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep } from '../NewGame';
import type { NewGameStep, NewGameData } from '../../types/game';
interface NewGameScreenProps {
@@ -47,9 +47,27 @@ export default function NewGameScreen({
onStepChange('raceTo');
};
const handleBreakRuleNext = (rule: 'winnerbreak' | 'wechselbreak') => {
onDataChange({ breakRule: rule });
try { localStorage.setItem('lastBreakRule', rule); } catch {}
onStepChange('breakOrder');
};
const handleBreakOrderNext = (first: number, second?: number) => {
const finalData = { ...data, breakFirst: first, breakSecond: second ?? '' } as any;
onDataChange({ breakFirst: first, breakSecond: second ?? '' });
try {
localStorage.setItem('lastBreakFirst', String(first));
if (second) localStorage.setItem('lastBreakSecond', String(second));
} catch {}
onCreateGame(finalData as any);
};
const handleRaceToNext = (raceTo: string) => {
const finalData = { ...data, raceTo };
onCreateGame(finalData);
// After race to, go to break rule selection
onDataChange({ raceTo });
onStepChange('breakRule');
};
const handleStepBack = () => {
@@ -66,6 +84,12 @@ export default function NewGameScreen({
case 'raceTo':
onStepChange('gameType');
break;
case 'breakRule':
onStepChange('raceTo');
break;
case 'breakOrder':
onStepChange('breakRule');
break;
default:
onCancel();
}
@@ -107,6 +131,8 @@ export default function NewGameScreen({
initialValue={data.gameType}
/>
)}
{step === 'raceTo' && (
<RaceToStep
@@ -116,6 +142,25 @@ export default function NewGameScreen({
gameType={data.gameType}
/>
)}
{step === 'breakRule' && (
<BreakRuleStep
onNext={handleBreakRuleNext}
onCancel={handleStepBack}
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
/>
)}
{step === 'breakOrder' && (
<BreakOrderStep
players={[data.player1, data.player2, data.player3]}
rule={(data.breakRule as any) || 'winnerbreak'}
onNext={handleBreakOrderNext}
onCancel={handleStepBack}
initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1}
initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined}
/>
)}
</Screen>
);
}

View File

@@ -1,4 +1,4 @@
import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game';
import type { Game, GameType, StandardGame, EndlosGame, NewGameData, BreakRule } from '../types/game';
import { IndexedDBService } from './indexedDBService';
const LOCAL_STORAGE_KEY = 'bscscore_games';
@@ -163,6 +163,25 @@ export class GameService {
player2: gameData.player2,
score1: 0,
score2: 0,
breakRule: (gameData.breakRule as BreakRule) || 'winnerbreak',
breakOrder: (() => {
// Determine break order from inputs, fallback defaults
const players: number[] = [1, 2];
if (gameData.player3?.trim()) players.push(3);
const first = (typeof gameData.breakFirst === 'number' ? gameData.breakFirst : 1) as number;
const second = (typeof gameData.breakSecond === 'number' ? gameData.breakSecond : (players.includes(2) && first !== 2 ? 2 : (players.includes(3) ? 3 : 2))) as number;
const order = [first];
if (players.length === 2) {
order.push(first === 1 ? 2 : 1);
} else {
// 3 players: add chosen second, then the remaining third
order.push(second);
const third = [1,2,3].find(p => p !== first && p !== second)!;
order.push(third);
}
return order;
})(),
currentBreakerIdx: 0,
};
if (gameData.player3) {
@@ -185,6 +204,23 @@ export class GameService {
updated.score3 = Math.max(0, updated.score3 + change);
}
// Breaker logic
if (change > 0) {
if ((updated.breakRule || 'winnerbreak') === 'winnerbreak') {
// Winner keeps break: set breaker to this player
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const idx = order.findIndex(p => p === player);
updated.currentBreakerIdx = idx >= 0 ? idx : 0;
} else {
// Wechselbreak: rotate to next
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const curr = typeof updated.currentBreakerIdx === 'number' ? updated.currentBreakerIdx : 0;
updated.currentBreakerIdx = (curr + 1) % order.length;
}
}
updated.updatedAt = new Date().toISOString();
return updated;
}

View File

@@ -161,7 +161,6 @@ input:focus, select:focus {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.btn-primary:hover {
background: var(--color-primary-hover);
@@ -267,11 +266,6 @@ input:focus, select:focus {
border: 0;
}
/* Focus styles for better accessibility */
*:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Skip link for keyboard navigation */
.skip-link {

View File

@@ -33,6 +33,8 @@ export interface BaseGame {
lastModified?: number;
}
export type BreakRule = 'winnerbreak' | 'wechselbreak';
export interface StandardGame extends BaseGame {
player1: string;
player2: string;
@@ -40,6 +42,10 @@ export interface StandardGame extends BaseGame {
score1: number;
score2: number;
score3?: number;
// Break management
breakRule?: BreakRule; // default winnerbreak for backfill
breakOrder?: number[]; // 1-based player indices, e.g. [1,2] or [1,2,3]
currentBreakerIdx?: number; // index into breakOrder
}
export interface EndlosGame extends BaseGame {
@@ -58,8 +64,11 @@ export interface NewGameData {
player3: string;
gameType: GameType | '';
raceTo: string;
breakRule?: BreakRule | '';
breakFirst?: number | '';
breakSecond?: number | '';
}
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | null;
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
export type GameFilter = 'all' | 'active' | 'completed';