Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
50 lines
1.4 KiB
TypeScript
50 lines
1.4 KiB
TypeScript
export interface Tab<Id extends string> {
|
||
id: Id
|
||
label: string
|
||
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
|
||
badge?: number
|
||
}
|
||
|
||
interface Props<Id extends string> {
|
||
tabs: Tab<Id>[]
|
||
active: Id
|
||
onChange: (id: Id) => void
|
||
}
|
||
|
||
/** Kennung des Knopfes zu einem Tab – die Panels verweisen mit aria-labelledby darauf. */
|
||
export function tabId(id: string): string {
|
||
return `tab-${id}`
|
||
}
|
||
|
||
/** Kennung des Panels zu einem Tab. */
|
||
export function panelId(id: string): string {
|
||
return `panel-${id}`
|
||
}
|
||
|
||
/** Tab-Leiste. Native Knöpfe, damit die Tastaturbedienung von selbst funktioniert. */
|
||
export default function Tabs<Id extends string>({ tabs, active, onChange }: Props<Id>) {
|
||
return (
|
||
<nav className="tabs" role="tablist" aria-label="Bereiche">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
id={tabId(tab.id)}
|
||
type="button"
|
||
role="tab"
|
||
className={`tabs__button${tab.id === active ? ' tabs__button--active' : ''}`}
|
||
aria-selected={tab.id === active}
|
||
aria-controls={panelId(tab.id)}
|
||
onClick={() => onChange(tab.id)}
|
||
>
|
||
{tab.label}
|
||
{tab.badge ? (
|
||
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
|
||
{tab.badge > 99 ? '99+' : tab.badge}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
)
|
||
}
|