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

---
 packages/bibliothek-client/src/components/bibliothek-app.ts |  345 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 345 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-client/src/components/bibliothek-app.ts b/packages/bibliothek-client/src/components/bibliothek-app.ts
new file mode 100644
index 0000000..684b966
--- /dev/null
+++ b/packages/bibliothek-client/src/components/bibliothek-app.ts
@@ -0,0 +1,345 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, query, state } from 'lit/decorators.js';
+import type { DhInput, DhSelect } from '@dh-software/dh-components';
+import { EINSTUFUNGEN, type Einstufung, type Software } from 'bibliothek-types';
+import { ladeKatalog, ladeUmgebung, softwareLoeschen, abgleichen, alleAbgleichen, downloadUrl, downloadMehrereUrl } from '../api-client';
+import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme';
+import './bibliothek-zeile';
+import './bibliothek-formular';
+import './bibliothek-einstellungen';
+
+function themeOptionen(aktuell: ThemeModus) {
+  // Frische Kopie je dh-select (die Komponente mutiert die Options-Objekte).
+  return [
+    { dataId: 'system', showContent: 'System', selected: aktuell === 'system' },
+    { dataId: 'hell', showContent: 'Hell', selected: aktuell === 'hell' },
+    { dataId: 'dunkel', showContent: 'Dunkel', selected: aktuell === 'dunkel' },
+  ];
+}
+
+/** Wurzel-Komponente: Katalog als kompakte, durchsuchbare Liste; Verwaltung + Abgleich nur lokal. */
+@customElement('bibliothek-app')
+export class BibliothekApp extends LitElement {
+  @query('#theme-select') private themeSelect?: DhSelect;
+
+  @state() private katalog: Software[] = [];
+  @state() private lokal = false;
+  @state() private ladefehler = '';
+  @state() private formularOffen = false;
+  @state() private einstellungenOffen = false;
+  @state() private zuBearbeiten?: Software;
+  @state() private suchbegriff = '';
+  @state() private beschaeftigt = false;
+  @state() private themeModus: ThemeModus = gemerkterModus();
+  @state() private aktiveEinstufung: Einstufung = 'lizenzfrei';
+  /** Ids der für den Sammel-Download angehakten Einträge — bleibt über Reiter hinweg bestehen. */
+  @state() private auswahl: ReadonlySet<number> = new Set();
+
+  // Light DOM, damit die globalen dh-components-Theme-Regeln greifen.
+  createRenderRoot() {
+    return this;
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    this.datenLaden();
+  }
+
+  protected firstUpdated(): void {
+    if (this.themeSelect) {
+      (this.themeSelect as unknown as { selectOptions: ReturnType<typeof themeOptionen> }).selectOptions = themeOptionen(this.themeModus);
+    }
+  }
+
+  render() {
+    return html`
+      <div class="bibliothek-app-rahmen">
+        <header class="kopf">
+          <h1>Software-Bibliothek</h1>
+          <div class="kopf-aktionen">
+            <dh-select
+              id="theme-select"
+              class="theme-select"
+              withoutEmptyDefault
+              notSearchable
+              @changeSelectOption=${this.beiThemeWechsel}
+            ></dh-select>
+            ${this.lokal && !this.formularOffen && !this.einstellungenOffen
+              ? html`
+                  <dh-button buttonText="Alle prüfen" variant="standard" ?disabled=${this.beschaeftigt} @click=${this.allePruefen}></dh-button>
+                  <dh-button buttonText="+ Neue Software" variant="filled" @click=${this.neuAnlegen}></dh-button>
+                  <dh-button icon-button title="Einstellungen" variant="standard" @click=${this.einstellungenOeffnen}>
+                    <span slot="img" class="fv-icon-settings"></span>
+                  </dh-button>
+                `
+              : nothing}
+          </div>
+        </header>
+        ${this.beschaeftigt ? html`<p class="hinweis">Bezugsquellen werden geprüft …</p>` : nothing}
+        ${this.rendereProblemleiste()}
+        ${this.auswahl.size > 0 && !this.formularOffen
+          ? html`<div class="auswahl-leiste">
+              <dh-button icon-button variant="standard" title="Auswahl aufheben" @click=${this.auswahlAufheben}>
+                <span slot="img" class="fv-icon-close"></span>
+              </dh-button>
+              <span class="auswahl-anzahl">${this.auswahl.size} ausgewählt</span>
+              <dh-button buttonText="Herunterladen" variant="filled" @click=${this.auswahlHerunterladen}></dh-button>
+            </div>`
+          : nothing}
+        ${this.ladefehler ? html`<p class="fehler">${this.ladefehler}</p>` : nothing}
+        ${this.einstellungenOffen
+          ? html`<bibliothek-einstellungen
+              @geschlossen=${this.einstellungenSchliessen}
+              @umzugAbgeschlossen=${this.datenLaden}
+            ></bibliothek-einstellungen>`
+          : this.formularOffen
+          ? html`<bibliothek-formular
+              .software=${this.zuBearbeiten}
+              .moeglicheEltern=${this.hauptEintraege()}
+              @gespeichert=${this.beiGespeichert}
+              @abgebrochen=${this.formularSchliessen}
+            ></bibliothek-formular>`
+          : this.rendereKatalog()}
+      </div>
+    `;
+  }
+
+  /**
+   * Weist auf Einträge hin, deren letzter Abgleich fehlgeschlagen ist — sonst
+   * fällt ein toter Bezugslink nur auf, wenn jemand gezielt in die Liste schaut.
+   */
+  private rendereProblemleiste() {
+    const betroffene = this.katalog.filter((software) => software.letzterAbgleich && !software.abgleichErfolg);
+    if (betroffene.length === 0) {
+      return nothing;
+    }
+    const namen = betroffene.slice(0, 3).map((software) => software.name).join(', ');
+    const weitere = betroffene.length > 3 ? ` und ${betroffene.length - 3} weitere` : '';
+    // Der Hinweis auf den erhaltenen Stand ergibt nur Sinn, wenn es überhaupt einen gibt.
+    const mitBisherigerDatei = betroffene.some((software) => software.dateiname);
+    return html`<p class="warnung">
+      <strong>${betroffene.length} Eintrag${betroffene.length === 1 ? '' : 'e'} mit Problemen beim Abgleich:</strong>
+      ${namen}${weitere}.${mitBisherigerDatei ? ' Die zuletzt bereitgestellte Datei bleibt dabei erhalten.' : ''}
+    </p>`;
+  }
+
+  private rendereKatalog() {
+    if (this.katalog.length === 0) {
+      return html`<p>Noch keine Software im Katalog.</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.eintraegeMitEinstufung(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.gefilterterKatalog();
+    if (gefiltert.length === 0) {
+      return html`<p class="leer">
+        ${this.suchbegriff ? html`Keine Treffer für „${this.suchbegriff}".` : 'In dieser Einstufung ist noch nichts hinterlegt.'}
+      </p>`;
+    }
+    return html`
+      ${this.aktiveEinstufung === 'lizenzpflichtig'
+        ? html`<p class="hinweis">Für diese Programme kommt der Lizenzschlüssel über den Support.</p>`
+        : nothing}
+      <div class="liste ${this.lokal ? 'verwaltung' : ''}">
+      ${gefiltert.map(
+        (software) => html`<bibliothek-zeile
+          .software=${software}
+          .zusatzinhalte=${this.zusatzinhalteVon(software.id)}
+          .auswahl=${this.auswahl}
+          ?gewaehlt=${this.auswahl.has(software.id)}
+          ?lokal=${this.lokal}
+          @auswahlGeaendert=${this.beiAuswahlGeaendert}
+          @herunterladen=${this.beiHerunterladen}
+          @pruefen=${this.beiPruefen}
+          @bearbeiten=${this.beiBearbeiten}
+          @loeschen=${this.beiLoeschen}
+        ></bibliothek-zeile>`
+      )}
+    </div>`;
+  }
+
+  private beiAuswahlGeaendert(ereignis: CustomEvent<{ software: Software; gewaehlt: boolean }>): void {
+    const { software, gewaehlt } = ereignis.detail;
+    const neueAuswahl = new Set(this.auswahl);
+    if (gewaehlt) {
+      neueAuswahl.add(software.id);
+    } else {
+      neueAuswahl.delete(software.id);
+    }
+    this.auswahl = neueAuswahl;
+  }
+
+  private auswahlAufheben(): void {
+    this.auswahl = new Set();
+  }
+
+  private auswahlHerunterladen(): void {
+    const angegebenerName = this.namenErfragen();
+    if (angegebenerName === null) {
+      return;
+    }
+    window.location.href = downloadMehrereUrl([...this.auswahl], angegebenerName);
+  }
+
+  /** Fragt im Netzwerk-Betrieb nach dem Namen fürs Protokoll; lokal nicht nötig. */
+  private namenErfragen(): string | undefined | null {
+    if (this.lokal) {
+      return undefined;
+    }
+    return prompt('Bitte deinen Namen angeben (wird protokolliert):');
+  }
+
+  private beiReiterWechsel(ereignis: CustomEvent<string>): void {
+    this.aktiveEinstufung = ereignis.detail as Einstufung;
+  }
+
+  /** Eigenständige Tools einer Einstufung (Grundlage für die Zähler an den Reitern). */
+  private eintraegeMitEinstufung(einstufung: Einstufung): Software[] {
+    return this.hauptEintraege().filter((software) => software.einstufung === einstufung);
+  }
+
+  /** Eigenständige Tools (ohne die Zusatzinhalte, die unter ihrem Tool erscheinen). */
+  private hauptEintraege(): Software[] {
+    return this.katalog.filter((software) => !software.elternId);
+  }
+
+  private zusatzinhalteVon(elternId: number): Software[] {
+    return this.katalog.filter((software) => software.elternId === elternId);
+  }
+
+  /** Einträge des aktiven Reiters, zusätzlich nach dem Suchbegriff gefiltert. */
+  private gefilterterKatalog(): Software[] {
+    const eintraege = this.eintraegeMitEinstufung(this.aktiveEinstufung);
+    const begriff = this.suchbegriff.trim().toLowerCase();
+    if (!begriff) {
+      return eintraege;
+    }
+    return eintraege.filter(
+      (software) => software.name.toLowerCase().includes(begriff) || software.beschreibung.toLowerCase().includes(begriff)
+    );
+  }
+
+  private beiSuche(ereignis: Event): void {
+    this.suchbegriff = (ereignis.target as DhInput).value;
+  }
+
+  private beiThemeWechsel(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    this.themeModus = ereignis.detail.selectedOption.dataId as ThemeModus;
+    setzeTheme(this.themeModus);
+  }
+
+  public async datenLaden(): Promise<void> {
+    try {
+      const [umgebung, katalog] = await Promise.all([ladeUmgebung(), ladeKatalog()]);
+      this.lokal = umgebung.lokal;
+      this.katalog = katalog;
+      this.ladefehler = '';
+    } catch (fehler) {
+      this.ladefehler = fehler instanceof Error ? fehler.message : 'Daten konnten nicht geladen werden.';
+    }
+  }
+
+  private neuAnlegen(): void {
+    this.zuBearbeiten = undefined;
+    this.formularOffen = true;
+  }
+
+  private einstellungenOeffnen(): void {
+    this.einstellungenOffen = true;
+  }
+
+  private einstellungenSchliessen(): void {
+    this.einstellungenOffen = false;
+  }
+
+  private beiBearbeiten(ereignis: CustomEvent<Software>): void {
+    this.zuBearbeiten = ereignis.detail;
+    this.formularOffen = true;
+  }
+
+  private formularSchliessen(): void {
+    this.formularOffen = false;
+    this.zuBearbeiten = undefined;
+  }
+
+  private beiGespeichert(): void {
+    this.formularSchliessen();
+    this.datenLaden();
+  }
+
+  private beiHerunterladen(ereignis: CustomEvent<Software>): void {
+    const angegebenerName = this.namenErfragen();
+    if (angegebenerName === null) {
+      return;
+    }
+    window.location.href = downloadUrl(ereignis.detail.id, angegebenerName);
+  }
+
+  private async beiPruefen(ereignis: CustomEvent<Software>): Promise<void> {
+    await this.mitBeschaeftigt(() => abgleichen(ereignis.detail.id));
+    this.datenLaden();
+  }
+
+  private async allePruefen(): Promise<void> {
+    await this.mitBeschaeftigt(() => alleAbgleichen());
+    this.datenLaden();
+  }
+
+  private async beiLoeschen(ereignis: CustomEvent<Software>): Promise<void> {
+    const software = ereignis.detail;
+    const anzahlZusatzinhalte = this.zusatzinhalteVon(software.id).length;
+    const zusatz = anzahlZusatzinhalte > 0 ? ` Die ${anzahlZusatzinhalte} zugehörigen Zusatzinhalte werden mitgelöscht.` : '';
+    if (!confirm(`„${software.name}" wirklich löschen?${zusatz}`)) {
+      return;
+    }
+    try {
+      await softwareLoeschen(software.id);
+      this.datenLaden();
+    } catch (fehler) {
+      this.ladefehler = fehler instanceof Error ? fehler.message : 'Löschen fehlgeschlagen.';
+    }
+  }
+
+  private async mitBeschaeftigt(aktion: () => Promise<unknown>): Promise<void> {
+    this.beschaeftigt = true;
+    this.ladefehler = '';
+    try {
+      await aktion();
+    } catch (fehler) {
+      this.ladefehler = fehler instanceof Error ? fehler.message : 'Abgleich fehlgeschlagen.';
+    } finally {
+      this.beschaeftigt = false;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'bibliothek-app': BibliothekApp;
+  }
+}

--
Gitblit v1.9.3