help page
This commit is contained in:
+6
-1
@@ -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,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)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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ö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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -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"> {{ text }}</span></a
|
@if(text) {
|
||||||
>
|
<span class="link-text"> {{ text }}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
|||||||
+4
-2
@@ -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>
|
||||||
|
|||||||
+1
-5
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user