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
+4
View File
@@ -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({
@@ -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">
<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="['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 [icon]="faUser" link="/user" text="Benutzer"></app-link>
</div>
<div *appRole="['user', 'presenter', 'leader']" class="actions actions-search">
<button
@@ -15,6 +15,8 @@
(click)="themeService.toggleTheme()">
<fa-icon [icon]="themeService.isDarkMode() ? faLightMode : faDarkMode"></fa-icon>
</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>
</nav>
@@ -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);
@@ -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;