Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5deb38ebb7 | |||
| 586e5f26bd | |||
| 9bf4c20f11 | |||
| f8d895ab21 |
+20
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
|
||||
scorebscgpde:
|
||||
image: nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dist:/usr/share/nginx/html:ro
|
||||
container_name: scorebscgpde
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.scorebscgpde.rule=Host(`score.bsc-gp.de`)"
|
||||
- "traefik.http.routers.scorebscgpde.entrypoints=websecure"
|
||||
- "traefik.http.routers.scorebscgpde.tls.certresolver=myresolver"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
# Externes Traefik-Netzwerk für Reverse Proxy
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
@@ -33,6 +33,35 @@ export default function App() {
|
||||
const validationModal = useValidationModal();
|
||||
const completionModal = useCompletionModal();
|
||||
|
||||
// Keep viewport height stable on Android tablets when virtual keyboard opens.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const updateViewportVars = () => {
|
||||
const viewport = window.visualViewport;
|
||||
const appHeight = viewport ? viewport.height : window.innerHeight;
|
||||
const keyboardOffset = viewport
|
||||
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
|
||||
: 0;
|
||||
|
||||
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
|
||||
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
|
||||
};
|
||||
|
||||
updateViewportVars();
|
||||
window.addEventListener('resize', updateViewportVars);
|
||||
window.addEventListener('orientationchange', updateViewportVars);
|
||||
window.visualViewport?.addEventListener('resize', updateViewportVars);
|
||||
window.visualViewport?.addEventListener('scroll', updateViewportVars);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateViewportVars);
|
||||
window.removeEventListener('orientationchange', updateViewportVars);
|
||||
window.visualViewport?.removeEventListener('resize', updateViewportVars);
|
||||
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Game lifecycle handlers
|
||||
const handleCreateGame = useCallback(async (gameData: any) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: var(--app-height);
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
.screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: var(--app-height);
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: var(--app-height);
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
@@ -145,8 +145,10 @@
|
||||
|
||||
.form-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 var(--space-lg);
|
||||
padding-bottom: var(--space-md);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -154,7 +156,12 @@
|
||||
|
||||
.form-footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-lg);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-md) var(--space-lg) calc(var(--space-lg) + var(--keyboard-offset)) var(--space-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.progress-indicator {
|
||||
display: flex;
|
||||
@@ -210,7 +217,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-xxl);
|
||||
margin-top: var(--space-lg);
|
||||
width: 100%;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@@ -26,18 +26,29 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
|
||||
|
||||
const handleFirst = (idx: number) => {
|
||||
setFirst(idx);
|
||||
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
|
||||
onNext(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecond = (idx: number) => {
|
||||
setSecond(idx);
|
||||
onNext(first, idx);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (rule === 'wechselbreak' && playerCount === 3) {
|
||||
if (first > 0 && (second ?? 0) > 0) {
|
||||
onNext(first, second);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (first > 0) {
|
||||
onNext(first);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
|
||||
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen" onSubmit={handleSubmit}>
|
||||
<div className={styles['form-header']}>
|
||||
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
|
||||
<ProgressIndicator currentStep={7} style={{ marginBottom: 24 }} />
|
||||
@@ -87,18 +98,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
onClick={() => {
|
||||
if (rule === 'wechselbreak' && playerCount === 3) {
|
||||
if (first > 0 && (second ?? 0) > 0) {
|
||||
handleSecond(second as number);
|
||||
}
|
||||
} else if (first > 0) {
|
||||
onNext(first);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,17 @@ interface BreakRuleStepProps {
|
||||
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
|
||||
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
|
||||
|
||||
const handleSelect = (nextRule: BreakRule) => {
|
||||
setRule(nextRule);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onNext(rule);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
|
||||
<form className={styles['new-game-form']} aria-label="Break-Regel wählen" onSubmit={handleSubmit}>
|
||||
<div className={styles['form-header']}>
|
||||
<div className={styles['screen-title']}>Break-Regel wählen</div>
|
||||
<ProgressIndicator currentStep={6} style={{ marginBottom: 24 }} />
|
||||
@@ -30,10 +39,7 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
|
||||
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);
|
||||
}}
|
||||
onClick={() => handleSelect(opt.key as BreakRule)}
|
||||
aria-label={`Break-Regel wählen: ${opt.label}`}
|
||||
style={{ minWidth: 160, minHeight: 64, fontSize: '1.2rem', padding: '16px 32px' }}
|
||||
>
|
||||
@@ -48,7 +54,7 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
|
||||
<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' }}>
|
||||
←
|
||||
</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' }}>
|
||||
<button type="submit" className={styles['arrow-btn']} 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' }}>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
|
||||
|
||||
const handleSelect = (selectedType: string) => {
|
||||
setGameType(selectedType);
|
||||
onNext(selectedType);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
|
||||
@@ -111,8 +111,15 @@ export const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
spellCheck={false}
|
||||
enterKeyHint="next"
|
||||
aria-label="Name Spieler 1"
|
||||
aria-describedby="player1-help"
|
||||
onFocus={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
||||
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
||||
|
||||
@@ -70,7 +70,14 @@ export const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
||||
setPlayer2(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
spellCheck={false}
|
||||
enterKeyHint="next"
|
||||
aria-label="Name Spieler 2"
|
||||
onFocus={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}}
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,14 @@ export const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
||||
setPlayer3(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
spellCheck={false}
|
||||
enterKeyHint="done"
|
||||
aria-label="Name Spieler 3"
|
||||
onFocus={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}}
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
|
||||
@@ -33,9 +33,6 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
|
||||
const handleQuickPick = (value: number | typeof RACE_TO_INFINITY) => {
|
||||
const selected = value === RACE_TO_INFINITY ? RACE_TO_INFINITY : value;
|
||||
setRaceTo(selected);
|
||||
const raceToValue =
|
||||
selected === RACE_TO_INFINITY ? Infinity : parseInt(String(selected), 10) || 0;
|
||||
onNext(raceToValue);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: Event) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.layout {
|
||||
height: 100vh;
|
||||
height: var(--app-height);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -8,7 +8,7 @@ import App from "../components/App";
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>BSC Score - Pool Scoring App</title>
|
||||
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
|
||||
|
||||
|
||||
+10
-7
@@ -3,17 +3,13 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Design system tokens */
|
||||
:root {
|
||||
--app-height: 100dvh;
|
||||
--keyboard-offset: 0px;
|
||||
/* Colors */
|
||||
--color-primary: #ff9800;
|
||||
--color-primary-hover: #ffa726;
|
||||
@@ -87,7 +83,7 @@
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -98,6 +94,13 @@ body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: var(--app-height);
|
||||
}
|
||||
|
||||
button, .btn, .button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Improved input styling for better tablet experience */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const LANDSCAPE_VIEWPORTS = [
|
||||
{ name: 'small-landscape', width: 1024, height: 600 },
|
||||
{ name: 'tablet-landscape', width: 1280, height: 800 },
|
||||
{ name: 'large-landscape', width: 1366, height: 768 },
|
||||
];
|
||||
|
||||
for (const viewport of LANDSCAPE_VIEWPORTS) {
|
||||
test(`viewport matrix: ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
|
||||
// Step 1: ensure keyboard-sized viewport still keeps controls usable.
|
||||
await page.getByLabel('Name Spieler 1').fill('Alpha');
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: Math.max(360, viewport.height - Math.floor(viewport.height * 0.35)),
|
||||
});
|
||||
const nextButton = page.getByRole('button', { name: 'Weiter' });
|
||||
await nextButton.scrollIntoViewIfNeeded();
|
||||
await expect(nextButton).toBeVisible();
|
||||
await nextButton.click();
|
||||
|
||||
// Step 2: keep reduced height and verify progression still works.
|
||||
await page.getByLabel('Name Spieler 2').fill('Beta');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
|
||||
// Restore full landscape viewport for remaining wizard steps.
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: '5' }).click();
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Alpha' }).click();
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Aktueller Punktestand für Alpha/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Aktueller Punktestand für Beta/i })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user