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).
|
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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
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
|
## Nutzung
|
||||||
|
|
||||||
1. Teamnamen und Ziel festlegen
|
- Standard-Ziel: **1000** Punkte
|
||||||
2. Pro Runde: Team mit weniger Punkten wählen und Summe eingeben (Rest = 157 − x)
|
- Pro Runde: Team, das gezählt hat, und Summe (Rest = 157 − x)
|
||||||
3. Optional Weis und Matsch (257)
|
- Weis und Matsch möglich
|
||||||
4. Stand mit Schlangen-Strichen (100 / 50 / 20)
|
- 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;
|
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 {
|
.preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@ -314,60 +431,54 @@ input {
|
|||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
flex: 1;
|
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);
|
color: var(--ink);
|
||||||
border-radius: 3px 14px 10px 4px;
|
border-radius: 3px 14px 10px 4px;
|
||||||
padding: 1.15rem 0.9rem 1rem;
|
padding: 1rem 0.75rem 1rem;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 0 var(--paper-edge),
|
0 1px 0 var(--paper-edge),
|
||||||
0 22px 48px var(--shadow);
|
0 22px 48px var(--shadow);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.85rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
animation: sheet-in 0.45s ease-out both;
|
animation: sheet-in 0.45s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-cols {
|
.snake-board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-heads {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1px 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: start;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-gutter {
|
.snake-head {
|
||||||
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 {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.25rem 0.35rem;
|
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col:not(.lead) {
|
.snake-head:not(.lead) {
|
||||||
opacity: 0.72;
|
opacity: 0.62;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col h2 {
|
.snake-head h2 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 0.2rem;
|
margin: 0 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: clamp(2.4rem, 12vw, 3rem);
|
font-size: clamp(2.35rem, 11vw, 2.9rem);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -387,40 +498,47 @@ input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.snake {
|
/* Schrieba nach jassa.at: alles in einem SVG → Striche treffen die Linien */
|
||||||
margin-top: 0.85rem;
|
.schrieba {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
gap: 0.25rem;
|
height: auto;
|
||||||
min-height: 3.5rem;
|
margin: 0.1rem 0 0;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snake-row {
|
.schrieba-mid {
|
||||||
display: flex;
|
stroke: color-mix(in srgb, var(--ink) 28%, transparent);
|
||||||
align-items: baseline;
|
stroke-width: 1.3;
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.snake-label {
|
.schrieba-snake {
|
||||||
color: var(--ink-soft);
|
fill: none;
|
||||||
font-weight: 600;
|
stroke: #2a5f9e;
|
||||||
width: 1.6rem;
|
stroke-width: 3;
|
||||||
text-align: right;
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
.schrieba-label {
|
||||||
letter-spacing: -0.08em;
|
fill: color-mix(in srgb, var(--ink) 45%, transparent);
|
||||||
font-weight: 500;
|
font-family: var(--font-ui);
|
||||||
color: var(--felt);
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rest {
|
.svg-stroke {
|
||||||
margin: 0.15rem 0 0;
|
stroke: #14110e;
|
||||||
font-size: 0.85rem;
|
stroke-width: 2.6;
|
||||||
font-weight: 600;
|
stroke-linecap: round;
|
||||||
color: var(--ink-soft);
|
}
|
||||||
|
|
||||||
|
.schrieba-rest-svg {
|
||||||
|
fill: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history {
|
.history {
|
||||||
@ -431,7 +549,7 @@ input {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
max-height: 40vh;
|
max-height: 32vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
323
src/App.tsx
323
src/App.tsx
@ -9,6 +9,7 @@ import {
|
|||||||
ROUND_TOTAL,
|
ROUND_TOTAL,
|
||||||
snakeMarks,
|
snakeMarks,
|
||||||
totals,
|
totals,
|
||||||
|
WEIS_OPTIONS,
|
||||||
winner,
|
winner,
|
||||||
} from './game'
|
} from './game'
|
||||||
import { clearGame, loadGame, saveGame } from './storage'
|
import { clearGame, loadGame, saveGame } from './storage'
|
||||||
@ -182,11 +183,14 @@ function PlayScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="sheet" aria-label="Punktestand">
|
<section className="sheet" aria-label="Punktestand">
|
||||||
<div className="sheet-cols">
|
<SnakeBoard
|
||||||
<TeamColumn name={game.teamA} total={score.a} marks={marksA} leading={score.a >= score.b} />
|
teamA={game.teamA}
|
||||||
<div className="sheet-gutter" aria-hidden="true" />
|
teamB={game.teamB}
|
||||||
<TeamColumn name={game.teamB} total={score.b} marks={marksB} leading={score.b >= score.a} />
|
scoreA={score.a}
|
||||||
</div>
|
scoreB={score.b}
|
||||||
|
marksA={marksA}
|
||||||
|
marksB={marksB}
|
||||||
|
/>
|
||||||
|
|
||||||
<ol className="history">
|
<ol className="history">
|
||||||
{game.rounds.length === 0 && (
|
{game.rounds.length === 0 && (
|
||||||
@ -201,10 +205,16 @@ function PlayScreen({
|
|||||||
<span>
|
<span>
|
||||||
{r.pointsA}
|
{r.pointsA}
|
||||||
{round.matsch && round.team === 'a' ? ' M' : ''}
|
{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>
|
||||||
<span>
|
<span>
|
||||||
{r.pointsB}
|
{r.pointsB}
|
||||||
{round.matsch && round.team === 'b' ? ' M' : ''}
|
{round.matsch && round.team === 'b' ? ' M' : ''}
|
||||||
|
{round.weisB > 0 && !(round.matsch && round.team !== 'b') ? (
|
||||||
|
<em className="weis-tag"> W{round.weisB}</em>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@ -227,38 +237,160 @@ function PlayScreen({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamColumn({
|
function SnakeBoard({
|
||||||
name,
|
teamA,
|
||||||
total,
|
teamB,
|
||||||
marks,
|
scoreA,
|
||||||
leading,
|
scoreB,
|
||||||
|
marksA,
|
||||||
|
marksB,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
teamA: string
|
||||||
total: number
|
teamB: string
|
||||||
marks: ReturnType<typeof snakeMarks>
|
scoreA: number
|
||||||
leading: boolean
|
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 (
|
return (
|
||||||
<div className={`col ${leading ? 'lead' : ''}`}>
|
<div className="snake-board">
|
||||||
<h2>{name}</h2>
|
<div className="snake-heads">
|
||||||
<p className="total">{total}</p>
|
<div className={`snake-head ${scoreA >= scoreB ? 'lead' : ''}`}>
|
||||||
<div className="snake" aria-hidden="true">
|
<h2>{teamA}</h2>
|
||||||
<SnakeRow label="100" count={marks.hundreds} />
|
<p className="total" key={scoreA}>
|
||||||
<SnakeRow label="50" count={marks.fifties} />
|
{scoreA}
|
||||||
<SnakeRow label="20" count={marks.twenties} />
|
</p>
|
||||||
{marks.rest > 0 && <p className="rest">+{marks.rest}</p>}
|
</div>
|
||||||
|
<div className={`snake-head ${scoreB >= scoreA ? 'lead' : ''}`}>
|
||||||
|
<h2>{teamB}</h2>
|
||||||
|
<p className="total" key={scoreB}>
|
||||||
|
{scoreB}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SnakeRow({ label, count }: { label: string; count: number }) {
|
function SvgMarkRow({
|
||||||
if (count === 0) return null
|
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 (
|
return (
|
||||||
<div className="snake-row">
|
<g>
|
||||||
<span className="snake-label">{label}</span>
|
{Array.from({ length: count }, (_, i) => {
|
||||||
<span className="ticks">{'丨'.repeat(count)}</span>
|
const x = side === 'left' ? first - i * gap : first + i * gap
|
||||||
</div>
|
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 [team, setTeam] = useState<TeamId>('a')
|
||||||
const [cardPoints, setCardPoints] = useState('')
|
const [cardPoints, setCardPoints] = useState('')
|
||||||
const [matsch, setMatsch] = useState(false)
|
const [matsch, setMatsch] = useState(false)
|
||||||
const [weisA, setWeisA] = useState('')
|
const [weisA, setWeisA] = useState(0)
|
||||||
const [weisB, setWeisB] = useState('')
|
const [weisB, setWeisB] = useState(0)
|
||||||
|
|
||||||
const parsedCards = cardPoints === '' ? 0 : Number(cardPoints)
|
const parsedCards = cardPoints === '' ? 0 : Number(cardPoints)
|
||||||
const parsedWeisA = weisA === '' ? 0 : Number(weisA)
|
const effectiveWeisA = matsch && team !== 'a' ? 0 : weisA
|
||||||
const parsedWeisB = weisB === '' ? 0 : Number(weisB)
|
const effectiveWeisB = matsch && team !== 'b' ? 0 : weisB
|
||||||
|
|
||||||
const preview = computeRound({
|
const preview = computeRound({
|
||||||
id: 'preview',
|
id: 'preview',
|
||||||
team,
|
team,
|
||||||
cardPoints: parsedCards,
|
cardPoints: parsedCards,
|
||||||
matsch,
|
matsch,
|
||||||
weisA: parsedWeisA,
|
weisA: effectiveWeisA,
|
||||||
weisB: parsedWeisB,
|
weisB: effectiveWeisB,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cardsValid =
|
const cardsValid =
|
||||||
@ -298,7 +430,13 @@ function RoundScreen({
|
|||||||
Number.isInteger(parsedCards))
|
Number.isInteger(parsedCards))
|
||||||
|
|
||||||
const teamName = team === 'a' ? game.teamA : game.teamB
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@ -319,23 +457,15 @@ function RoundScreen({
|
|||||||
team,
|
team,
|
||||||
cardPoints: matsch ? 0 : parsedCards,
|
cardPoints: matsch ? 0 : parsedCards,
|
||||||
matsch,
|
matsch,
|
||||||
weisA: loserWeisDisabled('a')
|
weisA: effectiveWeisA,
|
||||||
? 0
|
weisB: effectiveWeisB,
|
||||||
: Number.isFinite(parsedWeisA)
|
|
||||||
? Math.max(0, Math.round(parsedWeisA))
|
|
||||||
: 0,
|
|
||||||
weisB: loserWeisDisabled('b')
|
|
||||||
? 0
|
|
||||||
: Number.isFinite(parsedWeisB)
|
|
||||||
? Math.max(0, Math.round(parsedWeisB))
|
|
||||||
: 0,
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1>Neue Runde</h1>
|
<h1>Neue Runde</h1>
|
||||||
|
|
||||||
<fieldset>
|
<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">
|
<div className="seg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -365,7 +495,7 @@ function RoundScreen({
|
|||||||
|
|
||||||
{!matsch && (
|
{!matsch && (
|
||||||
<label>
|
<label>
|
||||||
Punkte von {teamName}
|
Gezählte Punkte ({teamName})
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@ -379,41 +509,41 @@ function RoundScreen({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="weis-grid">
|
<fieldset className="weis-block">
|
||||||
<label>
|
<legend>Weisen</legend>
|
||||||
Weis {game.teamA}
|
<p className="hint">
|
||||||
<input
|
Tippe Weise dazu — bei Matsch zählt nur der Weis des Siegers (Stöck inkl.).
|
||||||
type="number"
|
</p>
|
||||||
inputMode="numeric"
|
<div className="weis-grid">
|
||||||
min={0}
|
<WeisTeam
|
||||||
|
name={game.teamA}
|
||||||
value={weisA}
|
value={weisA}
|
||||||
onChange={(e) => setWeisA(e.target.value)}
|
disabled={weisBlocked('a')}
|
||||||
placeholder="0"
|
onAdd={(p) => addWeis('a', p)}
|
||||||
disabled={loserWeisDisabled('a')}
|
onClear={() => setWeisA(0)}
|
||||||
|
onChange={setWeisA}
|
||||||
/>
|
/>
|
||||||
</label>
|
<WeisTeam
|
||||||
<label>
|
name={game.teamB}
|
||||||
Weis {game.teamB}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
value={weisB}
|
value={weisB}
|
||||||
onChange={(e) => setWeisB(e.target.value)}
|
disabled={weisBlocked('b')}
|
||||||
placeholder="0"
|
onAdd={(p) => addWeis('b', p)}
|
||||||
disabled={loserWeisDisabled('b')}
|
onClear={() => setWeisB(0)}
|
||||||
|
onChange={setWeisB}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div className="preview" aria-live="polite">
|
<div className="preview" aria-live="polite">
|
||||||
<div>
|
<div>
|
||||||
<span>{game.teamA}</span>
|
<span>{game.teamA}</span>
|
||||||
<strong>{preview.pointsA}</strong>
|
<strong>{preview.pointsA}</strong>
|
||||||
|
{effectiveWeisA > 0 && <small>+{effectiveWeisA} Weis</small>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{game.teamB}</span>
|
<span>{game.teamB}</span>
|
||||||
<strong>{preview.pointsB}</strong>
|
<strong>{preview.pointsB}</strong>
|
||||||
|
{effectiveWeisB > 0 && <small>+{effectiveWeisB} Weis</small>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -424,3 +554,58 @@ function RoundScreen({
|
|||||||
</div>
|
</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 MATSCH_TOTAL = 257
|
||||||
export const DEFAULT_TARGET = 1000
|
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 {
|
export interface Round {
|
||||||
id: string
|
id: string
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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