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:
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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user