help page

This commit is contained in:
benjamin
2026-05-15 12:05:14 +02:00
parent 3995b7e585
commit 395070f660
13 changed files with 622 additions and 13 deletions
+6 -1
View File
@@ -38,7 +38,12 @@
"src/favicon-32x32.png", "src/favicon-32x32.png",
"src/mstile-150x150.png", "src/mstile-150x150.png",
"src/assets", "src/assets",
"src/manifest.webmanifest" "src/manifest.webmanifest",
{
"glob": "**/*",
"input": "man",
"output": "man"
}
], ],
"styles": [ "styles": [
"src/custom-theme.scss", "src/custom-theme.scss",
-2
View File
@@ -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. 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 ## Schnellstart
- [Erste Schritte](tutorials/erste-schritte.md) - [Erste Schritte](tutorials/erste-schritte.md)
+4
View File
@@ -50,6 +50,10 @@ const routes: Routes = [
path: 'guest', path: 'guest',
loadChildren: () => import('./modules/guest/guest.module').then(m => m.GuestModule), loadChildren: () => import('./modules/guest/guest.module').then(m => m.GuestModule),
}, },
{
path: 'help',
loadChildren: () => import('./modules/help/help.module').then(m => m.HelpModule),
},
]; ];
@NgModule({ @NgModule({
@@ -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 {}
+10
View File
@@ -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 {}
+18
View File
@@ -0,0 +1,18 @@
<app-page-frame title="Hilfe" [withMenu]="false">
<div content>
<app-card [heading]="heading">
<div (click)="onContentClick($event)" [innerHTML]="renderedHtml" class="help-content"></div>
<!-- @if (loading && !hasLoadedContent) {-->
<!-- <div class="help-state">Hilfe wird geladen.</div>-->
<!-- } @else if (error && !hasLoadedContent) {-->
<!-- <div class="help-state">{{ error }}</div>-->
<!-- } @else {-->
<!-- @if (loading) {-->
<!-- <div class="help-inline-state">Seite wird geladen.</div>-->
<!-- }-->
<!-- <div (click)="onContentClick($event)" [innerHTML]="renderedHtml" class="help-content"></div>-->
<!-- }-->
</app-card>
</div>
</app-page-frame>
+109
View File
@@ -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;
}
}
}
+113
View File
@@ -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<HelpComponent>;
let fetchMock: ReturnType<typeof vi.fn>;
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<string>}) => 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<void>}).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&#246;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('<ol start="7">');
expect(result.html).toContain('<li>Siebter Schritt</li>');
expect(result.html).toContain('<li>Achter Schritt</li>');
expect(result.html).toContain('<li>Neunter Schritt</li>');
});
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('<ol>');
expect(result.html).toContain('<ul>');
expect(result.html).toContain('<li>Erster Schritt<ul><li>Unterpunkt A</li><li>Unterpunkt B</li></ul></li>');
expect(result.html).toContain('<li>Zweiter Schritt</li>');
});
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');
});
});
+332
View File
@@ -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<void> {
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(`<nav class="doc-nav">${this.renderInlineMarkdown(text)}</nav>`);
} else {
htmlParts.push(`<p>${this.renderInlineMarkdown(text)}</p>`);
}
}
paragraphLines.length = 0;
};
const flushCodeBlock = () => {
if (!inCodeBlock) {
return;
}
const code = this.escapeHtml(codeLines.join('\n'));
htmlParts.push(`<pre><code>${code}</code></pre>`);
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(`<h${level}>${this.renderInlineMarkdown(text)}</h${level}>`);
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(`<li>${currentItem}</li>`);
};
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('')}</${listType}>`, 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>${code}</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 `<a href="${safeHref}" target="_blank" rel="noreferrer">${safeLabel}</a>`;
}
return `<a href="${safeHref}">${safeLabel}</a>`;
});
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(^|[^\*])\*([^*]+)\*/g, '$1<em>$2</em>');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
private decodeHtmlEntities(text: string): string {
if (typeof document === 'undefined') {
return text;
}
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
}
@@ -1,4 +1,6 @@
<a [routerLink]="link" href="#" routerLinkActive="active"> <a [routerLink]="link" href="#" routerLinkActive="active">
<fa-icon [icon]="icon"></fa-icon> <fa-icon [icon]="icon"></fa-icon>
<span class="link-text">&nbsp;&nbsp;{{ text }}</span></a @if(text) {
> <span class="link-text">&nbsp;&nbsp;{{ text }}</span>
}
</a>
@@ -4,7 +4,7 @@
<app-link *appRole="['user', 'presenter', 'leader']" [icon]="faSongs" link="/songs" text="Lieder"></app-link> <app-link *appRole="['user', 'presenter', 'leader']" [icon]="faSongs" link="/songs" text="Lieder"></app-link>
<app-link *appRole="['leader', 'member']" [icon]="faShows" link="/shows" text="Veranstaltungen"></app-link> <app-link *appRole="['leader', 'member']" [icon]="faShows" link="/shows" text="Veranstaltungen"></app-link>
<app-link *appRole="['presenter']" [icon]="faPresentation" link="/presentation" text="Präsentation"></app-link> <app-link *appRole="['presenter']" [icon]="faPresentation" link="/presentation" text="Präsentation"></app-link>
<app-link [icon]="faUser" link="/user" text="Benutzer"></app-link>
</div> </div>
<div *appRole="['user', 'presenter', 'leader']" class="actions actions-search"> <div *appRole="['user', 'presenter', 'leader']" class="actions actions-search">
<button <button
@@ -15,6 +15,8 @@
(click)="themeService.toggleTheme()"> (click)="themeService.toggleTheme()">
<fa-icon [icon]="themeService.isDarkMode() ? faLightMode : faDarkMode"></fa-icon> <fa-icon [icon]="themeService.isDarkMode() ? faLightMode : faDarkMode"></fa-icon>
</button> </button>
<app-filter></app-filter> <!-- <app-filter></app-filter>-->
<app-link [icon]="faUser" link="/user"></app-link>
<app-link [icon]="faHelp" link="/help"></app-link>
</div> </div>
</nav> </nav>
@@ -32,15 +32,11 @@ nav {
display: flex; display: flex;
height: 100%; height: 100%;
align-items: center; align-items: center;
padding-right: 12px;
} }
.actions-search {
gap: var(--gap-s);
padding-right: 20px;
}
.theme-toggle { .theme-toggle {
zoom: 0.8;
color: var(--text-inverse); color: var(--text-inverse);
transition: var(--transition-fast); transition: var(--transition-fast);
@@ -1,7 +1,7 @@
import {Component, inject} from '@angular/core'; import {Component, inject} from '@angular/core';
import {MatIconButton} from '@angular/material/button'; import {MatIconButton} from '@angular/material/button';
import {FaIconComponent} from '@fortawesome/angular-fontawesome'; 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 {fromEvent, Observable} from 'rxjs';
import {distinctUntilChanged, map, shareReplay, startWith} from 'rxjs/operators'; import {distinctUntilChanged, map, shareReplay, startWith} from 'rxjs/operators';
import {BrandComponent} from './brand/brand.component'; import {BrandComponent} from './brand/brand.component';
@@ -23,6 +23,7 @@ export class NavigationComponent {
public faSongs = faMusic; public faSongs = faMusic;
public faShows = faPersonBooth; public faShows = faPersonBooth;
public faUser = faUserCog; public faUser = faUserCog;
public faHelp = faQuestion;
public faPresentation = faChalkboard; public faPresentation = faChalkboard;
public faDarkMode = faMoon; public faDarkMode = faMoon;
public faLightMode = faSun; public faLightMode = faSun;