diff --git a/angular.json b/angular.json index 8193d63..63e0738 100644 --- a/angular.json +++ b/angular.json @@ -38,7 +38,12 @@ "src/favicon-32x32.png", "src/mstile-150x150.png", "src/assets", - "src/manifest.webmanifest" + "src/manifest.webmanifest", + { + "glob": "**/*", + "input": "man", + "output": "man" + } ], "styles": [ "src/custom-theme.scss", diff --git a/man/index.md b/man/index.md index f7863f2..ccf50e5 100644 --- a/man/index.md +++ b/man/index.md @@ -2,8 +2,6 @@ Willkommen in der Benutzeranleitung für den Worshipgenerator. Diese Anleitung erklärt die Anwendung aus Anwendersicht: welche Seiten es gibt, welche Aufgaben dort erledigt werden und welche Berechtigungen dafür nötig sind. -Die Anleitung beschreibt keine technischen Hintergründe. Wenn du wissen möchtest, wie die Anwendung intern aufgebaut ist, nutze stattdessen die [Projektdokumentation](../docs/index.md). - ## Schnellstart - [Erste Schritte](tutorials/erste-schritte.md) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b86fedf..3baad71 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -50,6 +50,10 @@ const routes: Routes = [ path: 'guest', loadChildren: () => import('./modules/guest/guest.module').then(m => m.GuestModule), }, + { + path: 'help', + loadChildren: () => import('./modules/help/help.module').then(m => m.HelpModule), + }, ]; @NgModule({ diff --git a/src/app/modules/help/help-routing.module.ts b/src/app/modules/help/help-routing.module.ts new file mode 100644 index 0000000..2de59b5 --- /dev/null +++ b/src/app/modules/help/help-routing.module.ts @@ -0,0 +1,19 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; + +import {HelpComponent} from './help/help'; + +const routes: Routes = [ + { + path: '', + component: HelpComponent, + pathMatch: 'full', + + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class HelpRoutingModule {} diff --git a/src/app/modules/help/help.module.ts b/src/app/modules/help/help.module.ts new file mode 100644 index 0000000..f4a3812 --- /dev/null +++ b/src/app/modules/help/help.module.ts @@ -0,0 +1,10 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {HelpRoutingModule} from './help-routing.module'; + + +@NgModule({ + declarations: [], + imports: [CommonModule, HelpRoutingModule], +}) +export class HelpModule {} diff --git a/src/app/modules/help/help/help.html b/src/app/modules/help/help/help.html new file mode 100644 index 0000000..ca6ed4c --- /dev/null +++ b/src/app/modules/help/help/help.html @@ -0,0 +1,18 @@ + +
+ +
+ + + + + + + + + + + +
+
+
diff --git a/src/app/modules/help/help/help.less b/src/app/modules/help/help/help.less new file mode 100644 index 0000000..1ef135f --- /dev/null +++ b/src/app/modules/help/help/help.less @@ -0,0 +1,109 @@ +:host { + --help-link-radius: 4px; + --help-code-radius: 4px; + --help-panel-radius: 8px; + --help-list-indent: 24px; + --help-inline-code-padding: 2px 6px; + --help-content-line-height: 1.6; +} + +.help-state { + color: var(--text-soft); +} + +.help-inline-state { + margin-bottom: var(--gap-m); + color: var(--text-soft); + font-size: 14px; +} + +.help-content { + color: var(--text); + line-height: var(--help-content-line-height); +} + +:host ::ng-deep .help-content { + .doc-nav { + display: flex; + flex-wrap: wrap; + gap: var(--gap-s); + margin: 0 0 var(--gap-l); + padding-bottom: var(--gap-s); + border-bottom: 1px solid var(--divider); + color: var(--text-soft); + } + + h2, + h3, + h4 { + margin: calc(var(--gap-l) + var(--gap-s)) 0 var(--gap-s); + color: var(--text); + opacity: 0.7; + } + + h2 { + font-size: 1.1rem; + } + + h3, h4 { + font-size: 1rem; + } + + p, + ul, + ol, + pre { + margin: 0 0 var(--gap-m); + } + + ul, + ol { + padding-left: var(--help-list-indent); + } + + li > ul, + li > ol { + margin: 0; + } + + li > p { + margin-bottom: var(--gap-s); + } + + a { + color: var(--link-color); + text-decoration: underline; + cursor: pointer; + transition: var(--transition-fast); + + &:hover { + color: var(--primary-hover); + } + + &:focus-visible { + outline: none; + border-radius: var(--help-link-radius); + box-shadow: var(--focus-ring); + } + } + + code { + background: var(--surface-muted); + border-radius: var(--help-code-radius); + padding: var(--help-inline-code-padding); + font-size: 0.95em; + } + + pre { + overflow-x: auto; + background: var(--surface-muted); + border: 1px solid var(--surface-border); + border-radius: var(--help-panel-radius); + padding: var(--gap-m); + + code { + background: transparent; + padding: 0; + } + } +} diff --git a/src/app/modules/help/help/help.spec.ts b/src/app/modules/help/help/help.spec.ts new file mode 100644 index 0000000..58344b6 --- /dev/null +++ b/src/app/modules/help/help/help.spec.ts @@ -0,0 +1,113 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {HelpComponent} from './help'; + +describe('HelpComponent', () => { + let component: HelpComponent; + let fixture: ComponentFixture; + let fetchMock: ReturnType; + + beforeEach(async () => { + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('# Benutzeranleitung\n\n[Zurück zur Übersicht](index.md)\n\n## Start\n\nText'), + }); + + vi.stubGlobal('fetch', fetchMock); + + await TestBed.configureTestingModule({ + imports: [HelpComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HelpComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load man index on init', () => { + expect(fetchMock).toHaveBeenCalledWith('man/index.md', {cache: 'no-cache'}); + expect(component.heading).toBe('Benutzeranleitung'); + }); + + it('should keep the current content visible while the next page loads', async () => { + let resolveFetch: ((value: {ok: boolean; text: () => Promise}) => void) | null = null; + + fetchMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + const oldHtml = component.renderedHtml; + const oldHeading = component.heading; + + void (component as {loadDocument(path: string): Promise}).loadDocument('man/pages/lieder-liste.md'); + fixture.detectChanges(); + + expect(component.loading).toBe(true); + expect(component.renderedHtml).toBe(oldHtml); + expect(component.heading).toBe(oldHeading); + + resolveFetch?.({ + ok: true, + text: () => Promise.resolve('# Liedliste\n\nInhalt'), + }); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.loading).toBe(false); + expect(component.heading).toBe('Liedliste'); + expect(component.renderedHtml).not.toBe(oldHtml); + }); + + it('should decode html entities in the title', () => { + const result = (component as unknown as {renderMarkdown(markdown: string): {title: string; html: string}}).renderMarkdown( + '# Veranstaltung veröffentlichen und teilen' + ); + + expect(result.title).toBe('Veranstaltung veröffentlichen und teilen'); + }); + + it('should preserve ordered list start values from markdown', () => { + const result = (component as unknown as {renderMarkdown(markdown: string): {title: string; html: string}}).renderMarkdown( + '# Test\n\n## Schritte\n\n7. Siebter Schritt\n8. Achter Schritt\n9. Neunter Schritt' + ); + + expect(result.html).toContain('
    '); + expect(result.html).toContain('
  1. Siebter Schritt
  2. '); + expect(result.html).toContain('
  3. Achter Schritt
  4. '); + expect(result.html).toContain('
  5. Neunter Schritt
  6. '); + }); + + it('should render nested lists inside ordered steps', () => { + const result = (component as unknown as {renderMarkdown(markdown: string): {title: string; html: string}}).renderMarkdown( + '# Test\n\n## Schritte\n\n1. Erster Schritt\n - Unterpunkt A\n - Unterpunkt B\n2. Zweiter Schritt' + ); + + expect(result.html).toContain('
      '); + expect(result.html).toContain('
        '); + expect(result.html).toContain('
      • Erster Schritt
        • Unterpunkt A
        • Unterpunkt B
      • '); + expect(result.html).toContain('
      • Zweiter Schritt
      • '); + }); + + it('should normalize duplicated tutorials paths', () => { + const resolved = (component as unknown as {resolveDocumentPath(basePath: string, href: string): string}).resolveDocumentPath( + 'man/tutorials/index.md', + 'tutorials/lied-finden-und-nutzen.md' + ); + + expect(resolved).toBe('man/tutorials/lied-finden-und-nutzen.md'); + }); +}); diff --git a/src/app/modules/help/help/help.ts b/src/app/modules/help/help/help.ts new file mode 100644 index 0000000..c8eb854 --- /dev/null +++ b/src/app/modules/help/help/help.ts @@ -0,0 +1,332 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, SecurityContext, inject} from '@angular/core'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import {PageFrameComponent} from '../../../widget-modules/components/sidebar/page-frame.component'; +import {CardComponent} from '../../../widget-modules/components/card/card.component'; + +@Component({ + selector: 'app-help', + imports: [PageFrameComponent, CardComponent], + templateUrl: './help.html', + styleUrl: './help.less', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HelpComponent implements OnInit { + private readonly cdr = inject(ChangeDetectorRef); + private readonly sanitizer = inject(DomSanitizer); + + public heading = 'Hilfe'; + public currentDocument = 'man/index.md'; + public renderedHtml: SafeHtml = ''; + public hasLoadedContent = false; + public loading = true; + public error: string | null = null; + + public ngOnInit(): void { + void this.loadDocument(this.currentDocument); + } + + public onContentClick(event: MouseEvent): void { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const link = target.closest('a'); + const href = link?.getAttribute('href'); + if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { + return; + } + + const resolvedPath = this.resolveDocumentPath(this.currentDocument, href); + if (!resolvedPath.endsWith('.md') || !resolvedPath.startsWith('man/')) { + return; + } + + event.preventDefault(); + void this.loadDocument(resolvedPath); + } + + private async loadDocument(path: string): Promise { + if (!path.startsWith('man/')) { + if (!this.hasLoadedContent) { + this.heading = 'Hilfe'; + this.renderedHtml = this.sanitizer.bypassSecurityTrustHtml(''); + this.error = 'Dieses Dokument gehört nicht zur Benutzerhilfe.'; + } + this.loading = false; + this.cdr.markForCheck(); + return; + } + + this.loading = true; + this.error = null; + this.currentDocument = path; + this.cdr.markForCheck(); + + try { + const response = await fetch(path, {cache: 'no-cache'}); + if (!response.ok) { + throw new Error(`Dokument konnte nicht geladen werden: ${response.status}`); + } + + const markdown = await response.text(); + const {title, html} = this.renderMarkdown(markdown); + + this.currentDocument = path; + this.heading = title || 'Hilfe'; + this.renderedHtml = this.sanitizer.bypassSecurityTrustHtml(html); + this.hasLoadedContent = true; + } catch { + if (!this.hasLoadedContent) { + this.heading = 'Hilfe'; + this.renderedHtml = this.sanitizer.bypassSecurityTrustHtml(''); + this.error = 'Die Hilfe konnte nicht geladen werden.'; + } + } finally { + this.loading = false; + this.cdr.markForCheck(); + } + } + + private resolveDocumentPath(basePath: string, href: string): string { + const [pathWithoutHash] = href.split('#', 1); + const resolved = new URL(pathWithoutHash, new URL(basePath, 'https://help.local/')); + let path = resolved.pathname.replace(/^\/+/, ''); + + path = path.replace(/^man\/tutorials\/tutorials\//, 'man/tutorials/'); + path = path.replace(/^man\/pages\/pages\//, 'man/pages/'); + + return path; + } + + private renderMarkdown(markdown: string): {title: string; html: string} { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const htmlParts: string[] = []; + const paragraphLines: string[] = []; + let inCodeBlock = false; + let codeLines: string[] = []; + let title = ''; + + const flushParagraph = () => { + if (paragraphLines.length === 0) { + return; + } + + const text = paragraphLines.join(' ').trim(); + if (text) { + if (/^\[[^\]]+\]\([^)]+\)( \| \[[^\]]+\]\([^)]+\))*$/.test(text)) { + htmlParts.push(``); + } else { + htmlParts.push(`

        ${this.renderInlineMarkdown(text)}

        `); + } + } + paragraphLines.length = 0; + }; + + const flushCodeBlock = () => { + if (!inCodeBlock) { + return; + } + const code = this.escapeHtml(codeLines.join('\n')); + htmlParts.push(`
        ${code}
        `); + codeLines = []; + inCodeBlock = false; + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + + if (line.startsWith('```')) { + flushParagraph(); + if (inCodeBlock) { + flushCodeBlock(); + } else { + inCodeBlock = true; + codeLines = []; + } + continue; + } + + if (inCodeBlock) { + codeLines.push(line); + continue; + } + + const trimmed = line.trim(); + if (!trimmed) { + flushParagraph(); + continue; + } + + const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed); + if (headingMatch) { + flushParagraph(); + + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + if (!title && level === 1) { + title = this.decodePlainText(text); + continue; + } + + htmlParts.push(`${this.renderInlineMarkdown(text)}`); + continue; + } + + const listMatch = this.matchListItem(line); + if (listMatch) { + flushParagraph(); + const {html, nextIndex} = this.renderListBlock(lines, index); + htmlParts.push(html); + index = nextIndex - 1; + continue; + } + + paragraphLines.push(trimmed); + } + + flushParagraph(); + flushCodeBlock(); + + return {title, html: htmlParts.join('\n')}; + } + + private renderListBlock(lines: string[], startIndex: number): {html: string; nextIndex: number} { + const firstMatch = this.matchListItem(lines[startIndex]); + if (!firstMatch) { + return {html: '', nextIndex: startIndex + 1}; + } + + const items: string[] = []; + const listType = firstMatch.type; + const baseIndent = firstMatch.indent; + const orderedStart = listType === 'ol' ? firstMatch.number : null; + let currentItem = this.renderInlineMarkdown(firstMatch.text); + let index = startIndex + 1; + + const pushCurrentItem = () => { + items.push(`
      • ${currentItem}
      • `); + }; + + while (index < lines.length) { + const rawLine = lines[index]; + const trimmed = rawLine.trim(); + + if (!trimmed) { + break; + } + + const match = this.matchListItem(rawLine); + if (!match) { + const indent = this.getIndent(rawLine); + if (indent > baseIndent) { + currentItem += ` ${this.renderInlineMarkdown(trimmed)}`; + index += 1; + continue; + } + + break; + } + + if (match.indent < baseIndent) { + break; + } + + if (match.indent > baseIndent) { + const nested = this.renderListBlock(lines, index); + currentItem += nested.html; + index = nested.nextIndex; + continue; + } + + if (match.type !== listType) { + break; + } + + pushCurrentItem(); + currentItem = this.renderInlineMarkdown(match.text); + index += 1; + } + + pushCurrentItem(); + + const startAttribute = listType === 'ol' && orderedStart && orderedStart > 1 ? ` start="${orderedStart}"` : ''; + return {html: `<${listType}${startAttribute}>${items.join('')}`, nextIndex: index}; + } + + private matchListItem(line: string): {indent: number; type: 'ul' | 'ol'; text: string; number: number | null} | null { + const match = /^(\s*)([-*]|\d+\.)\s+(.*)$/.exec(line); + if (!match) { + return null; + } + + const indent = match[1].length; + const marker = match[2]; + const text = match[3].trim(); + + if (marker.endsWith('.')) { + return { + indent, + type: 'ol', + text, + number: Number.parseInt(marker, 10), + }; + } + + return { + indent, + type: 'ul', + text, + number: null, + }; + } + + private getIndent(line: string): number { + return /^(\s*)/.exec(line)?.[1]?.length ?? 0; + } + + private renderInlineMarkdown(text: string): string { + let html = this.escapeHtml(text); + + html = html.replace(/`([^`]+)`/g, (_match, code: string) => `${code}`); + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, href: string) => { + const safeHref = this.escapeHtml(href); + const safeLabel = this.escapeHtml(label); + + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { + return `${safeLabel}`; + } + + return `${safeLabel}`; + }); + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + html = html.replace(/(^|[^\*])\*([^*]+)\*/g, '$1$2'); + + return html; + } + + private decodePlainText(text: string): string { + const html = this.renderInlineMarkdown(text); + const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html)?.replace(/<[^>]+>/g, '').trim() ?? text; + return this.decodeHtmlEntities(sanitized); + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private decodeHtmlEntities(text: string): string { + if (typeof document === 'undefined') { + return text; + } + + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; + } +} diff --git a/src/app/widget-modules/components/application-frame/navigation/link/link.component.html b/src/app/widget-modules/components/application-frame/navigation/link/link.component.html index 57a3a89..4f04d92 100644 --- a/src/app/widget-modules/components/application-frame/navigation/link/link.component.html +++ b/src/app/widget-modules/components/application-frame/navigation/link/link.component.html @@ -1,4 +1,6 @@ -   {{ text }} + @if(text) { +   {{ text }} + } + diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.html b/src/app/widget-modules/components/application-frame/navigation/navigation.component.html index 501f098..0c76106 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.html +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.html @@ -4,7 +4,7 @@ - + diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.less b/src/app/widget-modules/components/application-frame/navigation/navigation.component.less index d4baddb..0490b2a 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.less +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.less @@ -32,15 +32,11 @@ nav { display: flex; height: 100%; align-items: center; - padding-right: 12px; } -.actions-search { - gap: var(--gap-s); - padding-right: 20px; -} .theme-toggle { + zoom: 0.8; color: var(--text-inverse); transition: var(--transition-fast); diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts b/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts index 9c3e3ad..3d1ae62 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts @@ -1,7 +1,7 @@ import {Component, inject} from '@angular/core'; import {MatIconButton} from '@angular/material/button'; import {FaIconComponent} from '@fortawesome/angular-fontawesome'; -import {faChalkboard, faMoon, faMusic, faPersonBooth, faSun, faUserCog} from '@fortawesome/free-solid-svg-icons'; +import {faChalkboard, faMoon, faMusic, faPersonBooth, faQuestion, faSun, faUserCog} from '@fortawesome/free-solid-svg-icons'; import {fromEvent, Observable} from 'rxjs'; import {distinctUntilChanged, map, shareReplay, startWith} from 'rxjs/operators'; import {BrandComponent} from './brand/brand.component'; @@ -23,6 +23,7 @@ export class NavigationComponent { public faSongs = faMusic; public faShows = faPersonBooth; public faUser = faUserCog; + public faHelp = faQuestion; public faPresentation = faChalkboard; public faDarkMode = faMoon; public faLightMode = faSun;