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