From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally
---
desktop/frontend/src/components/client-app.ts | 284 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 284 insertions(+), 0 deletions(-)
diff --git a/desktop/frontend/src/components/client-app.ts b/desktop/frontend/src/components/client-app.ts
new file mode 100644
index 0000000..f8bab1c
--- /dev/null
+++ b/desktop/frontend/src/components/client-app.ts
@@ -0,0 +1,284 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { EINSTUFUNGEN, type Einstufung } from 'bibliothek-types';
+import { bruecke, type EintragMitBefund, type Einstellungen } from '../bruecke';
+import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme';
+import './client-zeile';
+
+interface Auswahloption {
+ dataId: string;
+ showContent: string;
+ selected?: boolean;
+}
+
+const THEME_OPTIONEN: Array<{ id: ThemeModus; text: string }> = [
+ { id: 'system', text: 'System' },
+ { id: 'hell', text: 'Hell' },
+ { id: 'dunkel', text: 'Dunkel' },
+];
+
+/** Wurzel-Komponente des Clients. */
+@customElement('client-app')
+export class ClientApp extends LitElement {
+ @state() private eintraege: EintragMitBefund[] = [];
+ @state() private aktiveEinstufung: Einstufung = 'lizenzfrei';
+ @state() private suchbegriff = '';
+ @state() private ladefehler = '';
+ @state() private meldung = '';
+ @state() private laeuftFuer = 0;
+ @state() private einstellungenOffen = false;
+ @state() private einstellungen?: Einstellungen;
+ @state() private serverAdresse = '';
+ @state() private werkzeugOrdner = '';
+ @state() private themeOptionen: Auswahloption[] = [];
+
+ // Light DOM, damit das globale dh-components-Theme greift.
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ const modus = gemerkterModus();
+ this.themeOptionen = THEME_OPTIONEN.map((eintrag) => ({
+ dataId: eintrag.id,
+ showContent: eintrag.text,
+ selected: eintrag.id === modus,
+ }));
+ void this.alesLaden();
+ }
+
+ render() {
+ return html`
+ <div class="rahmen">
+ <header class="kopf">
+ <h1>Software-Bibliothek</h1>
+ <div class="kopf-aktionen">
+ <dh-select
+ class="theme-select"
+ withoutEmptyDefault
+ notSearchable
+ .selectOptions=${this.themeOptionen}
+ @changeSelectOption=${this.beiTheme}
+ ></dh-select>
+ <dh-button icon-button variant="standard" title="Werkzeug-Ordner öffnen" @click=${this.beiOrdnerOeffnen}>
+ <span slot="img" class="fv-icon-folder"></span>
+ </dh-button>
+ <dh-button icon-button variant="standard" title="Einstellungen" @click=${this.beiEinstellungenOeffnen}>
+ <span slot="img" class="fv-icon-settings"></span>
+ </dh-button>
+ </div>
+ </header>
+
+ ${this.ladefehler ? html`<p class="warnung">${this.ladefehler}</p>` : nothing}
+ ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing}
+ ${this.rendereUpdateHinweis()}
+ ${this.einstellungenOffen ? this.rendereEinstellungen() : this.rendereKatalog()}
+ </div>
+ `;
+ }
+
+ // --- Teilansichten ---
+
+ /** Der Grund, warum es diesen Client gibt: ungefragt sagen, was veraltet ist. */
+ private rendereUpdateHinweis() {
+ const veraltet = this.eintraege.filter(
+ (eintrag) => eintrag.ueberwacht && (eintrag.lokal?.zustand === 'veraltet' || eintrag.lokal?.zustand === 'fremd')
+ );
+ if (veraltet.length === 0) {
+ return nothing;
+ }
+ return html`<p class="update-hinweis">
+ ${veraltet.length === 1 ? 'Ein Werkzeug ist nicht auf dem neuesten Stand' : `${veraltet.length} Werkzeuge sind nicht auf dem neuesten Stand`}:
+ ${veraltet.map((eintrag) => eintrag.name).join(', ')}.
+ </p>`;
+ }
+
+ private rendereKatalog() {
+ if (this.eintraege.length === 0) {
+ return html`<p class="leer">${this.ladefehler ? '' : 'Katalog wird geladen …'}</p>`;
+ }
+ return html`
+ <dh-input class="suche" type="text" placeholder="Suchen" .value=${this.suchbegriff} @input=${this.beiSuche}></dh-input>
+ <dh-tab stretch @clickTab=${this.beiReiterWechsel}>
+ ${EINSTUFUNGEN.map(
+ (eintrag) => html`<div
+ slot="tab-head"
+ content=${eintrag.id}
+ class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''}
+ >
+ ${eintrag.text} (${this.mitEinstufung(eintrag.id).length})
+ </div>`
+ )}
+ ${EINSTUFUNGEN.map(
+ (eintrag) => html`<div
+ slot="tab-content"
+ tab=${eintrag.id}
+ class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'}
+ >
+ ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing}
+ </div>`
+ )}
+ </dh-tab>
+ `;
+ }
+
+ private rendereListe() {
+ const gefiltert = this.gefiltert();
+ if (gefiltert.length === 0) {
+ return html`<p class="leer">
+ ${this.suchbegriff ? html`Keine Treffer für „${this.suchbegriff}".` : 'In dieser Einstufung ist nichts hinterlegt.'}
+ </p>`;
+ }
+ return html`<div class="liste">
+ ${gefiltert.map(
+ (eintrag) => html`<client-zeile
+ .eintrag=${eintrag}
+ ?laeuft=${this.laeuftFuer === eintrag.id}
+ @holen=${this.beiHolen}
+ @verknuepfen=${this.beiVerknuepfen}
+ ></client-zeile>`
+ )}
+ </div>`;
+ }
+
+ private rendereEinstellungen() {
+ return html`
+ <dh-card variant="outlined" no-hover-effect no-click-effect>
+ <span slot="header">Einstellungen</span>
+ <dh-form-grid slot="content">
+ <dh-form-field label="Adresse der Bibliothek">
+ <dh-input type="text" .value=${this.serverAdresse} @input=${this.beiServerAdresse}></dh-input>
+ <span class="feldhinweis">
+ Rechnername statt IP-Adresse ist die bessere Wahl — er überlebt einen Wechsel der Adresse.
+ </span>
+ </dh-form-field>
+ <dh-form-field label="Werkzeug-Ordner">
+ <dh-input type="text" .value=${this.werkzeugOrdner} @input=${this.beiWerkzeugOrdner}></dh-input>
+ <span class="feldhinweis">
+ Hierhin kommen eigenständige Programme; Installationsprogramme in den Unterordner
+ „Installationsprogramme".
+ </span>
+ </dh-form-field>
+ </dh-form-grid>
+ <div slot="actions">
+ <dh-button buttonText="Speichern" variant="filled" @click=${this.beiEinstellungenSpeichern}></dh-button>
+ <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiEinstellungenSchliessen}></dh-button>
+ </div>
+ </dh-card>
+ `;
+ }
+
+ // --- Daten ---
+
+ private async alesLaden(): Promise<void> {
+ const api = await bruecke();
+ const stand = await api.einstellungen();
+ this.einstellungen = stand.einstellungen;
+ this.serverAdresse = stand.einstellungen.serverAdresse;
+ this.werkzeugOrdner = stand.einstellungen.werkzeugOrdner;
+ await this.katalogLaden();
+ }
+
+ private async katalogLaden(): Promise<void> {
+ const api = await bruecke();
+ const antwort = await api.katalog();
+ if (!antwort.ok) {
+ this.ladefehler = `${antwort.meldung} — Adresse in den Einstellungen prüfen.`;
+ this.eintraege = [];
+ return;
+ }
+ this.ladefehler = '';
+ this.eintraege = antwort.eintraege;
+ }
+
+ private mitEinstufung(einstufung: Einstufung): EintragMitBefund[] {
+ return this.eintraege.filter((eintrag) => eintrag.einstufung === einstufung && !eintrag.elternId);
+ }
+
+ private gefiltert(): EintragMitBefund[] {
+ const suche = this.suchbegriff.trim().toLowerCase();
+ const inDerEinstufung = this.mitEinstufung(this.aktiveEinstufung);
+ if (!suche) {
+ return inDerEinstufung;
+ }
+ return inDerEinstufung.filter(
+ (eintrag) =>
+ eintrag.name.toLowerCase().includes(suche) || eintrag.beschreibung.toLowerCase().includes(suche)
+ );
+ }
+
+ // --- Ereignisse ---
+
+ private beiTheme(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+ setzeTheme(ereignis.detail.selectedOption.dataId as ThemeModus);
+ }
+
+ private beiSuche(ereignis: CustomEvent<string>): void {
+ this.suchbegriff = ereignis.detail;
+ }
+
+ /** dh-tab liefert die Kennung des Reiters unmittelbar als `detail`. */
+ private beiReiterWechsel(ereignis: CustomEvent<string>): void {
+ this.aktiveEinstufung = ereignis.detail as Einstufung;
+ }
+
+ private async beiHolen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
+ const eintrag = ereignis.detail;
+ this.laeuftFuer = eintrag.id;
+ this.meldung = `${eintrag.name} wird geholt …`;
+ const api = await bruecke();
+ const ergebnis = await api.herunterladen(eintrag.id, eintrag.dateiname, eintrag.version, eintrag.programmart);
+ this.meldung = ergebnis.meldung;
+ this.laeuftFuer = 0;
+ await this.katalogLaden();
+ }
+
+ private async beiVerknuepfen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
+ const eintrag = ereignis.detail;
+ if (!eintrag.lokal?.pfad) {
+ return;
+ }
+ const api = await bruecke();
+ const ergebnis = await api.verknuepfung_anlegen(eintrag.lokal.pfad, eintrag.name);
+ this.meldung = ergebnis.meldung ?? '';
+ }
+
+ private async beiOrdnerOeffnen(): Promise<void> {
+ const api = await bruecke();
+ await api.ordner_oeffnen();
+ }
+
+ private beiEinstellungenOeffnen(): void {
+ this.serverAdresse = this.einstellungen?.serverAdresse ?? '';
+ this.werkzeugOrdner = this.einstellungen?.werkzeugOrdner ?? '';
+ this.einstellungenOffen = true;
+ }
+
+ private beiEinstellungenSchliessen(): void {
+ this.einstellungenOffen = false;
+ }
+
+ private beiServerAdresse(ereignis: CustomEvent<string>): void {
+ this.serverAdresse = ereignis.detail;
+ }
+
+ private beiWerkzeugOrdner(ereignis: CustomEvent<string>): void {
+ this.werkzeugOrdner = ereignis.detail;
+ }
+
+ private async beiEinstellungenSpeichern(): Promise<void> {
+ const api = await bruecke();
+ const ergebnis = await api.einstellungen_speichern(this.serverAdresse, this.werkzeugOrdner);
+ this.einstellungen = ergebnis.einstellungen;
+ this.einstellungenOffen = false;
+ this.meldung = 'Einstellungen gespeichert.';
+ await this.katalogLaden();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'client-app': ClientApp;
+ }
+}
--
Gitblit v1.9.3