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-zeile.ts |  235 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 235 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-client/src/components/bibliothek-zeile.ts b/packages/bibliothek-client/src/components/bibliothek-zeile.ts
new file mode 100644
index 0000000..5368285
--- /dev/null
+++ b/packages/bibliothek-client/src/components/bibliothek-zeile.ts
@@ -0,0 +1,235 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { event, type EventRef } from '@dh-software/lit-extensions';
+import type { Software } from 'bibliothek-types';
+
+/**
+ * Eine kompakte Katalog-Zeile für einen Software-Eintrag. Feuert Aktions-Events an die App.
+ * Die Aktionen liegen in festen Spalten, damit sie über alle Zeilen hinweg untereinander stehen;
+ * fehlt eine Aktion, bleibt ihre Spalte leer.
+ *
+ * Zusatzinhalte (Plug-ins, Sprachpakete, Vorlagen …) stehen eingerückt direkt unter ihrem Tool
+ * und bilden mit ihm eine Gruppe.
+ */
+@customElement('bibliothek-zeile')
+export class BibliothekZeile extends LitElement {
+  @property({ attribute: false }) software!: Software;
+  /** Weitere Einträge, die zu dieser Software gehören. */
+  @property({ attribute: false }) zusatzinhalte: Software[] = [];
+  @property({ type: Boolean }) lokal = false;
+  /** Zeile eines Zusatzinhalts (eingerückt unter seinem Tool). */
+  @property({ type: Boolean }) eingerueckt = false;
+  /** Ist dieser Eintrag für den Sammel-Download angehakt? */
+  @property({ type: Boolean }) gewaehlt = false;
+  /** Ids aller angehakten Einträge — nötig, damit auch eingerückte Zeilen ihren Zustand kennen. */
+  @property({ attribute: false }) auswahl: ReadonlySet<number> = new Set();
+
+  @event({ bubbles: true, composed: true }, 'auswahlGeaendert')
+  private auswahlGeaendert!: EventRef<{ software: Software; gewaehlt: boolean }>;
+
+  @event({ bubbles: true, composed: true }, 'herunterladen')
+  private herunterladen!: EventRef<Software>;
+
+  @event({ bubbles: true, composed: true }, 'pruefen')
+  private pruefen!: EventRef<Software>;
+
+  @event({ bubbles: true, composed: true }, 'bearbeiten')
+  private bearbeiten!: EventRef<Software>;
+
+  @event({ bubbles: true, composed: true }, 'loeschen')
+  private loeschen!: EventRef<Software>;
+
+  // Light DOM, damit das globale dh-components-Theme greift.
+  createRenderRoot() {
+    return this;
+  }
+
+  render() {
+    const software = this.software;
+    return html`
+      <div class="zeile ${this.eingerueckt ? 'zeile-eingerueckt' : ''}">
+        <span class="zeile-auswahl">
+          <dh-checkbox
+            ?checked=${this.gewaehlt}
+            ?disabled=${!software.dateiname}
+            title=${software.dateiname ? 'Für Sammel-Download auswählen' : 'Keine Datei vorhanden'}
+            @isChange=${this.beiAuswahl}
+          ></dh-checkbox>
+        </span>
+        <div class="zeile-info">
+          <span class="zeile-name">
+            ${software.name}
+            ${software.einstufung === 'lizenzpflichtig'
+              ? html`<span class="lizenz-marke" title="Lizenzschlüssel über den Support beziehen">lizenzpflichtig</span>`
+              : nothing}
+            ${software.einstufung === 'intern' ? html`<span class="intern-marke">intern</span>` : nothing}
+          </span>
+          <span class="zeile-meta">
+            ${this.rendereStand()}
+            <span class="${this.statusKlasse()}">${this.statusText()}</span>
+            ${this.rendereSignatur()}
+          </span>
+          ${this.rendereHinweise()}
+        </div>
+        <div class="zeile-aktionen">
+          <span class="aktion">
+            ${software.anleitungUrl
+              ? html`<a class="anleitung" href=${software.anleitungUrl} target="_blank" rel="noopener noreferrer">Anleitung</a>`
+              : nothing}
+          </span>
+          <span class="aktion">
+            ${software.dateiname
+              ? html`<dh-button
+                  icon-button
+                  variant="filled"
+                  title=${`${software.name} herunterladen`}
+                  @click=${this.beiHerunterladen}
+                >
+                  <span slot="img" class="fv-icon-download"></span>
+                </dh-button>`
+              : nothing}
+          </span>
+          ${this.lokal
+            ? html`
+                <span class="aktion">
+                  ${software.quelleTyp && software.quelleTyp !== 'manuell'
+                    ? html`<dh-button buttonText="Prüfen" variant="standard" @click=${this.beiPruefen}></dh-button>`
+                    : nothing}
+                </span>
+                <span class="aktion">
+                  <dh-button buttonText="Bearbeiten" variant="standard" @click=${this.beiBearbeiten}></dh-button>
+                </span>
+                <span class="aktion">
+                  <dh-button buttonText="Löschen" variant="standard" @click=${this.beiLoeschen}></dh-button>
+                </span>
+              `
+            : nothing}
+        </div>
+      </div>
+      ${this.zusatzinhalte.map(
+        (zusatzinhalt) => html`<bibliothek-zeile
+          .software=${zusatzinhalt}
+          .auswahl=${this.auswahl}
+          ?gewaehlt=${this.auswahl.has(zusatzinhalt.id)}
+          ?lokal=${this.lokal}
+          eingerueckt
+        ></bibliothek-zeile>`
+      )}
+    `;
+  }
+
+  private beiAuswahl(ereignis: CustomEvent<boolean>): void {
+    this.auswahlGeaendert.dispatch({ software: this.software, gewaehlt: ereignis.detail });
+  }
+
+  /**
+   * Hinweise zum Umgang mit der Datei: was aus der Programmart folgt, plus der
+   * freie Hinweis am Eintrag. Die eigene Zeile kostet nur dort Höhe, wo wirklich
+   * etwas zu sagen ist — die meisten Einträge bleiben einzeilig.
+   */
+  private rendereHinweise() {
+    const software = this.software;
+    const teile: string[] = [];
+    if (software.dateiname && software.programmart === 'installer') {
+      teile.push('Datei kann nach der Installation gelöscht werden');
+    }
+    if (software.dateiname && software.programmart === 'eigenstaendig') {
+      teile.push('Läuft direkt — an einen festen Ort legen');
+    }
+    if (software.hinweis) {
+      teile.push(software.hinweis);
+    }
+    return teile.length > 0 ? html`<span class="zeile-hinweis">${teile.join(' · ')}</span>` : nothing;
+  }
+
+  /**
+   * Signaturbefund der bereitgestellten Datei — nur in der Verwaltung.
+   * Für die Netzwerk-Ansicht ist das Betriebswissen ohne Nutzen: Wer etwas
+   * herunterlädt, kann daraus keine Entscheidung ableiten.
+   */
+  private rendereSignatur() {
+    const software = this.software;
+    if (!this.lokal || !software.dateiname || !software.signaturStatus) {
+      return nothing;
+    }
+    if (software.signaturStatus === 'Valid') {
+      return html` &nbsp;·&nbsp;
+        <span class="signatur-gut" title="Gültig signiert von ${software.signaturHerausgeber}">
+          signiert: ${software.signaturHerausgeber}
+        </span>`;
+    }
+    const text = software.signaturStatus === 'NotSigned' ? 'unsigniert' : `Signatur unklar (${software.signaturStatus})`;
+    return html` &nbsp;·&nbsp; <span class="signatur-unklar" title="Windows-Signaturstatus: ${software.signaturStatus}">${text}</span>`;
+  }
+
+  /**
+   * Zeigt bevorzugt das Veröffentlichungsdatum des Anbieters — das ist die Angabe,
+   * die den Nutzer interessiert. Nur wenn die Quelle keins liefert, wird ersatzweise
+   * unser Abrufzeitpunkt genannt (und als solcher benannt).
+   */
+  private rendereStand() {
+    const software = this.software;
+    if (software.veroeffentlichtAm) {
+      return html`Stand ${this.alsDatum(software.veroeffentlichtAm)} &nbsp;·&nbsp; `;
+    }
+    if (software.dateiStandVom) {
+      return html`geholt ${this.alsDatum(software.dateiStandVom)} &nbsp;·&nbsp; `;
+    }
+    return nothing;
+  }
+
+  private alsDatum(zeitstempel: string): string {
+    const zeitpunkt = new Date(zeitstempel);
+    return Number.isNaN(zeitpunkt.getTime()) ? '—' : zeitpunkt.toLocaleDateString('de-DE');
+  }
+
+  private statusText(): string {
+    const software = this.software;
+    if (software.dateiname) {
+      return software.dateiname;
+    }
+    if (!software.quelleTyp) {
+      return 'keine Quelle';
+    }
+    if (software.quelleTyp === 'manuell') {
+      return 'keine Datei hinterlegt';
+    }
+    if (software.letzterAbgleich && !software.abgleichErfolg) {
+      return software.abgleichMeldung || 'Abgleich fehlgeschlagen';
+    }
+    return 'Quelle hinterlegt, noch nicht geladen';
+  }
+
+  private statusKlasse(): string {
+    const software = this.software;
+    if (software.dateiname) {
+      return 'status-ok';
+    }
+    if (!software.quelleTyp || software.quelleTyp === 'manuell' || (software.letzterAbgleich && !software.abgleichErfolg)) {
+      return 'status-fehler';
+    }
+    return 'status-ausstehend';
+  }
+
+  private beiHerunterladen(): void {
+    this.herunterladen.dispatch(this.software);
+  }
+
+  private beiPruefen(): void {
+    this.pruefen.dispatch(this.software);
+  }
+
+  private beiBearbeiten(): void {
+    this.bearbeiten.dispatch(this.software);
+  }
+
+  private beiLoeschen(): void {
+    this.loeschen.dispatch(this.software);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'bibliothek-zeile': BibliothekZeile;
+  }
+}

--
Gitblit v1.9.3