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