Hustler-Pixelwelt T10: Keller (Szene mit Arne nach Status, Register-Buch, Akte, Gründe)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec63e5a43d
commit
1b3fdf5d97
14 files changed
+2247
-312
No files matched your search
@@ -1,34 +1,41 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import EmptyState from '../ui/EmptyState'
|
||||
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
|
||||
import Akte from './keller/Akte'
|
||||
import KellerSzene from './keller/KellerSzene'
|
||||
import RegisterBuch from './keller/RegisterBuch'
|
||||
import { pageInfo } from '../../lib/botleadsView'
|
||||
import { eintragModell } from '../../lib/hustlerKeller'
|
||||
import { formatCount } from '../../lib/workersView'
|
||||
import { restoreFocus } from '../../lib/workersFocus'
|
||||
|
||||
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
|
||||
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
|
||||
const UNKNOWN_ERROR = 'Unbekannter Fehler'
|
||||
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
|
||||
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
|
||||
const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
||||
|
||||
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
|
||||
// Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch
|
||||
// (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet
|
||||
// oder angerufen. Props: api, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), onBack (◂ Hochhaus).
|
||||
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
|
||||
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
|
||||
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
|
||||
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
|
||||
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
|
||||
export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten), und zwar
|
||||
// nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub.
|
||||
export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBack }) {
|
||||
const botId = typeof bot === 'string' ? bot : ''
|
||||
const single = botId !== ''
|
||||
const [view, setView] = useState(START_VIEW)
|
||||
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
|
||||
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
|
||||
const [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt
|
||||
const [zu, setZu] = useState(false) // „Register zuklappen“: nur die Szene bleibt
|
||||
const [akte, setAkte] = useState(null) // Nr. des Eintrags in der gezeigten Seite, dessen Akte offen ist
|
||||
const timerRef = useRef(null)
|
||||
const seqRef = useRef(0)
|
||||
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
|
||||
// Tastaturfokus: Wer „Alle anzeigen“ oder ◂/▸ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
|
||||
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
|
||||
const selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“
|
||||
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus
|
||||
const reiterRef = useRef(null) // aktiver Raum-Reiter: Ziel nach „Alle anzeigen“
|
||||
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen) bekommt der Reiter den Fokus
|
||||
const prevRef = useRef(null)
|
||||
const nextRef = useRef(null)
|
||||
const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“)
|
||||
@@ -40,6 +47,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
const mine = seqRef.current
|
||||
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
|
||||
setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
|
||||
setAkte(null) // eine neue Anfrage tauscht die Seite aus: die Akte zu einem Eintrag der alten Seite ist zu
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
|
||||
@@ -73,8 +81,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
pagedRef.current = dir
|
||||
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
|
||||
}
|
||||
const onRoom = (e) => {
|
||||
const room = e.target.value
|
||||
const onRoom = (room) => {
|
||||
pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück
|
||||
setView((v) => ({ ...v, room, page: 1 }))
|
||||
}
|
||||
@@ -88,17 +95,17 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
|
||||
const loading = result.key !== key
|
||||
|
||||
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter ersetzt den gedrückten Knopf und bekommt den Fokus.
|
||||
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raum-Reiter ersetzt den gedrückten Knopf und bekommt den Fokus.
|
||||
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
|
||||
useEffect(() => {
|
||||
const wanted = clearedRef.current
|
||||
clearedRef.current = false
|
||||
if (wanted && !single) selectRef.current?.focus()
|
||||
if (wanted && !single) reiterRef.current?.focus()
|
||||
}, [single])
|
||||
|
||||
// Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen.
|
||||
// Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Raumfilter), wird
|
||||
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite, denn die Seitenzeile steht unter der Tabelle (restoreFocus in workersFocus.js).
|
||||
// Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Reiter), wird
|
||||
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite (restoreFocus in workersFocus.js).
|
||||
// Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück).
|
||||
useEffect(() => {
|
||||
if (loading || result.error) return
|
||||
@@ -108,86 +115,59 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current])
|
||||
}, [loading, result.error])
|
||||
|
||||
const rows = result.items.map((item) => botleadRow(item))
|
||||
const zeilen = result.items.map((item) => eintragModell(item))
|
||||
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
|
||||
const filtered = view.room !== '' || view.q !== ''
|
||||
const zeigeAkte = akte !== null && !loading && !result.error && akte < zeilen.length
|
||||
const plakette = kpis && Number.isFinite(kpis.botLeads) ? formatCount(kpis.botLeads) : ''
|
||||
|
||||
let body
|
||||
if (loading) {
|
||||
body = <div className="loading" role="status">Wird geladen …</div>
|
||||
} else if (result.error) {
|
||||
body = (
|
||||
<>
|
||||
<div className="msg err" role="alert">{result.error}</div>
|
||||
<div className="toolbar wk-bl-retry">
|
||||
<button type="button" className="btn" onClick={onRetry}>Erneut versuchen</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (rows.length === 0) {
|
||||
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
|
||||
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
|
||||
else body = <EmptyState title="Noch keine Bot-Leads." hint="Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund." />
|
||||
} else {
|
||||
body = (
|
||||
<div className="wk-bl-scroll" role="region" aria-label="Bot-Leads" tabIndex={0}>
|
||||
<table className="table wk-bl-table">
|
||||
<thead>
|
||||
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && row.id === botId ? 'true' : undefined}>
|
||||
<td>
|
||||
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
|
||||
{row.place && <div className="cell-sub">{row.place}</div>}
|
||||
</td>
|
||||
<td>{row.roomLabel}</td>
|
||||
<td className="wk-bl-nowrap">{row.scoreLabel}</td>
|
||||
<td>{row.reason}</td>
|
||||
<td>{row.phone}</td>
|
||||
<td className="wk-bl-nowrap">
|
||||
{row.sourceHref
|
||||
? <a href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${row.title}`}>Quelle öffnen</a>
|
||||
: '–'}
|
||||
</td>
|
||||
<td className="wk-bl-nowrap">{row.found}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const onOpen = (nr) => setAkte(nr)
|
||||
const onBlaettern = (schritt) => setAkte((nr) => (nr === null || zeilen.length === 0 ? nr : (nr + schritt + zeilen.length) % zeilen.length))
|
||||
|
||||
return (
|
||||
<div className="wk-bl">
|
||||
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p>
|
||||
{single ? (
|
||||
<div className="toolbar wk-bl-single">
|
||||
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
|
||||
<button type="button" className="btn" onClick={onClear}>Alle anzeigen</button>
|
||||
<div className="hk-keller">
|
||||
<KellerSzene workers={workers} />
|
||||
<div className="hk-kl-vignette" aria-hidden="true" />
|
||||
<div className="hk-kl-dunkel" aria-hidden="true" />
|
||||
<div className="hk-kl-ui">
|
||||
<div className="hk-kl-kopf">
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-klein" aria-label="Zurück zum Hochhaus" onClick={() => { if (typeof onBack === 'function') onBack() }}>
|
||||
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" /><span className="hk-kl-label">Hochhaus</span>
|
||||
</button>
|
||||
<div className="hk-messing hk-kl-schild">
|
||||
<span className="hk-ic hk-ic-robo" aria-hidden="true" />
|
||||
<h1 className="hk-kl-titel">Bot-Leads</h1>
|
||||
{plakette ? <span className="hk-kl-zahl">{plakette}</span> : null}
|
||||
</div>
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-klein hk-kl-zuklappen" aria-expanded={!zu} aria-label={zu ? 'Register aufklappen' : 'Register zuklappen'} onClick={() => setZu((z) => !z)}>
|
||||
<span className="hk-ic hk-ic-akte" aria-hidden="true" /><span className="hk-kl-label">{zu ? 'Register aufklappen' : 'Register zuklappen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="toolbar">
|
||||
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
|
||||
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||
</select>
|
||||
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
{!single && !loading && !result.error && result.total > 0 && (
|
||||
<div className="table-foot">
|
||||
<span>{info.label}</span>
|
||||
{info.pages > 1 && (
|
||||
<span className="wk-bl-pager">
|
||||
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
|
||||
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zu ? null : (
|
||||
<RegisterBuch
|
||||
einzeln={single}
|
||||
botId={botId}
|
||||
room={view.room}
|
||||
draft={draft}
|
||||
filtered={filtered}
|
||||
laden={loading}
|
||||
fehler={result.error}
|
||||
zeilen={zeilen}
|
||||
total={result.total}
|
||||
info={info}
|
||||
onRoom={onRoom}
|
||||
onSearch={onSearch}
|
||||
onClear={onClear}
|
||||
onPage={onPage}
|
||||
onRetry={onRetry}
|
||||
onOpen={onOpen}
|
||||
reiterRef={reiterRef}
|
||||
zurueckRef={prevRef}
|
||||
weiterRef={nextRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{zeigeAkte ? <Akte eintrag={zeilen[akte]} index={akte} gesamt={zeilen.length} onClose={() => setAkte(null)} onBlaettern={onBlaettern} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Die Akte zu einem Bot-Lead (Hustler-Pixelwelt T10, Spec E12/E13/E14): Dialog auf der Manila-Karte (9-slice hk-akte der Vorlage), Aufbau und Klassen wie
|
||||
// keller/entwurf/BotLeads.dc.html mit hk- davor, die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
|
||||
// Inhalt: Raum und Scout, Name, Ort, Punkte (Balken und scoreLabel), Grund mit Symbol und Erklärung (grundInfo), Telefon (Regel aus botleadRow: Heilberufe nie, ohne Quelle
|
||||
// „nicht verfügbar“), Quelle als Link nur über row.sourceHref (botleadRow prüft mit safeHref: nur http und https, keine Zugangsdaten; neuer Tab ohne Rückbezug),
|
||||
// gefunden am und, bei einem gekürzten Eintrag, „gekürzt“. Blättern ◂ ▸ innerhalb der Seite (im Kreis), Esc schließt, ←/→ blättern, Tab bleibt im Dialog.
|
||||
// Fokus: beim Öffnen auf „Schließen“; beim Schließen zurück auf die Zeile des zuletzt gezeigten Eintrags ([data-hk-zeile="<Nr>"], die setzt das Buch), sonst auf das
|
||||
// Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument steht). Alle Texte sind Textknoten, kein HTML-Einschub, kein CSS-Import, keine Bilder.
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// Props: eintrag (Ergebnis von eintragModell), index (Nr. in der Seite, ab 0), gesamt (Einträge der Seite), onClose(), onBlaettern(schritt: -1 | 1)
|
||||
export default function Akte({ eintrag: e, index, gesamt, onClose, onBlaettern }) {
|
||||
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
|
||||
const letzterRef = useRef(null) // Knopf „Nächste Akte“ (Ende der Fokusfalle)
|
||||
const sectionRef = useRef(null)
|
||||
const hintenRef = useRef(null)
|
||||
const openerRef = useRef(null)
|
||||
const indexRef = useRef(index)
|
||||
indexRef.current = index
|
||||
|
||||
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
|
||||
useEffect(() => {
|
||||
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
|
||||
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
|
||||
const erster = ersterRef.current
|
||||
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
|
||||
return () => {
|
||||
const opener = openerRef.current
|
||||
openerRef.current = null
|
||||
let ziel = null
|
||||
if (typeof document !== 'undefined' && typeof document.querySelector === 'function' && Number.isInteger(indexRef.current)) {
|
||||
ziel = document.querySelector(`[data-hk-zeile="${indexRef.current}"]`)
|
||||
}
|
||||
if (!ziel && opener && opener.isConnected !== false) ziel = opener
|
||||
if (ziel && typeof ziel.focus === 'function') ziel.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder der Karte selbst liegt):
|
||||
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächste Akte“
|
||||
const taste = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
|
||||
if (ev.altKey || ev.ctrlKey || ev.metaKey) return // Alt+← ist „Zurück“ im Browser
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onBlaettern(ev.key === 'ArrowRight' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (ev.key !== 'Tab') return
|
||||
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
|
||||
const erster = ersterRef.current
|
||||
const letzter = letzterRef.current
|
||||
if (!ev.shiftKey && aktiv === letzter && erster) {
|
||||
ev.preventDefault()
|
||||
erster.focus()
|
||||
} else if (ev.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === ev.currentTarget)) {
|
||||
ev.preventDefault()
|
||||
letzter.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const p = e.punkte
|
||||
return (
|
||||
<div className="hk-kl-akte-ueber" onKeyDown={taste}>
|
||||
<button type="button" tabIndex={-1} className="hk-kl-akte-hinten" aria-label="Akte schließen" ref={hintenRef} onClick={() => onClose()} />
|
||||
<section role="dialog" aria-modal="true" aria-label={`Akte ${e.title}`} tabIndex={-1} className="hk-akte hk-kl-akte" ref={sectionRef}>
|
||||
<span className="hk-kl-sr" aria-live="polite">{`Akte ${index + 1} von ${gesamt}: ${e.title}`}</span>
|
||||
<div className="hk-kl-akte-inhalt">
|
||||
<div className="hk-kl-akte-kopf">
|
||||
<div className="hk-kl-akte-titelbox">
|
||||
<div className="hk-kl-akte-raum">
|
||||
<span className={e.raumIcon} aria-hidden="true" />
|
||||
{e.scout ? `${e.roomLabel} · gefunden von ${e.scout}` : e.roomLabel}
|
||||
</div>
|
||||
<h2 className="hk-kl-akte-name">{e.title}</h2>
|
||||
{e.place ? <div className="hk-kl-akte-ort">{e.place}</div> : null}
|
||||
</div>
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
|
||||
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="hk-kl-akte-dl">
|
||||
<dt>Punkte</dt>
|
||||
<dd className="hk-kl-akte-zeile">
|
||||
<span className="hk-kl-balken hk-kl-balken-gross" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${p.farbe}`} style={{ width: p.breiteGross }} /></span>
|
||||
<span className="hk-kl-punkte-text">{e.scoreLabel}</span>
|
||||
</dd>
|
||||
<dt>Grund</dt>
|
||||
<dd>
|
||||
<span className="hk-kl-akte-zeile hk-kl-akte-grund"><span className={e.grundIcon} aria-hidden="true" /><span className="hk-kl-akte-grundtext">{e.reason}</span></span>
|
||||
<span className="hk-kl-akte-erklaerung">{e.erklaerung}</span>
|
||||
</dd>
|
||||
<dt>Telefon</dt>
|
||||
<dd className="hk-kl-akte-zeile"><span className={e.telefonIcon} aria-hidden="true" />{e.phone}</dd>
|
||||
<dt>Quelle</dt>
|
||||
<dd>
|
||||
{e.sourceHref
|
||||
? <a className="hk-akte-link" href={e.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${e.title}`}><span className="hk-ic hk-ic-quelle" aria-hidden="true" />Quelle öffnen</a>
|
||||
: '–'}
|
||||
</dd>
|
||||
<dt>Gefunden</dt>
|
||||
<dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-kalender" aria-hidden="true" />{e.found}</dd>
|
||||
{e.gekuerzt ? <dt>Kürzung</dt> : null}
|
||||
{e.gekuerzt ? <dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-schere" aria-hidden="true" />gekürzt</dd> : null}
|
||||
</dl>
|
||||
<div className="hk-kl-akte-fuss">
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-icon" aria-label="Vorherige Akte" onClick={() => onBlaettern(-1)}>
|
||||
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="hk-kl-akte-hinweis">Nur zum Nachsehen, hier wird nichts verschickt.</span>
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={letzterRef} aria-label="Nächste Akte" onClick={() => onBlaettern(1)}>
|
||||
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Die Szene des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): der Archiv-Raum aus keller/entwurf/BotLeads.dc.html, Klassen und Inline-Geometrie 1:1 mit hk- davor
|
||||
// (Lage der Dinge: src/lib/hustlerKeller.js). Keller-Raum, Licht mit Flackern, Staub, Schnee im Fenster mit Passant, Maus, Blinkpunkte, Einpassen „cover“ links
|
||||
// verankert (ResizeObserver, wie die Vorlage).
|
||||
// Arne hängt am Status des Hustlers (arneAnsicht über figurZustand): bei „arbeitet“ läuft seine Ablauf-Schleife samt Rohrpost, Stempel und Schublade; sonst steht oder
|
||||
// sitzt er als Haus-Figur der passenden Pose (warten, schlafen, nicht) an seinem Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker hk-alarm.
|
||||
// Die ganze Szene ist Zierde (aria-hidden). Kein CSS-Import (WorkersPage hängt keller.css und hustler-keller-ui.css ein), keine Bilder, kein HTML-Einschub.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ARNE_RUHE, BUCH_STEMPEL, DAMPF, FENSTER, LEDS, MAUS_STREIFEN, ROHRPOST, SCHUBLADE, arneAnsicht, kellerEinpassen } from '../../../lib/hustlerKeller'
|
||||
|
||||
// Props: workers = Hustler-Tabelle der Seite (Zustandseinträge nach workerId, aus indexWorkers/reduceEvents)
|
||||
export default function KellerSzene({ workers }) {
|
||||
const rahmenRef = useRef(null)
|
||||
const [fit, setFit] = useState(() => kellerEinpassen(0, 0))
|
||||
const arne = arneAnsicht(workers)
|
||||
|
||||
// Einpassen: bei jeder Größenänderung des Rahmens neu (ohne Messung bleibt die Vorgabe der Vorlage, Maßstab 2)
|
||||
useEffect(() => {
|
||||
const el = rahmenRef.current
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
|
||||
const passe = () => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (!(r.width > 0 && r.height > 0)) return
|
||||
const next = kellerEinpassen(r.width, r.height)
|
||||
setFit((prev) => (prev.s === next.s && prev.x === next.x && prev.y === next.y ? prev : next))
|
||||
}
|
||||
passe()
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(passe)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('resize', passe)
|
||||
return () => window.removeEventListener('resize', passe)
|
||||
}
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const ruht = arne.arbeitet ? '' : ' hk-kl-ruht'
|
||||
|
||||
return (
|
||||
<div className="hk-kl-szene" aria-hidden="true" ref={rahmenRef} data-hk-arne={arne.status}>
|
||||
<div className="hk-kl-buehne" style={{ transform: `translate(${fit.x.toFixed(1)}px, ${fit.y.toFixed(1)}px) scale(${fit.s.toFixed(4)})` }}>
|
||||
<div className="hk-sz-ebene hk-sz-bild" />
|
||||
<div style={FENSTER}>
|
||||
<div className="hk-sz-fensterschnee" />
|
||||
<div className="hk-sz-passant-weg"><span className="hk-spr hk-sz-passant" /></div>
|
||||
<div className="hk-sz-kreuz" />
|
||||
</div>
|
||||
<span className="hk-spr hk-sz-dampf" style={DAMPF} />
|
||||
<span className={`hk-spr hk-sz-buch-stempel${ruht}`} style={BUCH_STEMPEL} />
|
||||
<span className={`hk-spr hk-sz-rohrpost${ruht}`} style={ROHRPOST} />
|
||||
<span className={`hk-spr hk-sz-schublade${ruht}`} style={SCHUBLADE} />
|
||||
{arne.arbeitet ? (
|
||||
<div className="hk-arne-weg"><span className="hk-arne-bild" /></div>
|
||||
) : (
|
||||
<span className="hk-kl-arne" style={ARNE_RUHE}>
|
||||
<span className={`hk-spr hk-kl-fig hk-kl-fig-${arne.pose}`} />
|
||||
{arne.punkte ? <span className="hk-spr hk-kl-punkte" /> : null}
|
||||
{arne.zzz ? <span className="hk-spr hk-kl-zzz" /> : null}
|
||||
{arne.alarm ? <span className="hk-alarm">!</span> : null}
|
||||
</span>
|
||||
)}
|
||||
<div style={MAUS_STREIFEN}><div className="hk-sz-maus-weg"><span className="hk-spr hk-sz-maus" /></div></div>
|
||||
{LEDS.map((l) => <span key={l.name} className="hk-sz-led" style={l.stil} />)}
|
||||
<div className="hk-sz-ebene hk-sz-licht-tisch" />
|
||||
<div className="hk-sz-ebene hk-sz-licht-92" />
|
||||
<div className="hk-sz-ebene hk-sz-licht-236" />
|
||||
<div className="hk-sz-ebene hk-sz-licht-halle" />
|
||||
<div className="hk-sz-ebene hk-sz-staub" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Das Register-Buch des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Raum-Reiter, das aufgeschlagene Buch mit Titel, Hinweis, Suchfeld (oder, bei einem einzelnen
|
||||
// Eintrag, „Alle anzeigen“), Kopfzeile, den Einträgen der Dienst-Seite (im Buch scrollbar), Leer-, Lade- und Fehlerzustand und der Seitenzeile mit ◂ ▸.
|
||||
// Aufbau und Klassen wie keller/entwurf/BotLeads.dc.html mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
|
||||
// Rein darstellend: Abruf, Suche, Seiten und Fokus gehören BotLeadList. Alle Texte der Zeilen sind Textknoten; Links gibt es hier nicht (der Link zur Quelle steht in der
|
||||
// Akte und kommt nur aus row.sourceHref). Kein CSS-Import, keine Bilder, kein HTML-Einschub.
|
||||
import { REITER } from '../../../lib/hustlerKeller'
|
||||
import { formatCount } from '../../../lib/workersView'
|
||||
|
||||
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
|
||||
|
||||
// Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon.
|
||||
function Zeile({ z, nr, aktuell, onOpen }) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={z.gekuerzt ? 'hk-zeile hk-zeile-alt' : 'hk-zeile'}
|
||||
data-hk-zeile={nr}
|
||||
aria-label={z.label}
|
||||
aria-current={aktuell ? 'true' : undefined}
|
||||
onClick={(ev) => onOpen(nr, ev)}
|
||||
>
|
||||
<span className={z.raumIcon} aria-hidden="true" />
|
||||
<span className="hk-kl-namebox">
|
||||
<span className="hk-kl-namezeile">
|
||||
<span className="hk-zelle-text hk-kl-name" title={z.title}>{z.title}</span>
|
||||
{z.gekuerzt ? <span className="hk-kl-tag"><span className="hk-ic hk-ic-schere hk-kl-tag-icon" aria-hidden="true" />gekürzt</span> : null}
|
||||
</span>
|
||||
<span className="hk-klein">{[z.place, z.found].filter((t) => t !== '').join(' · ')}</span>
|
||||
</span>
|
||||
<span className="hk-zelle hk-kl-spalte-punkte">
|
||||
<span className="hk-kl-balken" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${z.punkte.farbe}`} style={{ width: z.punkte.breite }} /></span>
|
||||
<span className="hk-kl-punkte-text">{z.punkte.text}</span>
|
||||
</span>
|
||||
<span className="hk-zelle"><span className={z.grundIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.reason}</span></span>
|
||||
<span className="hk-zelle hk-kl-spalte-telefon"><span className={z.telefonIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.phone}</span></span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Props:
|
||||
// einzeln (Einzelansicht über ?bot=), botId, room (gewählter Raum-Schlüssel), draft (Inhalt des Suchfelds), filtered (Raum oder Suche gesetzt)
|
||||
// laden, fehler (Satz des Dienstes oder null), zeilen (eintragModell je Eintrag der Seite), total, info (pageInfo)
|
||||
// onRoom(value), onSearch(ev), onClear(), onPage('prev' | 'next'), onRetry(), onOpen(nr, ev)
|
||||
// reiterRef (am aktiven Reiter), zurueckRef, weiterRef (Blätterknöpfe): Fokus-Ziele von BotLeadList
|
||||
export default function RegisterBuch({ einzeln, botId, room, draft, filtered, laden, fehler, zeilen, total, info, onRoom, onSearch, onClear, onPage, onRetry, onOpen, reiterRef, zurueckRef, weiterRef }) {
|
||||
const zahl = laden || fehler ? null : total // die Zahl steht nur am aktiven Reiter und erst, wenn die Antwort da ist
|
||||
|
||||
let koerper
|
||||
if (laden) {
|
||||
koerper = <p className="hk-kl-laden" role="status">Wird geladen …</p>
|
||||
} else if (fehler) {
|
||||
koerper = (
|
||||
<div className="hk-kl-fehlerbox">
|
||||
<p className="hk-kl-fehler" role="alert">{fehler}</p>
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onRetry()}>Erneut versuchen</button>
|
||||
</div>
|
||||
)
|
||||
} else if (zeilen.length === 0) {
|
||||
const leer = einzeln
|
||||
? ['Dieser Eintrag ist nicht (mehr) in der Liste.', 'Er wurde gekürzt oder ist kein Bot-Lead.']
|
||||
: filtered
|
||||
? ['Keine Treffer.', 'Für diesen Raum und diese Suche gibt es keine Bot-Leads.']
|
||||
: ['Noch keine Bot-Leads.', 'Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.']
|
||||
koerper = (
|
||||
<div className="hk-kl-leer">
|
||||
<p className="hk-kl-leer-titel">{leer[0]}</p>
|
||||
<p className="hk-kl-leer-text">{leer[1]}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
koerper = (
|
||||
<ol className="hk-liste" aria-label="Bot-Leads">
|
||||
{zeilen.map((z, i) => <Zeile key={z.id || `ohne-id-${i}`} z={z} nr={i} aktuell={einzeln && z.id === botId} onOpen={onOpen} />)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
const mehrere = info.pages > 1
|
||||
return (
|
||||
<>
|
||||
{einzeln ? null : (
|
||||
<div className="hk-kl-reiter" role="group" aria-label="Räume filtern">
|
||||
{REITER.map((r) => {
|
||||
const aktiv = r.value === room
|
||||
const mitZahl = aktiv && zahl !== null
|
||||
return (
|
||||
<button
|
||||
key={r.value || 'alle'}
|
||||
type="button"
|
||||
className={aktiv ? `${r.klasse} hk-reiter-an` : r.klasse}
|
||||
aria-pressed={aktiv}
|
||||
aria-label={mitZahl ? `${r.label}, ${formatCount(zahl)} ${zahl === 1 ? 'Eintrag' : 'Einträge'}` : r.label}
|
||||
ref={aktiv ? reiterRef : undefined}
|
||||
onClick={() => onRoom(r.value)}
|
||||
>
|
||||
<span className={r.icon} aria-hidden="true" />
|
||||
<span className="hk-zelle-text">{r.kurz}</span>
|
||||
{mitZahl ? <span className="hk-kl-reiter-zahl">{formatCount(zahl)}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<section className="hk-buch hk-kl-buch" aria-label="Nie-doppelt-Register">
|
||||
<div className="hk-kl-buchkopf">
|
||||
<div className="hk-kl-buchtitelbox">
|
||||
<h2 className="hk-kl-buchtitel">Nie-doppelt-Register</h2>
|
||||
<p className="hk-kl-hinweis">Reine Liste: keine Anrufe, kein Mailversand.</p>
|
||||
</div>
|
||||
{einzeln ? (
|
||||
<div className="hk-kl-einzeln">
|
||||
<span className="hk-kl-einzeln-text">Einzelner Eintrag aus dem Ereignisstrom</span>
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onClear()}>Alle anzeigen</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hk-kl-suchfeld">
|
||||
<span className="hk-ic hk-ic-lupe hk-kl-lupe" aria-hidden="true" />
|
||||
<input className="hk-suche" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{laden || fehler || zeilen.length === 0 ? null : (
|
||||
<div className="hk-kopfzeile" aria-hidden="true">
|
||||
<span />
|
||||
<span>Betrieb · Ort · gefunden</span>
|
||||
<span className="hk-kl-spalte-punkte">Punkte</span>
|
||||
<span>Grund</span>
|
||||
<span className="hk-kl-spalte-telefon">Telefon</span>
|
||||
</div>
|
||||
)}
|
||||
{koerper}
|
||||
{einzeln || laden || fehler || total <= 0 ? null : (
|
||||
<div className="hk-kl-fuss">
|
||||
{mehrere ? (
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={zurueckRef} aria-label="Vorherige Seite" disabled={!info.hasPrev} onClick={() => onPage('prev')}>
|
||||
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="hk-kl-seiten" aria-live="polite">{info.label}</span>
|
||||
{mehrere ? (
|
||||
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={weiterRef} aria-label="Nächste Seite" disabled={!info.hasNext} onClick={() => onPage('next')}>
|
||||
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Gründe der Bot-Lead-Liste (Spec E12): Grund-Text des Dienstes -> Symbol und Erklärung für die Akte des Kellers.
|
||||
// Die Texte sind die festen Grundtexte GRUND des Dienstes (src/scoring/business.js, Stand d0d66f9); Fixture tests/fixtures/botlead-gruende.json.
|
||||
// Symbole und Erklärungen stammen wörtlich aus der GRUENDE-Tabelle der Zeichenfläche (keller/entwurf/logik.js), die Erklärung für „Lauf unterbrochen“ ist neu im
|
||||
// selben Ton. Unbekannter Text (der Dienst bekommt einen neuen Grund) ergibt ein neutrales Symbol und einen allgemeinen Satz, nie einen Fehler.
|
||||
// Kein React, kein DOM: unter Node testbar (loadModule). Die Symbol-Klassen stehen in src/styles/hustler/keller.css (hk-ic-<symbol>).
|
||||
|
||||
const GRENZE_MUSTER = /^Punktzahl unter (\d{1,3})$/
|
||||
|
||||
// Grund-Text -> { symbol, erklaerung }; „Punktzahl unter N“ steht in grundInfo (die Grenze steht fest im Code des Dienstes, MIN_SCORE_MAIL)
|
||||
const TABELLE = Object.freeze({
|
||||
'keine veröffentlichte Mail': Object.freeze({ symbol: 'brief-x', erklaerung: 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.' }),
|
||||
'veraltet (nicht ausgewählt)': Object.freeze({ symbol: 'uhr', erklaerung: 'Die Website ist veraltet, kam aber nicht unter die Top 3 der Nacht.' }),
|
||||
'Website in gutem Zustand': Object.freeze({ symbol: 'haken', erklaerung: 'Die Website ist aktuell genug. Es gibt keinen Anlass zu schreiben.' }),
|
||||
'Kette oder Franchise': Object.freeze({ symbol: 'kette', erklaerung: 'Gehört zu einer Kette; die Website wird zentral gemacht.' }),
|
||||
'schon bekannt (E-Mail oder Telefon)': Object.freeze({ symbol: 'doppelt', erklaerung: 'Steht schon im Nie-doppelt-Register.' }),
|
||||
'schon bekannt (Name und PLZ)': Object.freeze({ symbol: 'doppelt', erklaerung: 'Name und Postleitzahl sind schon im Register.' }),
|
||||
'Impressum passt nicht zum Betrieb': Object.freeze({ symbol: 'fremd', erklaerung: 'Das Impressum gehört zu jemand anderem, zum Beispiel einem Vereins- oder Stadtportal.' }),
|
||||
'Name fehlt': Object.freeze({ symbol: 'etikett-leer', erklaerung: 'Auf der Website war kein brauchbarer Firmenname zu finden.' }),
|
||||
'Adresse nicht zustellbar': Object.freeze({ symbol: 'brief-zurueck', erklaerung: 'Die Mail-Domain hat keinen Mailserver.' }),
|
||||
'Lesen fehlgeschlagen': Object.freeze({ symbol: 'seite-kaputt', erklaerung: 'Die Website ließ sich nicht lesen.' }),
|
||||
Sperrliste: Object.freeze({ symbol: 'stopp', erklaerung: 'Hat widersprochen oder steht auf der Sperrliste.' }),
|
||||
'Bewertung ausgefallen': Object.freeze({ symbol: 'waage-kaputt', erklaerung: 'Bea konnte den Betrieb nicht bewerten.' }),
|
||||
'Lauf unterbrochen': Object.freeze({ symbol: 'pause', erklaerung: 'Der Suchlauf wurde unterbrochen, bevor der Betrieb bewertet war. Er bekommt keine Mail.' }),
|
||||
})
|
||||
|
||||
/** Symbol und Satz für einen Grund, den die Liste nicht kennt. */
|
||||
export const ALLGEMEIN = Object.freeze({ symbol: 'akte', erklaerung: 'Der Hustler schreibt diesem Betrieb nicht. Für diesen Grund gibt es noch keine genauere Erklärung.' })
|
||||
|
||||
/** Alle Symbole, die grundInfo liefern kann (jedes hat eine Klasse hk-ic-<symbol> in keller.css). */
|
||||
export const GRUND_SYMBOLE = Object.freeze([...new Set([...Object.values(TABELLE).map((g) => g.symbol), 'pfeil-runter', ALLGEMEIN.symbol])])
|
||||
|
||||
const schwelleErklaerung = (grenze) => `Zu wenige Punkte für eine Mail (Grenze ${grenze}).`
|
||||
|
||||
/** Ist der Grund-Text einer der Texte des Dienstes? (Die Punktzahl-Grenze zählt mit jeder Zahl.) */
|
||||
export function grundBekannt(reasonText) {
|
||||
const t = typeof reasonText === 'string' ? reasonText.trim() : ''
|
||||
return Object.hasOwn(TABELLE, t) || GRENZE_MUSTER.test(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeige eines Grundes: icon = Klassen des Symbols („hk-ic hk-ic-<symbol>“), erklaerung = ein Satz.
|
||||
* Nimmt den Text so, wie der Dienst ihn liefert (Leerraum am Rand zählt nicht); alles andere ergibt das neutrale Symbol mit dem allgemeinen Satz.
|
||||
*/
|
||||
export function grundInfo(reasonText) {
|
||||
const t = typeof reasonText === 'string' ? reasonText.trim() : ''
|
||||
if (Object.hasOwn(TABELLE, t)) {
|
||||
const g = TABELLE[t]
|
||||
return { icon: `hk-ic hk-ic-${g.symbol}`, erklaerung: g.erklaerung }
|
||||
}
|
||||
const m = GRENZE_MUSTER.exec(t)
|
||||
if (m) return { icon: 'hk-ic hk-ic-pfeil-runter', erklaerung: schwelleErklaerung(m[1]) }
|
||||
return { icon: `hk-ic hk-ic-${ALLGEMEIN.symbol}`, erklaerung: ALLGEMEIN.erklaerung }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Reine Daten und Ableitungen des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Lage der Dinge in der 640 x 360 großen Szene (aus dem Markup von
|
||||
// keller/entwurf/BotLeads.dc.html), Einpassen der Szene („cover“), Arne nach Status, Raum-Reiter, Zeilenmodell der Bot-Leads, Scout je Raum.
|
||||
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Beispieldaten gibt es hier nicht; alle Werte kommen aus dem Dienst.
|
||||
import { ROOM_FILTERS, botleadRow } from './botleadsView.js'
|
||||
import { grundInfo } from './botleadGruende.js'
|
||||
import { figurZustand } from './hustlerSicht.js'
|
||||
import { ROLES } from './workersRoster.js'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Szene
|
||||
|
||||
/** Größe der Szene in Kunstpixeln (keller.png) */
|
||||
export const SZENE = Object.freeze({ breite: 640, hoehe: 360 })
|
||||
|
||||
/**
|
||||
* Szene in den Rahmen einpassen wie die Vorlage (object-fit „cover“, links verankert): s = Maßstab, x/y = Verschiebung in Bildschirmpixeln.
|
||||
* Ohne brauchbare Größe (Rahmen noch nicht gemessen) gilt der Standard der Vorlage: Maßstab 2, keine Verschiebung.
|
||||
*/
|
||||
export function kellerEinpassen(breite, hoehe) {
|
||||
if (!(Number.isFinite(breite) && Number.isFinite(hoehe) && breite > 0 && hoehe > 0)) return { s: 2, x: 0, y: 0 }
|
||||
const s = Math.max(breite / SZENE.breite, hoehe / SZENE.hoehe)
|
||||
const ueber = hoehe - SZENE.hoehe * s
|
||||
return { s, x: Math.min(0, (breite - SZENE.breite * s) * 0.15), y: Math.min(ueber / 2, ueber + 36 * s) }
|
||||
}
|
||||
|
||||
const px = (n) => `${n}px`
|
||||
|
||||
/** Fenster mit Schnee, Passant und Kreuz (Glas 46 x 29 bei 23/34) */
|
||||
export const FENSTER = Object.freeze({ position: 'absolute', left: px(23), top: px(34), width: px(46), height: px(29), overflow: 'hidden' })
|
||||
/** Dampf über der Tasse */
|
||||
export const DAMPF = Object.freeze({ position: 'absolute', left: px(26), top: px(247) })
|
||||
/** Stempelabdruck im Register */
|
||||
export const BUCH_STEMPEL = Object.freeze({ position: 'absolute', left: px(60), top: px(252) })
|
||||
/** Rohrpost (Mündung bei 150/232, Sprite 40 x 48 links oben bei 130/224) */
|
||||
export const ROHRPOST = Object.freeze({ position: 'absolute', left: px(130), top: px(224) })
|
||||
/** Schublade des Aktenschranks (dritte, unterste breite Schublade) */
|
||||
export const SCHUBLADE = Object.freeze({ position: 'absolute', left: px(214), top: px(279) })
|
||||
/** Bodenstreifen, in dem die Maus läuft (Ausschnitt 238 x 24) */
|
||||
export const MAUS_STREIFEN = Object.freeze({ position: 'absolute', left: px(30), top: px(336), width: px(238), height: px(24), overflow: 'hidden' })
|
||||
|
||||
/** Arnes Platz am Pult, wenn er nicht arbeitet: Füße auf der Laufline (318) beim Halt am Pult (128), Sprite 24 x 32 */
|
||||
export const ARNE_RUHE = Object.freeze({ position: 'absolute', left: px(128 - 12), top: px(318 - 32) })
|
||||
|
||||
// Blinkpunkte (Lage, Größe, Aus-Farbe, Animation): Keyframes hk-k-k-led-… stehen in keller.css
|
||||
const led = (name, left, top, breite, aus, dauer) => Object.freeze({
|
||||
name,
|
||||
stil: Object.freeze({ left: px(left), top: px(top), width: px(breite), height: px(2), background: aus, animation: `hk-k-k-led-${name} ${dauer}ms steps(1,end) infinite` }),
|
||||
})
|
||||
export const LEDS = Object.freeze([
|
||||
led('weiche-gruen', 146, 57, 2, '#1f5a3a', 1800),
|
||||
led('weiche-bernstein', 153, 57, 2, '#6a4a1a', 2600),
|
||||
led('sicherung-1', 161, 125, 1, '#1f5a3a', 3000),
|
||||
led('sicherung-2', 163, 125, 1, '#6a4a1a', 2200),
|
||||
led('sicherung-3', 165, 125, 1, '#1f5a3a', 3400),
|
||||
led('verteiler', 203, 33, 2, '#6a2a1a', 2000),
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Arne
|
||||
|
||||
/**
|
||||
* Was die Szene von Arne zeigt (Spec E12): bei „arbeitet“ die Ablauf-Schleife mit Rohrpost, Stempel und Schublade, sonst steht oder sitzt er als Haus-Figur der
|
||||
* passenden Pose (warten, schlafen, nicht) am Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker. workers = Hustler-Tabelle der Seite.
|
||||
*/
|
||||
export function arneAnsicht(workers) {
|
||||
const f = figurZustand('arne', workers && typeof workers === 'object' ? workers.arne : undefined)
|
||||
return {
|
||||
status: f.status,
|
||||
arbeitet: f.status === 'arbeitet',
|
||||
pose: f.pose,
|
||||
alarm: f.alarm,
|
||||
punkte: f.punkte,
|
||||
zzz: f.zzz,
|
||||
label: f.label,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Register-Buch
|
||||
|
||||
const RAUM_KURZ = Object.freeze({ '': 'Alle', aerzte: 'Ärzte', handwerk: 'Handwerk', selbst: 'Selbstständige', laden: 'Läden' })
|
||||
const RAUM_SYMBOL = Object.freeze({ '': 'alle', aerzte: 'raum-aerzte', handwerk: 'raum-handwerk', selbst: 'raum-selbst', laden: 'raum-laden' })
|
||||
|
||||
/** Raum-Reiter in der Reihenfolge von ROOM_FILTERS: value, label (voll, für Screenreader), kurz (sichtbar), icon und klasse (Farbe des Reiters). */
|
||||
export const REITER = Object.freeze(ROOM_FILTERS.map((f) => Object.freeze({
|
||||
value: f.value,
|
||||
label: f.label,
|
||||
kurz: RAUM_KURZ[f.value] ?? f.label,
|
||||
icon: `hk-ic hk-ic-${RAUM_SYMBOL[f.value] ?? 'alle'}`,
|
||||
klasse: `hk-reiter hk-reiter-${f.value === '' ? 'alle' : f.value}`,
|
||||
})))
|
||||
|
||||
/** Symbol-Klassen des Raums einer Zeile; ein unbekannter Raum zeigt den Aktenstapel. */
|
||||
export function raumIcon(room) {
|
||||
const symbol = typeof room === 'string' && Object.hasOwn(RAUM_SYMBOL, room) && room !== '' ? RAUM_SYMBOL[room] : 'alle'
|
||||
return `hk-ic hk-ic-${symbol}`
|
||||
}
|
||||
|
||||
/** Hustler, der den Raum scoutet (Roster: Etage „Ohne Website“, Raum aerzte → Ali, handwerk → Hanna, selbst → Sven, laden → Lea), sonst ''. */
|
||||
export function scoutFuer(room) {
|
||||
const rolle = ROLES.find((r) => r.floor === 'ohne_website' && r.room === room)
|
||||
return rolle ? rolle.name : ''
|
||||
}
|
||||
|
||||
// Punkte-Balken: Farbe nach Punktzahl (ab 75 grün, ab 55 gelb, darunter rot), ohne Punktzahl grau
|
||||
const farbeVon = (punkte) => (punkte === null ? 'leer' : punkte >= 75 ? 'gut' : punkte >= 55 ? 'mittel' : 'schlecht')
|
||||
|
||||
/** Punkte-Anzeige: text (Zahl oder „–“), breite (Füllung in px bei 48 px Balken), breiteGross (96 px, Akte), farbe (gut | mittel | schlecht | leer). */
|
||||
export function punkteInfo(score) {
|
||||
const punkte = Number.isFinite(score) ? Math.round(score) : null
|
||||
const balken = punkte === null ? 0 : Math.min(100, Math.max(0, punkte)) // die Füllung bleibt im Balken, der Text nennt die Zahl des Dienstes
|
||||
return {
|
||||
text: punkte === null ? '–' : String(punkte),
|
||||
breite: Math.round(balken * 0.48),
|
||||
breiteGross: Math.round(balken * 0.96),
|
||||
farbe: farbeVon(punkte),
|
||||
}
|
||||
}
|
||||
|
||||
/** Symbol-Klassen der Telefon-Zeile: Schloss bei Heilberufen („bewusst nicht gespeichert“), Schere bei gekürzten Einträgen, sonst Telefon. */
|
||||
function telefonIcon(item, gekuerzt) {
|
||||
const heil = Boolean(item && typeof item === 'object' && item.isHealth)
|
||||
return `hk-ic hk-ic-${heil ? 'schloss' : gekuerzt ? 'schere' : 'telefon'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles, was eine Zeile des Buchs und die Akte zu einem Eintrag des Dienstes brauchen: das Zeilenmodell aus botleadRow (Telefonregel, sichere Quelle, Datum)
|
||||
* plus Raum-Symbol, Scout, Punkte-Anzeige, Grund mit Symbol und Erklärung und die Beschriftung für Screenreader.
|
||||
*/
|
||||
export function eintragModell(item) {
|
||||
const it = item && typeof item === 'object' ? item : {}
|
||||
const row = botleadRow(it)
|
||||
const raum = typeof it.room === 'string' ? it.room : ''
|
||||
const grund = grundInfo(row.reason)
|
||||
const punkte = punkteInfo(it.score)
|
||||
return {
|
||||
...row,
|
||||
raum,
|
||||
raumIcon: raumIcon(raum),
|
||||
scout: scoutFuer(raum),
|
||||
punkte,
|
||||
grundIcon: grund.icon,
|
||||
erklaerung: grund.erklaerung,
|
||||
telefonIcon: telefonIcon(it, row.gekuerzt),
|
||||
label: [row.title + (row.gekuerzt ? ' (gekürzt)' : ''), row.place, row.scoreLabel, row.reason].filter((t) => t !== '').join(', ') + `, gefunden am ${row.found}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Keller (BotLeadList.jsx und keller/*, Hustler-Pixelwelt T10, Spec E12/E13/E14). Handgeschrieben, nicht erzeugt: die Inline-Stile des Artboards BotLeads.dc.html stehen
|
||||
hier als Klassen. Szene, Reiter, Buch, Zeile, Knöpfe, Akte-Karte und Symbole (hk-sz-*, hk-reiter, hk-buch, hk-zeile, hk-knopf, hk-akte, hk-ic-*) kommen aus dem
|
||||
erzeugten keller.css, hk-alarm aus hustler-ui.css. Alle Klassen beginnen mit hk-kl- (eigene) und stehen unter der Wurzel .hk-keller; Keyframes heißen hk-kl-….
|
||||
Überschreibt eine Regel eine erzeugte, steht ein zweiter Klassenname davor, damit die Reihenfolge der Stylesheets keine Rolle spielt.
|
||||
Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS): keller.css, dann diese Datei. */
|
||||
|
||||
/* ---------- Wurzel: füllt die Seite (neben der WOMS-Seitenleiste), kein Seiten-Scrollen ---------- */
|
||||
.hk-keller { position: relative; width: 100%; height: 100vh; height: 100dvh; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
|
||||
.hk-seite > .hk-keller { position: absolute; inset: 0; width: auto; height: auto; }
|
||||
|
||||
/* ---------- Szene: 640 x 360, per transform eingepasst (cover), dahinter nur Zierde ---------- */
|
||||
.hk-keller .hk-kl-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.hk-keller .hk-kl-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
|
||||
.hk-keller .hk-kl-vignette {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse 80% 75% at 30% 52%, rgba(10, 12, 28, 0) 45%, rgba(10, 12, 28, 0.45) 78%, rgba(6, 7, 18, 0.82) 100%);
|
||||
}
|
||||
.hk-keller .hk-kl-dunkel { display: none; }
|
||||
|
||||
/* Arne, wenn er nicht arbeitet: Haus-Figur am Pult (24 x 32), Pose nach Status; Rohrpost, Stempel und Schublade stehen auf dem Ruhebild */
|
||||
.hk-keller .hk-kl-arne { position: absolute; width: 24px; height: 32px; }
|
||||
.hk-keller .hk-kl-fig { position: absolute; left: 0; top: 0; width: 24px; height: 32px; }
|
||||
.hk-keller .hk-kl-fig-warten { background-image: url(../../assets/hustler/figuren/muetze-warten.png); animation: hk-kl-k24x6 2400ms steps(6) infinite; }
|
||||
.hk-keller .hk-kl-fig-schlafen { background-image: url(../../assets/hustler/figuren/muetze-schlafen.png); animation: hk-kl-k24x4 2000ms steps(4) infinite; }
|
||||
.hk-keller .hk-kl-fig-nicht { background-image: url(../../assets/hustler/figuren/muetze-nicht.png); animation: hk-kl-nicht 3000ms steps(1, end) infinite; }
|
||||
.hk-keller .hk-kl-punkte { position: absolute; left: 14px; top: -6px; width: 12px; height: 8px; background-image: url(../../assets/hustler/figuren/punkte.png); animation: hk-kl-k12x3 1200ms steps(3) infinite; }
|
||||
.hk-keller .hk-kl-zzz { position: absolute; left: 15px; top: -11px; width: 12px; height: 16px; background-image: url(../../assets/hustler/figuren/zzz.png); animation: hk-kl-k12x4 1600ms steps(4) infinite; }
|
||||
.hk-keller .hk-sz-rohrpost.hk-kl-ruht { animation: none; background-position: -360px 0; }
|
||||
.hk-keller .hk-sz-schublade.hk-kl-ruht { animation: none; background-position: 0 0; }
|
||||
.hk-keller .hk-sz-buch-stempel.hk-kl-ruht { animation: none; background-position: 0 0; }
|
||||
@keyframes hk-kl-k24x4 { to { background-position: -96px 0; } }
|
||||
@keyframes hk-kl-k24x6 { to { background-position: -144px 0; } }
|
||||
@keyframes hk-kl-nicht { 0% { background-position: 0 0; } 90% { background-position: -24px 0; } 100% { background-position: -24px 0; } }
|
||||
@keyframes hk-kl-k12x3 { to { background-position: -36px 0; } }
|
||||
@keyframes hk-kl-k12x4 { to { background-position: -48px 0; } }
|
||||
|
||||
/* ---------- Oberfläche: rechts, Buch und Kopfzeile ---------- */
|
||||
.hk-keller .hk-kl-ui { position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px)); display: flex; flex-direction: column; }
|
||||
.hk-keller .hk-kl-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
|
||||
.hk-keller .hk-knopf.hk-kl-knopf-klein { padding: 0 10px 4px 4px; }
|
||||
.hk-keller .hk-knopf.hk-kl-knopf-icon { padding-bottom: 4px; }
|
||||
.hk-keller .hk-knopf.hk-kl-knopf-text { padding: 0 12px 4px; }
|
||||
.hk-keller .hk-knopf:disabled { opacity: 0.45; cursor: default; }
|
||||
.hk-keller .hk-knopf.hk-kl-zuklappen { margin-left: auto; }
|
||||
.hk-keller .hk-kl-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
|
||||
.hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
|
||||
|
||||
/* Raum-Reiter: sitzen oberhalb des Buchs, die inaktiven ragen 8 px hinter das Buch */
|
||||
.hk-keller .hk-kl-reiter { position: relative; z-index: 1; flex: none; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
|
||||
.hk-keller .hk-kl-reiter-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; color: #5a4a3a; }
|
||||
|
||||
/* Buch: Kopf mit Titel, Hinweis und Suche, Kopfzeile, Liste, Fuß mit Seitenzeile */
|
||||
.hk-keller .hk-kl-buch { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hk-keller .hk-kl-buchkopf { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 6px 16px; padding: 0 4px 6px; border-bottom: 2px solid rgba(122, 46, 58, 0.35); }
|
||||
.hk-keller .hk-kl-buchtitelbox { flex: 1 1 220px; min-width: 0; }
|
||||
.hk-keller .hk-kl-buchtitel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #7a2e3a; }
|
||||
.hk-keller .hk-kl-hinweis { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||
.hk-keller .hk-kl-suchfeld { position: relative; display: block; flex: 0 1 250px; min-width: 180px; }
|
||||
.hk-keller .hk-kl-lupe { position: absolute; left: 4px; top: 2px; pointer-events: none; }
|
||||
.hk-keller input.hk-suche { -webkit-appearance: none; appearance: none; border-radius: 0; }
|
||||
.hk-keller .hk-kl-einzeln { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-left: auto; }
|
||||
.hk-keller .hk-kl-einzeln-text { font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||
|
||||
/* Zeile: Name mit Ort und Funddatum, „gekürzt“, Punkte-Balken */
|
||||
.hk-keller .hk-kl-namebox { min-width: 0; }
|
||||
.hk-keller .hk-kl-namezeile { display: flex; align-items: center; gap: 4px; min-width: 0; height: 17px; }
|
||||
.hk-keller .hk-kl-name { font-size: 15px; line-height: 17px; }
|
||||
.hk-keller .hk-kl-tag { flex: none; display: inline-flex; align-items: center; gap: 2px; font-size: 11px; line-height: 14px; color: #6b5f58; }
|
||||
.hk-keller .hk-ic.hk-kl-tag-icon { width: 16px; height: 16px; background-size: auto 16px; }
|
||||
.hk-keller .hk-kl-balken { position: relative; flex: none; width: 48px; height: 10px; background: #e8dcc0; box-shadow: 0 0 0 2px #2b2230; }
|
||||
.hk-keller .hk-kl-balken.hk-kl-balken-gross { width: 96px; height: 12px; background: #f3ecd8; }
|
||||
.hk-keller .hk-kl-balken-fuell { position: absolute; left: 0; top: 0; bottom: 0; }
|
||||
.hk-keller .hk-kl-farbe-gut { background: #3f9a57; }
|
||||
.hk-keller .hk-kl-farbe-mittel { background: #c9962a; }
|
||||
.hk-keller .hk-kl-farbe-schlecht { background: #b8452f; }
|
||||
.hk-keller .hk-kl-farbe-leer { background: #9a8f84; }
|
||||
.hk-keller .hk-kl-punkte-text { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; }
|
||||
|
||||
/* Laden, Leer, Fehler */
|
||||
.hk-keller .hk-kl-laden, .hk-keller .hk-kl-leer, .hk-keller .hk-kl-fehlerbox { margin: 8px; color: #5a4a3a; }
|
||||
.hk-keller .hk-kl-laden { margin-top: 8px; margin-bottom: 0; }
|
||||
.hk-keller .hk-kl-leer-titel { margin: 0; font-size: 16px; line-height: 20px; color: #2b2230; }
|
||||
.hk-keller .hk-kl-leer-text { margin: 4px 0 0; font-size: 13px; line-height: 16px; }
|
||||
.hk-keller .hk-kl-fehler { margin: 0 0 8px; font-weight: 700; color: #b8452f; }
|
||||
|
||||
/* Fuß: ◂ Seitenzeile ▸ */
|
||||
.hk-keller .hk-kl-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 2px; }
|
||||
.hk-keller .hk-kl-seiten { flex: 1 1 auto; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 12px; color: #5a4a3a; text-align: center; }
|
||||
|
||||
/* Nur für Screenreader */
|
||||
.hk-keller .hk-kl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* ---------- Akte: Dialog auf der Manila-Karte ---------- */
|
||||
.hk-keller .hk-kl-akte-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
|
||||
.hk-keller .hk-kl-akte-hinten {
|
||||
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0;
|
||||
background: rgba(6, 7, 18, 0.62); cursor: default;
|
||||
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hk-keller .hk-akte.hk-kl-akte { position: relative; width: min(560px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
|
||||
.hk-keller .hk-kl-akte-inhalt { overflow: auto; min-height: 0; padding: 6px; }
|
||||
.hk-keller .hk-kl-akte-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.hk-keller .hk-kl-akte-titelbox { min-width: 0; }
|
||||
.hk-keller .hk-kl-akte-raum { display: flex; align-items: center; gap: 6px; font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||
.hk-keller .hk-kl-akte-name { margin: 8px 0 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 22px; font-weight: 400; overflow-wrap: anywhere; }
|
||||
.hk-keller .hk-kl-akte-ort { margin-top: 4px; }
|
||||
.hk-keller .hk-kl-akte-dl { margin: 14px 0 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: start; }
|
||||
.hk-keller .hk-kl-akte-dl dt { color: #7a2e3a; }
|
||||
.hk-keller .hk-kl-akte-dl dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.hk-keller .hk-kl-akte-zeile { display: flex; align-items: center; gap: 6px; }
|
||||
.hk-keller .hk-kl-akte-zeile.hk-kl-akte-grund { align-items: flex-start; }
|
||||
.hk-keller .hk-kl-akte-grundtext { padding-top: 6px; }
|
||||
.hk-keller .hk-kl-akte-erklaerung { display: block; margin-top: 4px; font-size: 13px; line-height: 17px; color: #5a4a3a; }
|
||||
.hk-keller .hk-kl-akte-fuss { margin-top: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.hk-keller .hk-kl-akte-hinweis { font-size: 13px; line-height: 16px; color: #5a4a3a; text-align: center; }
|
||||
|
||||
/* ---------- Handy (unter 720 px, Spec E13): Buch in voller Breite über der abgedunkelten Szene, Akte als Vollbild ---------- */
|
||||
@media (max-width: 719px) {
|
||||
.hk-keller .hk-kl-dunkel { display: block; position: absolute; inset: 0; background: rgba(6, 7, 18, 0.62); pointer-events: none; }
|
||||
.hk-keller .hk-kl-ui { top: 8px; right: 8px; bottom: 8px; left: 8px; width: auto; }
|
||||
/* Kopfzeile in einer Reihe: ◂ und Register-Knopf nur als Symbol (die Beschriftung steht im aria-label), das Schild ohne Roboter und kleiner */
|
||||
.hk-keller .hk-kl-kopf { flex-wrap: nowrap; gap: 8px; }
|
||||
.hk-keller .hk-kl-label { display: none; }
|
||||
.hk-keller .hk-knopf.hk-kl-knopf-klein { padding: 0 6px 4px; }
|
||||
.hk-keller .hk-kl-schild { flex: 1 1 auto; min-width: 0; justify-content: center; gap: 8px; padding: 0; }
|
||||
.hk-keller .hk-kl-schild .hk-ic-robo { display: none; }
|
||||
.hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { font-size: 12px; line-height: 12px; }
|
||||
/* Die Reiter wischen seitlich; oben und unten Platz für Rand und die 8 px hinter dem Buch, damit nichts abgeschnitten wird */
|
||||
.hk-keller .hk-kl-reiter { overflow-x: auto; padding: 2px 0 8px 8px; margin-bottom: -8px; scrollbar-width: none; }
|
||||
.hk-keller .hk-kl-reiter::-webkit-scrollbar { display: none; }
|
||||
.hk-keller .hk-kl-reiter .hk-reiter { flex: none; }
|
||||
.hk-keller .hk-kl-buch .hk-zeile, .hk-keller .hk-kl-buch .hk-kopfzeile { grid-template-columns: 32px minmax(0, 1fr) minmax(0, 38%); }
|
||||
.hk-keller .hk-kl-buch .hk-kl-spalte-punkte, .hk-keller .hk-kl-buch .hk-kl-spalte-telefon { display: none; }
|
||||
.hk-keller .hk-kl-akte-name { font-size: 12px; line-height: 18px; }
|
||||
.hk-keller .hk-kl-akte-ueber { padding: 0; }
|
||||
.hk-keller .hk-akte.hk-kl-akte { width: 100%; height: 100%; max-height: none; }
|
||||
}
|
||||
|
||||
/* ---------- weniger Bewegung: Arne am Pult steht still auf dem ersten Bild ---------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hk-keller .hk-kl-fig-warten, .hk-keller .hk-kl-fig-schlafen, .hk-keller .hk-kl-fig-nicht, .hk-keller .hk-kl-punkte, .hk-keller .hk-kl-zzz { animation: none; }
|
||||
}
|
||||
Reference in new issue
Block a user