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