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:
parent
2cd921d609
commit
381c39a93c
84
README.md
84
README.md
@ -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)
|
||||
|
||||
15
deploy/apache-jassen.local.conf
Normal file
15
deploy/apache-jassen.local.conf
Normal 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>
|
||||
11
deploy/apache-jassen.pauker.at.conf
Normal file
11
deploy/apache-jassen.pauker.at.conf
Normal 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>
|
||||
21
deploy/nginx-jassen.pauker.at.conf
Normal file
21
deploy/nginx-jassen.pauker.at.conf
Normal 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
23
deploy/server-setup.sh
Normal 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
34
deploy/setup-local.ps1
Normal 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
6
public/.htaccess
Normal file
@ -0,0 +1,6 @@
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
224
src/App.css
224
src/App.css
@ -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;
|
||||
}
|
||||
|
||||
323
src/App.tsx
323
src/App.tsx
@ -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>
|
||||
)}
|
||||
|
||||
<div className="weis-grid">
|
||||
<label>
|
||||
Weis {game.teamA}
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
/**
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user