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;
|
}
|
}
|