Hustler-Pixelwelt T6: Kopfzeile mit Münzen und Icons, Meldungen, Pause mit Pixel-Rückfrage, Einstellungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:20:19 +00:00
1 parent 3027f03dbe
commit 6f4350495f
3 files changed
+1110

No files matched your search

@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { shouldCloseOnKey } from '../../../lib/dialogKeys'
import { hudWerte } from '../../../lib/hustlerSicht'
import { pauseLabel } from '../../../lib/workersView'
import WorkersSettingsDialog from '../WorkersSettingsDialog'
const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.'
const NO_REASON_TEXT = 'Grund nicht angegeben'
// Grund der Notbremse für den Satz „Versand gestoppt: <Grund>. …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“)
function haltReasonOf(reason) {
const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : ''
return text || NO_REASON_TEXT
}
// Rückfrage „Alles anhalten?“ als Pixel-Dialog (Panel-Stil der Zeichenfläche). Fokus beim Öffnen auf „Abbrechen“ (die harmlose Wahl),
// Tab bleibt im Dialog, Esc schließt, beim Schließen geht der Fokus zurück: auf das Element, das ihn vorher hatte, sonst auf den
// Pause-Knopf (fallbackRef; Safari fokussiert Knöpfe beim Klicken nicht). Ein Klick auf den Hintergrund tut nichts und nimmt dem Dialog
// den Fokus nicht.
function Rueckfrage({ onCancel, onConfirm, fallbackRef }) {
const boxRef = useRef(null)
const cancelRef = useRef(null)
const confirmRef = useRef(null)
const openerRef = useRef(undefined)
if (openerRef.current === undefined) openerRef.current = document.activeElement
useEffect(() => {
cancelRef.current?.focus()
return () => {
const opener = openerRef.current
const usable = opener && opener !== document.body && opener.isConnected !== false && typeof opener.focus === 'function'
const target = usable ? opener : fallbackRef.current
if (target && target.isConnected !== false && typeof target.focus === 'function') target.focus()
}
}, [fallbackRef])
const onKeyDown = (e) => {
if (shouldCloseOnKey(e, false)) {
e.preventDefault()
onCancel()
return
}
if (e.key !== 'Tab') return
const items = [cancelRef.current, confirmRef.current].filter(Boolean)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
const inside = items.includes(active)
if (e.shiftKey && (active === first || active === boxRef.current || !inside)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault()
first.focus()
}
}
return (
<div
className="hk-rueckfrage-hintergrund"
onKeyDown={onKeyDown}
onMouseDown={(e) => { if (e.target === e.currentTarget) e.preventDefault() }}
>
<section className="hk-panel hk-rueckfrage" role="dialog" aria-modal="true" aria-labelledby="hk-rueckfrage-titel" tabIndex={-1} ref={boxRef}>
<h2 className="hk-rueckfrage-titel" id="hk-rueckfrage-titel">Alles anhalten?</h2>
<p className="hk-rueckfrage-text">Die Worker hören sofort auf, bis jemand fortsetzt.</p>
<div className="hk-rueckfrage-knoepfe">
<button type="button" className="hk-knopf" ref={cancelRef} onClick={() => onCancel()}>Abbrechen</button>
<button type="button" className="hk-knopf" ref={confirmRef} onClick={() => onConfirm()}>Anhalten</button>
</div>
</section>
</div>
)
}
// Kopfzeile des Hochhauses (Spec E7): Münzen (Einnahmen, API-Kosten), fünf Kennzahlen mit Icon, rechts „Ganzes Haus zeigen“ (nur Tastatur),
// Pause/Fortsetzen und das Zahnrad (nur Admins), darunter die Meldungen als Pixel-Banner. Die Pause-Logik ist die des früheren WorkersBoard:
// der Dienst bekommt den Zielwert (true = anhalten, nie aus dem Zustand abgeleitet), danach wird der Zustand sofort neu geholt; nur das
// Anhalten fragt vorher nach. Ohne Auth-Bezug: isAdmin kommt von der Seite. state ist der Dienst-Zustand (bereits geladen), error der
// Verbindungsfehler des Feeds (leer, solange alles gut geht), refresh holt den Zustand neu, onGanz zeigt das ganze Haus.
export default function HochhausKopf({ api, isAdmin, state, error, refresh, onGanz }) {
const aliveRef = useRef(true)
const pauseRef = useRef(null)
const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, falls er ihn verlor
const [settingsOpen, setSettingsOpen] = useState(false)
const [pausing, setPausing] = useState(false)
const [pauseError, setPauseError] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf gesperrt. Schließt die Rückfrage im selben Takt, in dem pausing beginnt,
// kann sie den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf <body>. Deshalb wird er erst hier gesetzt,
// wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch inzwischen woanders hin gewechselt
// (Fokus nicht auf <body>), bleibt es dabei.
useEffect(() => {
if (pausing || !refocusPauseRef.current) return
refocusPauseRef.current = false
const active = document.activeElement
if (active && active !== document.body) return
pauseRef.current?.focus()
}, [pausing])
// Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen
const haltedByState = Boolean(state?.paused)
useEffect(() => {
if (haltedByState) setConfirmOpen(false)
}, [haltedByState])
const paused = haltedByState
const hud = hudWerte(state?.kpis)
// Pause für alle Mitarbeiter: Zielwert übergeben, danach den Zustand sofort neu holen. Ein offener Dialog darf nie fortsetzen, falls
// inzwischen jemand anderes angehalten hat (darum der feste Zielwert).
const setPause = async (target) => {
if (pausing) return
refocusPauseRef.current = true
setPausing(true)
setPauseError('')
try {
await api.pause(target)
refresh()
} catch (err) {
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
}
if (aliveRef.current) setPausing(false)
}
// Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage
const requestPause = () => {
if (paused) setPause(false)
else setConfirmOpen(true)
}
const confirmHalt = () => {
setConfirmOpen(false)
setPause(true)
}
return (
<>
<div className="hk-kopf">
<div className="hk-kopf-zeile">
<section className="hk-panel hk-kopf-geld" aria-label="Geld">
<div className="hk-kopf-wert" title={hud.einnahmen.titel}>
<span className="hk-ic hk-muenze-gruen" aria-hidden="true" />
<span className="hk-kopf-betrag hk-kopf-gruen">
<span className="hk-kopf-sr">{hud.einnahmen.sr}</span>{hud.einnahmen.text}
</span>
</div>
<div className="hk-kopf-wert" title={hud.api.titel}>
<span className="hk-ic hk-muenze-rot hk-kopf-v-rot" aria-hidden="true" />
<span className={`hk-kopf-betrag hk-kopf-rot${hud.api.stufe ? ` hk-api-${hud.api.stufe}` : ''}`}>
<span className="hk-kopf-sr">{hud.api.sr}</span>{hud.api.text}
</span>
</div>
</section>
<section className="hk-panel hk-kopf-zahlen" aria-label="Kennzahlen">
{hud.zahlen.map((z) => (
<div className="hk-kopf-zahl" key={z.key} title={z.titel} style={{ color: z.farbe }}>
<span className={`${z.icon} hk-kopf-v-${z.key}`} aria-hidden="true" />
<span className="hk-kopf-sr">{z.titel}:</span>
<span>{z.text}</span>
</div>
))}
</section>
<span className="hk-kopf-abstand" />
<div className="hk-panel hk-kopf-rechts">
<button type="button" className="hk-nur-tastatur" onClick={() => onGanz?.()}>Ganzes Haus zeigen</button>
<button
type="button"
className="hk-hud-knopf"
aria-label={pauseLabel(paused)}
title={pauseLabel(paused)}
disabled={pausing}
onClick={requestPause}
ref={pauseRef}
data-hk-pause
>
<span className={paused ? 'hk-ic hk-pfeil-rechts' : 'hk-ic hk-pause'} aria-hidden="true" />
</button>
{isAdmin && (
<button type="button" className="hk-hud-knopf" aria-label="Einstellungen" title="Einstellungen" onClick={() => setSettingsOpen(true)}>
<span className="hk-ic hk-zahnrad hk-kopf-v-zahnrad" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="hk-meldungen">
{paused && <div className="hk-meldung hk-meldung-angehalten" role="status">{PAUSED_TEXT}</div>}
{pauseError !== '' && <div className="hk-meldung hk-meldung-umschalten" role="alert">{pauseError}</div>}
{state?.sendingHalted === true && (
<div className="hk-meldung hk-meldung-notbremse" role="alert">
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
</div>
)}
{Boolean(error) && <div className="hk-meldung hk-meldung-verbindung" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
</div>
</div>
{isAdmin && settingsOpen && (
<div className="hk-woms">
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
</div>
)}
{confirmOpen && !paused && (
<Rueckfrage onCancel={() => setConfirmOpen(false)} onConfirm={confirmHalt} fallbackRef={pauseRef} />
)}
</>
)
}
+108
View File
@@ -0,0 +1,108 @@
/* Kopfzeile des Hochhauses (HochhausKopf.jsx): Lage, Münzen und Kennzahlen, Pause/Zahnrad, Pixel-Banner für Meldungen, Rückfrage „Alles anhalten?“.
Handgeschrieben (nicht erzeugt). Die Inline-Stile der Zeichenfläche (Main.dc.html, erster Block) stehen hier als Klassen; Panel, Knopf, Icons und
hk-nur-tastatur kommen aus hochhaus.css. Alle Klassen beginnen mit hk- und stehen unter .hk-hochhaus; Keyframes heißen hk-kopf-… .
Wird wie hustler-ui.css in WorkersPage.jsx eingehängt (Komponenten importieren kein CSS). */
/* ---------- Lage: oben über der Welt, Abstand 12/10 px; nur die Panels fangen Zeigerereignisse ab (die Welt darunter bleibt bedienbar) ---------- */
.hk-hochhaus .hk-kopf {
position: absolute; left: 12px; top: 10px; right: 12px; z-index: 5;
display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
pointer-events: none;
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff;
}
.hk-hochhaus .hk-kopf-zeile { align-self: stretch; display: flex; align-items: flex-start; flex-wrap: wrap; gap: 10px; pointer-events: none; }
.hk-hochhaus .hk-kopf-abstand { flex: 1; min-width: 0; }
/* ---------- Panel „Geld“ ---------- */
.hk-hochhaus .hk-kopf-geld { display: flex; align-items: center; gap: 20px; padding: 0 6px 0 2px; pointer-events: auto; }
.hk-hochhaus .hk-kopf-wert { display: flex; align-items: center; gap: 8px; }
.hk-hochhaus .hk-kopf-betrag { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 20px; }
.hk-hochhaus .hk-kopf-gruen { color: #4ade5a; }
.hk-hochhaus .hk-kopf-rot { color: #ff6b6b; }
/* Budget fast (80 %) oder ganz verbraucht: der rote Betrag blinkt bzw. leuchtet kräftiger */
.hk-hochhaus .hk-api-warn { animation: hk-kopf-blinken 1s steps(1, end) infinite; }
.hk-hochhaus .hk-api-neg { color: #ff2d2d; text-shadow: 0 0 0 #ff2d2d, 1px 0 0 #ff2d2d; }
@keyframes hk-kopf-blinken { 0% { opacity: 1; } 50% { opacity: 0.3; } }
/* ---------- Panel „Kennzahlen“ ---------- */
.hk-hochhaus .hk-kopf-zahlen {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; max-width: 100%; padding: 0 6px 0 2px; pointer-events: auto;
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 13px; line-height: 18px;
}
.hk-hochhaus .hk-kopf-zahl { display: flex; align-items: center; gap: 6px; }
/* Die Icons laufen gegeneinander versetzt (wie in der Zeichenfläche), sonst blinzeln alle im selben Takt */
.hk-hochhaus .hk-kopf-v-rot { animation-delay: -1.3s; }
.hk-hochhaus .hk-kopf-v-abos { animation-delay: -0.4s; }
.hk-hochhaus .hk-kopf-v-deals { animation-delay: -1.1s; }
.hk-hochhaus .hk-kopf-v-threads { animation-delay: -1.8s; }
.hk-hochhaus .hk-kopf-v-pult { animation-delay: -2.5s; }
.hk-hochhaus .hk-kopf-v-botleads { animation-delay: -3.2s; }
.hk-hochhaus .hk-kopf-v-zahnrad { animation-delay: -0.9s; }
/* Nur für Screenreader (Beschriftung vor der Zahl) */
.hk-hochhaus .hk-kopf-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* ---------- Panel rechts: Ganzes Haus (nur Tastatur), Pause/Fortsetzen, Zahnrad ---------- */
.hk-hochhaus .hk-kopf-rechts { display: flex; align-items: center; gap: 2px; padding: 0; pointer-events: auto; }
.hk-hochhaus .hk-kopf .hk-nur-tastatur { font: inherit; }
.hk-hochhaus .hk-hud-knopf {
display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; margin: -6px 0; padding: 0;
border: 0; background: none; cursor: pointer;
}
.hk-hochhaus .hk-hud-knopf:disabled { opacity: 0.5; cursor: progress; }
/* ---------- Meldungen: Pixel-Banner unter der Kopfzeile, links, in der Breite begrenzt, mehrere untereinander ---------- */
.hk-hochhaus .hk-meldungen { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; max-width: min(560px, 100%); pointer-events: none; }
.hk-hochhaus .hk-meldungen:empty { display: none; }
.hk-hochhaus .hk-meldung {
--hk-ton: #5b6bc0;
position: relative; box-sizing: border-box; margin: 0; padding: 8px 12px 8px 44px;
border: 4px solid var(--hk-ton); background: #0e1430; color: #e6ebff; overflow-wrap: anywhere;
/* abgetreppte Ecken statt runder: Pixel-Optik ohne Bild */
clip-path: polygon(0 4px, 4px 4px, 4px 0, calc(100% - 4px) 0, calc(100% - 4px) 4px, 100% 4px, 100% calc(100% - 4px), calc(100% - 4px) calc(100% - 4px), calc(100% - 4px) 100%, 4px 100%, 4px calc(100% - 4px), 0 calc(100% - 4px));
}
.hk-hochhaus .hk-meldung::before {
content: '!'; position: absolute; left: 10px; top: 50%; width: 22px; height: 22px; margin-top: -11px;
background: var(--hk-ton); color: #1a1630; text-align: center;
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 12px; line-height: 22px;
}
.hk-hochhaus .hk-meldung-angehalten { --hk-ton: #ffd25a; }
.hk-hochhaus .hk-meldung-angehalten::before { content: 'II'; font-size: 10px; }
.hk-hochhaus .hk-meldung-umschalten,
.hk-hochhaus .hk-meldung-notbremse { --hk-ton: #e2483d; }
.hk-hochhaus .hk-meldung-umschalten::before,
.hk-hochhaus .hk-meldung-notbremse::before { color: #ffffff; }
.hk-hochhaus .hk-meldung-verbindung { --hk-ton: #ff9f43; }
/* ---------- Rückfrage „Alles anhalten?“: Pixel-Dialog über der ganzen Welt ---------- */
.hk-hochhaus .hk-rueckfrage-hintergrund {
position: absolute; inset: 0; z-index: 30; box-sizing: border-box;
display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(5, 8, 26, 0.72); color: #e6ebff;
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px;
}
.hk-hochhaus .hk-rueckfrage { width: 100%; max-width: 420px; padding: 4px 8px 8px; outline: none; }
.hk-hochhaus .hk-rueckfrage-titel { margin: 0 0 12px; color: #ffe9a0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 22px; font-weight: 400; }
.hk-hochhaus .hk-rueckfrage-text { margin: 0; color: #b8c2f0; }
.hk-hochhaus .hk-rueckfrage-knoepfe { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
/* ---------- Handy: die Kopfzeile bricht um, Schrift etwas kleiner ---------- */
@media (max-width: 719px) {
.hk-hochhaus .hk-kopf { left: 8px; right: 8px; top: 8px; gap: 8px; font-size: 14px; line-height: 18px; }
.hk-hochhaus .hk-kopf-zeile { gap: 6px 8px; }
.hk-hochhaus .hk-kopf-abstand { display: none; }
.hk-hochhaus .hk-kopf-geld { order: 1; gap: 12px; }
.hk-hochhaus .hk-kopf-betrag { font-size: 12px; line-height: 16px; }
.hk-hochhaus .hk-kopf-rechts { order: 2; margin-left: auto; }
.hk-hochhaus .hk-kopf-zahlen { order: 3; flex: 1 1 100%; gap: 6px 10px; font-size: 11px; line-height: 16px; }
.hk-hochhaus .hk-meldung { padding: 6px 10px 6px 40px; }
.hk-hochhaus .hk-rueckfrage-hintergrund { padding: 12px; }
.hk-hochhaus .hk-rueckfrage-titel { font-size: 13px; }
}
/* ---------- Weniger Bewegung: kein Blinken (der Betrag bleibt auch ohne Blinken erkennbar) ---------- */
@media (prefers-reduced-motion: reduce) {
.hk-hochhaus .hk-api-warn { animation: none; color: #ffb86b; }
}