Hustler-Pixelwelt T7: Detailansicht eines Hustlers (Szene nach Status, Daten, Spur, Blättern, Fokus)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
363419ed12
commit
3027f03dbe
3 files changed
+928
No files matched your search
@@ -0,0 +1,193 @@
|
||||
// Detailansicht eines Hustlers im Hochhaus (Spec E10, E14): Szene mit großer Figur nach Status (Büro, Stecker oder Brücke), rechts Name,
|
||||
// Status, Modell, Etage · Raum, Aufgabe, Gerade, Heute, Kosten, die Spur und unten ◂ Nummer ▸. Aufbau und Klassen wie Entwurf/markup.html der
|
||||
// Zeichenfläche (Block „offen“), alle Klassen mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-detail.css.
|
||||
// Die Felder liefert hustlerSicht.detailModell, die Positionen kommen aus DETAIL in hustlerWelt.js, die Spur aus useWorkerTrace (alle 15 s).
|
||||
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, kein innerHTML.
|
||||
//
|
||||
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf „Schließen“, Tab bleibt im Dialog (erster Knopf = Schließen, letzter = ▸),
|
||||
// Esc schließt, ←/→ blättern in Roster-Reihenfolge, ein Klick auf den Hintergrund schließt erst 400 ms nach dem Öffnen (Geister-Klick nach
|
||||
// dem Antippen der Figur). Beim Schließen geht der Fokus zurück auf das Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument
|
||||
// steht), sonst auf die Figur des zuletzt gezeigten Hustlers ([data-hk-hustler="<id>"], die setzt die Welt).
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useWorkerTrace } from '../../../hooks/useWorkersFeed'
|
||||
import { blaettern, detailModell } from '../../../lib/hustlerSicht'
|
||||
import { DETAIL } from '../../../lib/hustlerWelt'
|
||||
import { safeLink } from '../../../lib/workersView'
|
||||
|
||||
// So lange nach dem Öffnen schließt der Hintergrund nicht (der Antipp-Klick, der den Dialog öffnet, kommt sonst als Geister-Klick darauf an)
|
||||
const HINTERGRUND_SPERRE_MS = 400
|
||||
|
||||
// Ids der Hustler sind einfache Kennungen; nur solche gehen in den Attribut-Selektor
|
||||
const EINFACHE_ID = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
// Einziger Ort des Dialogs, an dem ein Ziel für einen Link entsteht (wie TraceText in WorkerCard): nur was safeLink durchlässt, wird ein Link
|
||||
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
|
||||
function SpurText({ link, children }) {
|
||||
const safe = isRecord(link) ? safeLink(link.href) : null
|
||||
if (!safe) return <>{children}</>
|
||||
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
return <Link to={safe.href}>{children}</Link>
|
||||
}
|
||||
|
||||
// Stelle der Figur in der Szene: Brücke (Overseer), Stecker-Szene (nicht angeschlossen) oder Büro
|
||||
function figurStelle(szene) {
|
||||
if (szene.includes('hk-sz-bruecke')) return DETAIL.figur.bruecke
|
||||
if (szene.includes('hk-sz-nicht')) return DETAIL.figur.nicht
|
||||
return DETAIL.figur.buero
|
||||
}
|
||||
|
||||
// Props: api (Dienst-API für die Spur), workerId (null = nichts anzeigen), workers (Hustler-Tabelle der Seite, Zustandseinträge nach workerId),
|
||||
// onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id)
|
||||
export default function HustlerDialog({ api, workerId, workers, onClose, onWechsel }) {
|
||||
const { trace, loading, error } = useWorkerTrace(api, workerId)
|
||||
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
|
||||
const letzterRef = useRef(null) // Knopf „Nächster Hustler“ (Ende der Fokusfalle)
|
||||
const sectionRef = useRef(null)
|
||||
const hintenRef = useRef(null)
|
||||
const geoeffnetRef = useRef(0)
|
||||
const openerRef = useRef(null)
|
||||
const idRef = useRef(workerId)
|
||||
const offen = Boolean(workerId)
|
||||
if (workerId) idRef.current = workerId
|
||||
|
||||
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
|
||||
useEffect(() => {
|
||||
if (!offen) return undefined
|
||||
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
|
||||
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
|
||||
geoeffnetRef.current = Date.now()
|
||||
const erster = ersterRef.current
|
||||
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
|
||||
return () => {
|
||||
const opener = openerRef.current
|
||||
openerRef.current = null
|
||||
let ziel = opener && opener.isConnected ? opener : null
|
||||
if (!ziel && typeof document !== 'undefined' && typeof document.querySelector === 'function' && EINFACHE_ID.test(String(idRef.current ?? ''))) {
|
||||
ziel = document.querySelector(`[data-hk-hustler="${idRef.current}"]`)
|
||||
}
|
||||
if (ziel && typeof ziel.focus === 'function') ziel.focus({ preventScroll: true })
|
||||
}
|
||||
}, [offen])
|
||||
|
||||
if (!workerId) return null
|
||||
|
||||
const eintrag = isRecord(workers) && Object.hasOwn(workers, workerId) ? workers[workerId] : null
|
||||
const m = detailModell(workerId, eintrag, trace, Date.now())
|
||||
const stelle = figurStelle(m.szene)
|
||||
const o = m.overlays
|
||||
const wechsel = (schritt) => onWechsel(blaettern(workerId, schritt))
|
||||
|
||||
// Hintergrund: schließt erst nach der Sperrzeit
|
||||
const hintergrund = () => {
|
||||
if (Date.now() - geoeffnetRef.current > HINTERGRUND_SPERRE_MS) onClose()
|
||||
}
|
||||
|
||||
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder dem Dialog selbst liegt):
|
||||
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächster Hustler“
|
||||
const taste = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return // Alt+← ist „Zurück“ im Browser
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
wechsel(e.key === 'ArrowRight' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
|
||||
const erster = ersterRef.current
|
||||
const letzter = letzterRef.current
|
||||
if (!e.shiftKey && aktiv === letzter && erster) {
|
||||
e.preventDefault()
|
||||
erster.focus()
|
||||
} else if (e.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === e.currentTarget)) {
|
||||
e.preventDefault()
|
||||
letzter.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hk-dialog-ueber" onKeyDown={taste}>
|
||||
<button type="button" tabIndex={-1} className="hk-dialog-hinten" aria-label="Detailansicht schließen" ref={hintenRef} onClick={hintergrund} />
|
||||
<section role="dialog" aria-modal="true" aria-label={`Hustler ${m.name}`} tabIndex={-1} className="hk-panel hk-dialog" ref={sectionRef}>
|
||||
<div className={m.szene} aria-hidden="true">
|
||||
<span className={o.gedimmt ? `${m.figur} hk-gedimmt` : m.figur} aria-hidden="true" style={{ left: stelle.left, top: stelle.top }}>
|
||||
{o.alarm && <span className="hk-alarm hk-alarm-gross" aria-hidden="true">!</span>}
|
||||
</span>
|
||||
{o.monitor && <span className="hk-spr hk-monitor-gross" aria-hidden="true" style={{ left: DETAIL.monitor.left, top: DETAIL.monitor.top }} />}
|
||||
{o.dampf && <span className="hk-spr hk-dampf" aria-hidden="true" style={{ left: DETAIL.dampf.left, top: DETAIL.dampf.top }} />}
|
||||
{o.zzz && <span className="hk-spr hk-zzz-gross" aria-hidden="true" style={{ left: DETAIL.zzz.left, top: DETAIL.zzz.top }} />}
|
||||
{o.wirbel && <span className="hk-spr hk-wirbel" aria-hidden="true" style={{ left: DETAIL.wirbel.left, top: DETAIL.wirbel.top }} />}
|
||||
{o.schild && (
|
||||
<span className="hk-dialog-schild" style={{ left: DETAIL.schild.left, top: DETAIL.schild.top, width: DETAIL.schild.w, height: DETAIL.schild.h }}>
|
||||
noch nicht angeschlossen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hk-dialog-daten">
|
||||
<div className="hk-dialog-kopf">
|
||||
<div>
|
||||
<h2 className="hk-dialog-name">{m.name}</h2>
|
||||
<div className="hk-dialog-status" style={{ '--hk-status': m.statusFarbe }}>
|
||||
<span className="hk-dialog-punkt" aria-hidden="true" />
|
||||
{m.statusText}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="hk-knopf" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
|
||||
<span className="hk-ic hk-schliessen" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="hk-dialog-dl">
|
||||
<dt>Modell</dt>
|
||||
<dd>{m.modell}</dd>
|
||||
<dt>Etage</dt>
|
||||
<dd>{m.etageRaum}</dd>
|
||||
<dt>Aufgabe</dt>
|
||||
<dd>{m.aufgabe}</dd>
|
||||
<dt>Gerade</dt>
|
||||
<dd>{m.gerade}</dd>
|
||||
<dt>Heute</dt>
|
||||
<dd>{m.heute}</dd>
|
||||
<dt>Kosten</dt>
|
||||
<dd>{`${m.kosten} · ${m.tokens} Tokens`}</dd>
|
||||
</dl>
|
||||
<div>
|
||||
<h3 className="hk-dialog-spurtitel">Spur</h3>
|
||||
{error && <p className="hk-dialog-fehler" role="alert">{error}</p>}
|
||||
{loading && <p className="hk-dialog-hinweis" role="status">Spur wird geladen …</p>}
|
||||
{!loading && m.spur.length === 0 && !error && <p className="hk-dialog-hinweis">Noch keine Einträge.</p>}
|
||||
{m.spur.length > 0 && (
|
||||
<ol className="hk-dialog-spurliste">
|
||||
{m.spur.map((row, i) => (
|
||||
<li key={`${row.id}-${i}`} className="hk-dialog-spurzeile">
|
||||
<span className="hk-dialog-zeit">{row.time}</span>
|
||||
<span className="hk-dialog-spurtext">
|
||||
<SpurText link={row.link}>{row.text}</SpurText>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
<div className="hk-dialog-fuss">
|
||||
<button type="button" className="hk-knopf" aria-label="Vorheriger Hustler" onClick={() => wechsel(-1)}>
|
||||
<span className="hk-ic hk-pfeil-links" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="hk-dialog-nummer">{m.nummer}</span>
|
||||
<button type="button" className="hk-knopf" ref={letzterRef} aria-label="Nächster Hustler" onClick={() => wechsel(1)}>
|
||||
<span className="hk-ic hk-pfeil-rechts" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Detailansicht eines Hustlers (HustlerDialog.jsx, Spec E10/E13/E14). Handgeschrieben, nicht erzeugt: die Inline-Stile des Aufbaus „offen“ in
|
||||
pixelwelt-v2/entwurf/markup.html stehen hier als Klassen. Szene, Figur, Zusätze (hk-sz, hk-gr-*, hk-monitor-gross, hk-dampf, hk-zzz-gross,
|
||||
hk-wirbel), Panel (hk-panel), Knöpfe (hk-knopf) und Symbole (hk-ic, hk-schliessen, hk-pfeil-*) kommen aus dem erzeugten hochhaus.css;
|
||||
hk-gedimmt und hk-alarm aus hustler-ui.css. Alle Klassen beginnen mit hk- und stehen unter der Wurzel .hk-hochhaus.
|
||||
Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS). */
|
||||
|
||||
/* ---------- Überlagerung: dunkler Hintergrund-Knopf und das Panel in der Mitte ---------- */
|
||||
.hk-hochhaus .hk-dialog-ueber {
|
||||
position: absolute; inset: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-sizing: border-box; padding: 16px;
|
||||
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff;
|
||||
}
|
||||
.hk-hochhaus .hk-dialog-hinten {
|
||||
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0;
|
||||
background: rgba(5, 8, 26, 0.74); cursor: default;
|
||||
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Panel: Szene links, Daten rechts; wird es zu schmal, rutschen die Daten unter die Szene und das Panel scrollt */
|
||||
.hk-hochhaus .hk-dialog {
|
||||
position: relative; display: flex; flex-wrap: wrap; gap: 20px;
|
||||
max-width: 100%; max-height: 100%; min-height: min(560px, 100%);
|
||||
overflow: auto; padding: 4px; outline: none;
|
||||
}
|
||||
.hk-hochhaus .hk-dialog a:focus-visible { outline: 2px solid #ffd25a; outline-offset: 2px; }
|
||||
|
||||
/* ---------- Szene: Hülle und Schild kommen aus hochhaus.css (hk-sz) bzw. hier (Schild) ---------- */
|
||||
.hk-hochhaus .hk-dialog-schild {
|
||||
position: absolute; display: flex; align-items: center; justify-content: center;
|
||||
color: #3a2f3d; font-size: 10px; line-height: 13px; text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- Rechte Spalte: Kopf, Fakten, Spur, Fußzeile ---------- */
|
||||
.hk-hochhaus .hk-dialog-daten {
|
||||
flex: 1 1 300px; min-width: 280px; max-width: 380px;
|
||||
display: flex; flex-direction: column; gap: 14px; padding: 2px 2px 2px 0;
|
||||
}
|
||||
.hk-hochhaus .hk-dialog-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.hk-hochhaus .hk-dialog-name {
|
||||
margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 20px; line-height: 28px; font-weight: 400; color: #ffffff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Farbe des Status kommt als --hk-status vom Dienst-Status (statusFarbe) */
|
||||
.hk-hochhaus .hk-dialog-status { margin-top: 6px; display: inline-flex; align-items: center; gap: 8px; color: var(--hk-status, #9aa6d6); }
|
||||
.hk-hochhaus .hk-dialog-punkt { flex: none; width: 10px; height: 10px; background: var(--hk-status, #9aa6d6); box-shadow: 0 0 0 2px #05081a; }
|
||||
|
||||
.hk-hochhaus .hk-dialog-dl { margin: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 14px; }
|
||||
.hk-hochhaus .hk-dialog-dl dt { color: #9aa6d6; }
|
||||
.hk-hochhaus .hk-dialog-dl dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.hk-hochhaus .hk-dialog-spurtitel {
|
||||
margin: 0 0 8px; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; font-weight: 400; color: #9aa6d6;
|
||||
}
|
||||
.hk-hochhaus .hk-dialog-spurliste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hk-hochhaus .hk-dialog-spurzeile { display: flex; gap: 10px; }
|
||||
/* Breite für „TT.MM. HH:MM“ (Ereignisse von früheren Tagen), nicht nur „HH:MM“ */
|
||||
.hk-hochhaus .hk-dialog-zeit { flex: none; width: 96px; color: #ffd25a; white-space: nowrap; }
|
||||
.hk-hochhaus .hk-dialog-spurtext { min-width: 0; overflow-wrap: anywhere; }
|
||||
.hk-hochhaus .hk-dialog-hinweis { margin: 0; color: #9aa6d6; }
|
||||
.hk-hochhaus .hk-dialog-fehler { margin: 0 0 8px; color: #ff6b6b; overflow-wrap: anywhere; }
|
||||
|
||||
.hk-hochhaus .hk-dialog-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.hk-hochhaus .hk-dialog-nummer { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; color: #9aa6d6; }
|
||||
|
||||
/* ---------- Handy (unter 720 px, Spec E13): Vollbild, Szene oben (auf die Breite verkleinert), Daten darunter scrollbar ---------- */
|
||||
@media (max-width: 719px) {
|
||||
.hk-hochhaus .hk-dialog-ueber { padding: 0; }
|
||||
.hk-hochhaus .hk-dialog {
|
||||
/* Container für die Szenen-Skalierung (cqw = Innenbreite des Panels) */
|
||||
container-type: inline-size;
|
||||
display: flex; flex-direction: column; flex-wrap: nowrap; gap: 12px;
|
||||
width: 100%; max-width: 100%; height: 100%; max-height: 100%; min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Szene: 640 x 400 wird per zoom auf die Breite des Panels gebracht (Layoutgröße schrumpft mit, kein waagrechtes Scrollen) */
|
||||
.hk-hochhaus .hk-dialog .hk-sz { flex: none; zoom: 0.5; }
|
||||
@supports (zoom: tan(atan2(1px, 1px))) {
|
||||
.hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(100cqw, 640px)); }
|
||||
}
|
||||
/* Daten: füllen den Rest und scrollen für sich; die Fußzeile ◂ n / 19 ▸ bleibt unten stehen */
|
||||
.hk-hochhaus .hk-dialog-daten {
|
||||
flex: 1 1 auto; min-width: 0; max-width: none; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 0 2px 0 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.hk-hochhaus .hk-dialog-fuss { position: sticky; bottom: 0; margin-top: 4px; padding: 6px 0 2px; background: #0e1430; }
|
||||
}
|
||||
|
||||
/* Handy quer (niedrig): Szene links (45 % der Breite), Daten rechts daneben und scrollbar */
|
||||
@media (max-width: 719px) and (max-height: 520px) {
|
||||
.hk-hochhaus .hk-dialog { flex-direction: row; align-items: flex-start; }
|
||||
.hk-hochhaus .hk-dialog .hk-sz { zoom: 0.4; }
|
||||
@supports (zoom: tan(atan2(1px, 1px))) {
|
||||
.hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(45cqw, 640px)); }
|
||||
}
|
||||
.hk-hochhaus .hk-dialog-daten { align-self: stretch; }
|
||||
}
|
||||
Reference in new issue
Block a user