Add Schlangajass UI, Weis-Hilfe und Deploy für jassen.local/pauker.at.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Stefan 2026-07-25 18:51:57 +02:00
parent 2cd921d609
commit 381c39a93c
11 changed files with 633 additions and 128 deletions

View File

@ -2,18 +2,90 @@
Mobile-first Web-App zum Punktezählen beim Kreuzjassen (Schlangajass).
## Start
## URLs
| Umgebung | Adresse |
|----------|---------|
| Lokal (Apache / Build) | http://jassen.local/ |
| Lokal (Vite Dev) | http://jassen.local:5173/ |
| Server | https://jassen.pauker.at |
## Lokal einrichten
1. Einmalig als Administrator: `deploy\setup-local.ps1`
(trägt `jassen.local` in die hosts-Datei ein und startet WAMP-Apache)
2. Build: `npm run build`
3. Öffnen: http://jassen.local/
Apache-VHost liegt in `httpd-vhosts.conf` (DocumentRoot → `dist/`). Vorlage: `deploy/apache-jassen.local.conf`.
## Entwicklung
```bash
npm install
npm run dev
```
Standard-Zielpunktzahl: **1000**. Partie wird im Browser gespeichert (`localStorage`).
Dann http://jassen.local:5173/ (hosts-Eintrag nötig).
## Server (jassen.pauker.at → `/web/jassen`)
### Einmalig auf dem Debian-Server
```bash
# 1) Repo klonen + bauen
sudo mkdir -p /web
sudo git clone https://git.pauker.at/Stefan/jassen.git /web/jassen
sudo chown -R "$USER:$USER" /web/jassen
cd /web/jassen
# falls privat: SSH-URL nutzen, z.B. git@git.pauker.at:Stefan/jassen.git
npm ci
npm run build
```
Oder das Skript: `bash deploy/server-setup.sh`
### Webserver
**Nginx** (Beispiel):
```bash
sudo cp /web/jassen/deploy/nginx-jassen.pauker.at.conf /etc/nginx/sites-available/jassen.pauker.at
sudo ln -sf /etc/nginx/sites-available/jassen.pauker.at /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
**Apache**: `deploy/apache-jassen.pauker.at.conf` — DocumentRoot = `/web/jassen/dist`
### DNS
`jassen.pauker.at` A/AAAA → Server-IP. Optional HTTPS mit Certbot:
```bash
sudo certbot --nginx -d jassen.pauker.at
```
### Updates später
```bash
cd /web/jassen
git pull
npm ci
npm run build
```
### Alternative: nur `dist/` von Windows pushen
```powershell
npm run build
scp -r dist/* USER@SERVER:/web/jassen/dist/
```
(VHost muss dann trotzdem auf `/web/jassen/dist` zeigen.)
## Nutzung
1. Teamnamen und Ziel festlegen
2. Pro Runde: Team mit weniger Punkten wählen und Summe eingeben (Rest = 157 x)
3. Optional Weis und Matsch (257)
4. Stand mit Schlangen-Strichen (100 / 50 / 20)
- Standard-Ziel: **1000** Punkte
- Pro Runde: Team, das gezählt hat, und Summe (Rest = 157 x)
- Weis und Matsch möglich
- Stand als Schlangajass (100 / 50 / 20)

View File

@ -0,0 +1,15 @@
#
# Lokal: http://jassen.local/
# DocumentRoot zeigt auf den Vite-Build (dist).
# Nach Änderungen: npm run build
#
<VirtualHost *:80>
ServerName jassen.local
DocumentRoot "c:/users/stefan/nextcloud/web/jassen/dist"
<Directory "c:/users/stefan/nextcloud/web/jassen/dist/">
Options +Indexes +Includes +FollowSymLinks +MultiViews
AllowOverride All
Require local
FallbackResource /index.html
</Directory>
</VirtualHost>

View File

@ -0,0 +1,11 @@
# Apache auf Debian für jassen.pauker.at
<VirtualHost *:80>
ServerName jassen.pauker.at
DocumentRoot /web/jassen/dist
<Directory /web/jassen/dist>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
FallbackResource /index.html
</Directory>
</VirtualHost>

View File

@ -0,0 +1,21 @@
# Server: https://jassen.pauker.at
# DocumentRoot: /web/jassen/dist (Vite-Build)
server {
listen 80;
listen [::]:80;
server_name jassen.pauker.at;
root /web/jassen/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
}

23
deploy/server-setup.sh Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Auf dem Debian-Server ausführen (einmalig oder nach Updates).
# Voraussetzung: Node.js 20+, Git, Zugriff auf git.pauker.at
set -euo pipefail
APP=/web/jassen
REPO=https://git.pauker.at/Stefan/jassen.git
if [ ! -d "$APP/.git" ]; then
sudo mkdir -p /web
sudo git clone "$REPO" "$APP"
sudo chown -R "$USER:$USER" "$APP"
else
cd "$APP"
git pull --ff-only
fi
cd "$APP"
npm ci
npm run build
echo "Build fertig: $APP/dist"
echo "Danach VHost aktivieren (siehe deploy/) und DNS auf jassen.pauker.at setzen."

34
deploy/setup-local.ps1 Normal file
View File

@ -0,0 +1,34 @@
# Als Administrator ausführen:
# Rechtsklick PowerShell → „Als Administrator ausführen“, dann:
# Set-ExecutionPolicy -Scope Process Bypass; .\deploy\setup-local.ps1
$ErrorActionPreference = 'Stop'
$hostsPath = 'C:\Windows\System32\drivers\etc\hosts'
$hostsLine4 = '127.0.0.1 jassen.local'
$hostsLine6 = '::1 jassen.local'
$content = Get-Content $hostsPath -Raw
if ($content -notmatch 'jassen\.local') {
Add-Content -Path $hostsPath -Value "`r`n$hostsLine4`r`n$hostsLine6`r`n"
Write-Host "hosts: jassen.local eingetragen"
} else {
Write-Host "hosts: jassen.local war schon da"
}
$apache = Get-Service wampapache64 -ErrorAction SilentlyContinue
if ($apache) {
if ($apache.Status -ne 'Running') {
Start-Service wampapache64
Write-Host "Apache gestartet"
} else {
Restart-Service wampapache64
Write-Host "Apache neu gestartet"
}
} else {
Write-Host "Hinweis: Dienst wampapache64 nicht gefunden — WAMP manuell starten."
}
Write-Host ""
Write-Host "Lokal: http://jassen.local/"
Write-Host "Dev: npm run dev → http://jassen.local:5173/"
Write-Host "Server: https://jassen.pauker.at (siehe deploy/)"

6
public/.htaccess Normal file
View File

@ -0,0 +1,6 @@
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

View File

@ -237,6 +237,123 @@ input {
gap: 0.75rem;
}
.weis-block .hint {
margin: 0 0 0.65rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--ink-soft);
line-height: 1.35;
}
.weis-team {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.55rem 0.45rem 0.6rem;
background: color-mix(in srgb, var(--felt) 6%, var(--paper));
border-radius: 8px;
}
.weis-team.off {
opacity: 0.45;
}
.weis-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-soft);
}
.weis-head strong {
font-family: var(--font-display);
font-size: 1.35rem;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.weis-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.weis-chips button {
border: 1px solid color-mix(in srgb, var(--ink) 18%, transparent);
background: var(--paper);
color: var(--ink);
font-size: 0.75rem;
font-weight: 700;
padding: 0.35rem 0.45rem;
border-radius: 6px;
cursor: pointer;
}
.weis-chips button:disabled {
cursor: not-allowed;
}
.weis-chips button:not(:disabled):active {
background: var(--felt);
color: var(--chalk);
border-color: var(--felt);
}
.weis-edit {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem;
align-items: center;
}
.weis-edit input {
width: 100%;
border: none;
border-bottom: 2px solid color-mix(in srgb, var(--ink) 18%, transparent);
background: transparent;
padding: 0.35rem 0.1rem;
color: var(--ink);
font-size: 1rem;
}
.weis-edit input:focus {
outline: none;
border-bottom-color: var(--felt);
}
.weis-clear {
border: none;
background: color-mix(in srgb, var(--ink) 8%, transparent);
color: var(--ink-soft);
width: 2rem;
height: 2rem;
border-radius: 6px;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.weis-clear:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.preview small {
font-size: 0.7rem;
font-weight: 600;
color: var(--felt);
}
.weis-tag {
font-style: normal;
font-size: 0.75rem;
font-weight: 600;
color: var(--felt);
}
.preview {
display: grid;
grid-template-columns: 1fr 1fr;
@ -314,60 +431,54 @@ input {
.sheet {
flex: 1;
background: var(--paper);
background:
radial-gradient(ellipse at 20% 10%, rgba(255, 255, 255, 0.35), transparent 45%),
linear-gradient(180deg, #f4ead6 0%, var(--paper) 40%, #e8dcc4 100%);
color: var(--ink);
border-radius: 3px 14px 10px 4px;
padding: 1.15rem 0.9rem 1rem;
padding: 1rem 0.75rem 1rem;
box-shadow:
0 1px 0 var(--paper-edge),
0 22px 48px var(--shadow);
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.85rem;
min-height: 0;
animation: sheet-in 0.45s ease-out both;
}
.sheet-cols {
.snake-board {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.snake-heads {
display: grid;
grid-template-columns: 1fr 1px 1fr;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
align-items: start;
padding: 0 0.5rem;
}
.sheet-gutter {
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--ink) 25%, transparent) 8%,
color-mix(in srgb, var(--ink) 25%, transparent) 92%,
transparent
);
width: 1px;
align-self: stretch;
min-height: 8rem;
}
.col {
.snake-head {
text-align: center;
padding: 0.25rem 0.35rem;
transition: opacity 0.2s;
}
.col:not(.lead) {
opacity: 0.72;
.snake-head:not(.lead) {
opacity: 0.62;
}
.col h2 {
.snake-head h2 {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 0.2rem;
margin: 0 0 0.15rem;
}
.total {
font-family: var(--font-display);
font-size: clamp(2.4rem, 12vw, 3rem);
font-size: clamp(2.35rem, 11vw, 2.9rem);
font-weight: 650;
font-variant-numeric: tabular-nums;
margin: 0;
@ -387,40 +498,47 @@ input {
}
}
.snake {
margin-top: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 3.5rem;
/* Schrieba nach jassa.at: alles in einem SVG → Striche treffen die Linien */
.schrieba {
display: block;
width: 100%;
height: auto;
margin: 0.1rem 0 0;
overflow: visible;
}
.snake-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.35rem;
font-size: 0.8rem;
.schrieba-mid {
stroke: color-mix(in srgb, var(--ink) 28%, transparent);
stroke-width: 1.3;
}
.snake-label {
color: var(--ink-soft);
font-weight: 600;
width: 1.6rem;
text-align: right;
.schrieba-snake {
fill: none;
stroke: #2a5f9e;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.ticks {
letter-spacing: -0.08em;
font-weight: 500;
color: var(--felt);
.schrieba-label {
fill: color-mix(in srgb, var(--ink) 45%, transparent);
font-family: var(--font-ui);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
}
.rest {
margin: 0.15rem 0 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-soft);
.svg-stroke {
stroke: #14110e;
stroke-width: 2.6;
stroke-linecap: round;
}
.schrieba-rest-svg {
fill: var(--ink);
font-family: var(--font-display);
font-size: 16px;
font-weight: 650;
}
.history {
@ -431,7 +549,7 @@ input {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 40vh;
max-height: 32vh;
overflow: auto;
font-variant-numeric: tabular-nums;
}

View File

@ -9,6 +9,7 @@ import {
ROUND_TOTAL,
snakeMarks,
totals,
WEIS_OPTIONS,
winner,
} from './game'
import { clearGame, loadGame, saveGame } from './storage'
@ -182,11 +183,14 @@ function PlayScreen({
)}
<section className="sheet" aria-label="Punktestand">
<div className="sheet-cols">
<TeamColumn name={game.teamA} total={score.a} marks={marksA} leading={score.a >= score.b} />
<div className="sheet-gutter" aria-hidden="true" />
<TeamColumn name={game.teamB} total={score.b} marks={marksB} leading={score.b >= score.a} />
</div>
<SnakeBoard
teamA={game.teamA}
teamB={game.teamB}
scoreA={score.a}
scoreB={score.b}
marksA={marksA}
marksB={marksB}
/>
<ol className="history">
{game.rounds.length === 0 && (
@ -201,10 +205,16 @@ function PlayScreen({
<span>
{r.pointsA}
{round.matsch && round.team === 'a' ? ' M' : ''}
{round.weisA > 0 && !(round.matsch && round.team !== 'a') ? (
<em className="weis-tag"> W{round.weisA}</em>
) : null}
</span>
<span>
{r.pointsB}
{round.matsch && round.team === 'b' ? ' M' : ''}
{round.weisB > 0 && !(round.matsch && round.team !== 'b') ? (
<em className="weis-tag"> W{round.weisB}</em>
) : null}
</span>
</li>
)
@ -227,38 +237,160 @@ function PlayScreen({
)
}
function TeamColumn({
name,
total,
marks,
leading,
function SnakeBoard({
teamA,
teamB,
scoreA,
scoreB,
marksA,
marksB,
}: {
name: string
total: number
marks: ReturnType<typeof snakeMarks>
leading: boolean
teamA: string
teamB: string
scoreA: number
scoreB: number
marksA: ReturnType<typeof snakeMarks>
marksB: ReturnType<typeof snakeMarks>
}) {
// Einheitlicher Wendungsradius: Linienabstand = 2R
const R = 28
const Y100 = 46
const Y50 = Y100 + 2 * R
const Y20 = Y50 + 2 * R
const left = 72
const right = 248
return (
<div className={`col ${leading ? 'lead' : ''}`}>
<h2>{name}</h2>
<p className="total">{total}</p>
<div className="snake" aria-hidden="true">
<SnakeRow label="100" count={marks.hundreds} />
<SnakeRow label="50" count={marks.fifties} />
<SnakeRow label="20" count={marks.twenties} />
{marks.rest > 0 && <p className="rest">+{marks.rest}</p>}
<div className="snake-board">
<div className="snake-heads">
<div className={`snake-head ${scoreA >= scoreB ? 'lead' : ''}`}>
<h2>{teamA}</h2>
<p className="total" key={scoreA}>
{scoreA}
</p>
</div>
<div className={`snake-head ${scoreB >= scoreA ? 'lead' : ''}`}>
<h2>{teamB}</h2>
<p className="total" key={scoreB}>
{scoreB}
</p>
</div>
</div>
<svg
className="schrieba"
viewBox="0 0 320 200"
role="img"
aria-label={`Stand ${teamA} ${scoreA}, ${teamB} ${scoreB}`}
>
<line x1="160" y1="10" x2="160" y2="180" className="schrieba-mid" />
{/* Halbkreise Radius R: rechts unten CW, links unten CCW → S-Form */}
<path
className="schrieba-snake"
d={`
M ${left} ${Y100}
L ${right} ${Y100}
A ${R} ${R} 0 0 1 ${right} ${Y50}
L ${left} ${Y50}
A ${R} ${R} 0 0 0 ${left} ${Y20}
L ${right} ${Y20}
`}
/>
<text x="8" y={Y100} dominantBaseline="middle" className="schrieba-label">
100
</text>
<text x="14" y={Y50} dominantBaseline="middle" className="schrieba-label">
50
</text>
<text x="14" y={Y20} dominantBaseline="middle" className="schrieba-label">
20
</text>
<SvgMarkRow count={marksA.hundreds} y={Y100} side="left" kind="bar" />
<SvgMarkRow count={marksB.hundreds} y={Y100} side="right" kind="bar" />
<SvgMarkRow count={marksA.fifties} y={Y50} side="left" kind="fifty" />
<SvgMarkRow count={marksB.fifties} y={Y50} side="right" kind="fifty" />
<SvgMarkRow count={marksA.twenties} y={Y20} side="left" kind="bar" />
<SvgMarkRow count={marksB.twenties} y={Y20} side="right" kind="bar" />
{marksA.rest > 0 && (
<text x="152" y="190" textAnchor="end" className="schrieba-rest-svg">
+{marksA.rest}
</text>
)}
{marksB.rest > 0 && (
<text x="168" y="190" textAnchor="start" className="schrieba-rest-svg">
+{marksB.rest}
</text>
)}
</svg>
</div>
)
}
function SnakeRow({ label, count }: { label: string; count: number }) {
if (count === 0) return null
function SvgMarkRow({
count,
y,
side,
kind,
}: {
count: number
y: number
side: 'left' | 'right'
kind: 'bar' | 'fifty'
}) {
if (count <= 0) return null
const gap = kind === 'fifty' ? 14 : 9
const first = side === 'left' ? 151 : 169
return (
<div className="snake-row">
<span className="snake-label">{label}</span>
<span className="ticks">{'丨'.repeat(count)}</span>
</div>
<g>
{Array.from({ length: count }, (_, i) => {
const x = side === 'left' ? first - i * gap : first + i * gap
if (x < 58 || x > 262) return null
const seed = i * 17 + (side === 'left' ? 3 : 11)
const rot = ((seed % 5) - 2) * 1.15
if (kind === 'fifty') {
return (
<g key={i} transform={`translate(${x} ${y}) rotate(${rot})`}>
<line x1="-5" y1="-5" x2="5" y2="5" className="svg-stroke" />
<line x1="5" y1="-5" x2="-5" y2="5" className="svg-stroke" />
</g>
)
}
if ((i + 1) % 5 === 0) {
const span = gap * 3.3
return (
<line
key={i}
className="svg-stroke"
x1={side === 'left' ? x + 2 : x - 2}
y1={y - 9}
x2={side === 'left' ? x + span : x - span}
y2={y + 9}
/>
)
}
return (
<line
key={i}
className="svg-stroke"
transform={`rotate(${rot} ${x} ${y})`}
x1={x}
y1={y - 9}
x2={x}
y2={y + 9}
/>
)
})}
</g>
)
}
@ -274,20 +406,20 @@ function RoundScreen({
const [team, setTeam] = useState<TeamId>('a')
const [cardPoints, setCardPoints] = useState('')
const [matsch, setMatsch] = useState(false)
const [weisA, setWeisA] = useState('')
const [weisB, setWeisB] = useState('')
const [weisA, setWeisA] = useState(0)
const [weisB, setWeisB] = useState(0)
const parsedCards = cardPoints === '' ? 0 : Number(cardPoints)
const parsedWeisA = weisA === '' ? 0 : Number(weisA)
const parsedWeisB = weisB === '' ? 0 : Number(weisB)
const effectiveWeisA = matsch && team !== 'a' ? 0 : weisA
const effectiveWeisB = matsch && team !== 'b' ? 0 : weisB
const preview = computeRound({
id: 'preview',
team,
cardPoints: parsedCards,
matsch,
weisA: parsedWeisA,
weisB: parsedWeisB,
weisA: effectiveWeisA,
weisB: effectiveWeisB,
})
const cardsValid =
@ -298,7 +430,13 @@ function RoundScreen({
Number.isInteger(parsedCards))
const teamName = team === 'a' ? game.teamA : game.teamB
const loserWeisDisabled = (side: TeamId) => matsch && team !== side
const weisBlocked = (side: TeamId) => matsch && team !== side
const addWeis = (side: TeamId, points: number) => {
if (weisBlocked(side)) return
if (side === 'a') setWeisA((v) => v + points)
else setWeisB((v) => v + points)
}
return (
<div className="app">
@ -319,23 +457,15 @@ function RoundScreen({
team,
cardPoints: matsch ? 0 : parsedCards,
matsch,
weisA: loserWeisDisabled('a')
? 0
: Number.isFinite(parsedWeisA)
? Math.max(0, Math.round(parsedWeisA))
: 0,
weisB: loserWeisDisabled('b')
? 0
: Number.isFinite(parsedWeisB)
? Math.max(0, Math.round(parsedWeisB))
: 0,
weisA: effectiveWeisA,
weisB: effectiveWeisB,
})
}}
>
<h1>Neue Runde</h1>
<fieldset>
<legend>{matsch ? 'Wer hat den Matsch?' : 'Wer hat weniger Punkte?'}</legend>
<legend>{matsch ? 'Wer hat den Matsch?' : 'Wer hat gezählt?'}</legend>
<div className="seg">
<button
type="button"
@ -365,7 +495,7 @@ function RoundScreen({
{!matsch && (
<label>
Punkte von {teamName}
Gezählte Punkte ({teamName})
<input
type="number"
inputMode="numeric"
@ -379,41 +509,41 @@ function RoundScreen({
</label>
)}
<fieldset className="weis-block">
<legend>Weisen</legend>
<p className="hint">
Tippe Weise dazu bei Matsch zählt nur der Weis des Siegers (Stöck inkl.).
</p>
<div className="weis-grid">
<label>
Weis {game.teamA}
<input
type="number"
inputMode="numeric"
min={0}
<WeisTeam
name={game.teamA}
value={weisA}
onChange={(e) => setWeisA(e.target.value)}
placeholder="0"
disabled={loserWeisDisabled('a')}
disabled={weisBlocked('a')}
onAdd={(p) => addWeis('a', p)}
onClear={() => setWeisA(0)}
onChange={setWeisA}
/>
</label>
<label>
Weis {game.teamB}
<input
type="number"
inputMode="numeric"
min={0}
<WeisTeam
name={game.teamB}
value={weisB}
onChange={(e) => setWeisB(e.target.value)}
placeholder="0"
disabled={loserWeisDisabled('b')}
disabled={weisBlocked('b')}
onAdd={(p) => addWeis('b', p)}
onClear={() => setWeisB(0)}
onChange={setWeisB}
/>
</label>
</div>
</fieldset>
<div className="preview" aria-live="polite">
<div>
<span>{game.teamA}</span>
<strong>{preview.pointsA}</strong>
{effectiveWeisA > 0 && <small>+{effectiveWeisA} Weis</small>}
</div>
<div>
<span>{game.teamB}</span>
<strong>{preview.pointsB}</strong>
{effectiveWeisB > 0 && <small>+{effectiveWeisB} Weis</small>}
</div>
</div>
@ -424,3 +554,58 @@ function RoundScreen({
</div>
)
}
function WeisTeam({
name,
value,
disabled,
onAdd,
onClear,
onChange,
}: {
name: string
value: number
disabled: boolean
onAdd: (points: number) => void
onClear: () => void
onChange: (value: number) => void
}) {
return (
<div className={`weis-team ${disabled ? 'off' : ''}`}>
<div className="weis-head">
<span>{name}</span>
<strong>{disabled ? 0 : value}</strong>
</div>
<div className="weis-chips">
{WEIS_OPTIONS.map((opt) => (
<button
key={`${opt.label}-${opt.points}`}
type="button"
disabled={disabled}
onClick={() => onAdd(opt.points)}
>
+{opt.label}
</button>
))}
</div>
<div className="weis-edit">
<input
type="number"
inputMode="numeric"
min={0}
value={disabled ? 0 : value || ''}
placeholder="0"
disabled={disabled}
onChange={(e) => {
const n = e.target.value === '' ? 0 : Number(e.target.value)
onChange(Number.isFinite(n) && n >= 0 ? Math.round(n) : 0)
}}
aria-label={`Weis ${name}`}
/>
<button type="button" className="weis-clear" disabled={disabled || value === 0} onClick={onClear}>
×
</button>
</div>
</div>
)
}

View File

@ -4,6 +4,14 @@ export const ROUND_TOTAL = 157
export const MATSCH_TOTAL = 257
export const DEFAULT_TARGET = 1000
/** Häufige Weise zum schnellen Tippen (inkl. Stöck). */
export const WEIS_OPTIONS: { label: string; points: number }[] = [
{ label: '20', points: 20 },
{ label: '50', points: 50 },
{ label: '100', points: 100 },
{ label: 'Stöck', points: 20 },
]
export interface Round {
id: string
/**

View File

@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
strictPort: true,
allowedHosts: ['jassen.local', 'jassen.pauker.at', 'localhost'],
},
preview: {
host: true,
port: 4173,
strictPort: true,
allowedHosts: ['jassen.local', 'jassen.pauker.at', 'localhost'],
},
})