im profil kann ein nutzer nun seine daten exportieren. man kann seinen account löschen. nach 2 jahren werden inaktive accounts gelöscht, 1 monat vorher wird eine email geschickt. Hilfetexte und Legal Pages in der Guest PWA korrigiert und vom layout her optimiert (dark mode).

This commit is contained in:
Codex Agent
2025-11-10 19:55:46 +01:00
parent 447a90a742
commit 2587b2049d
37 changed files with 1650 additions and 50 deletions

View File

@@ -1,24 +1,31 @@
import React from "react";
type Props = {
markdown: string;
markdown?: string;
html?: string;
};
export function LegalMarkdown({ markdown }: Props) {
const html = React.useMemo(() => {
let safe = markdown
export function LegalMarkdown({ markdown = '', html }: Props) {
const derived = React.useMemo(() => {
if (html && html.trim().length > 0) {
return html;
}
const escaped = markdown
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
safe = safe
return escaped
.split(/\n{2,}/)
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
.join('\n');
return safe;
}, [markdown]);
}, [markdown, html]);
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<div
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: derived }}
/>
);
}

View File

@@ -36,10 +36,10 @@ type ViewState =
};
type LegalDocumentState =
| { phase: 'idle'; title: string; body: string }
| { phase: 'loading'; title: string; body: string }
| { phase: 'ready'; title: string; body: string }
| { phase: 'error'; title: string; body: string };
| { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; markdown: string; html: string };
type NameStatus = 'idle' | 'saved';
@@ -223,10 +223,10 @@ function LegalView({
<CardHeader>
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
<LegalMarkdown markdown={document.body} />
</CardContent>
</Card>
<CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
<LegalMarkdown markdown={document.markdown} html={document.html} />
</CardContent>
</Card>
</div>
);
}
@@ -418,17 +418,18 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle',
title: '',
body: '',
markdown: '',
html: '',
});
React.useEffect(() => {
if (!slug) {
setState({ phase: 'idle', title: '', body: '' });
setState({ phase: 'idle', title: '', markdown: '', html: '' });
return;
}
const controller = new AbortController();
setState({ phase: 'loading', title: '', body: '' });
setState({ phase: 'loading', title: '', markdown: '', html: '' });
const langParam = encodeURIComponent(locale);
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
@@ -443,7 +444,8 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
setState({
phase: 'ready',
title: payload.title ?? '',
body: payload.body_markdown ?? '',
markdown: payload.body_markdown ?? '',
html: payload.body_html ?? '',
});
})
.catch((error) => {
@@ -451,7 +453,7 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
return;
}
console.error('Failed to load legal page', error);
setState({ phase: 'error', title: '', body: '' });
setState({ phase: 'error', title: '', markdown: '', html: '' });
});
return () => controller.abort();

View File

@@ -2,20 +2,48 @@ import React from 'react';
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
import { useLocale } from './LocaleContext';
export type TranslateFn = (key: string, fallback?: string) => string;
type ReplacementValues = Record<string, string | number>;
export type TranslateFn = {
(key: string): string;
(key: string, fallback: string): string;
(key: string, replacements: ReplacementValues): string;
(key: string, replacements: ReplacementValues, fallback: string): string;
};
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
}
function applyReplacements(value: string, replacements?: ReplacementValues): string {
if (!replacements) {
return value;
}
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
const pattern = new RegExp(`\\{${token}\\}`, 'g');
return acc.replace(pattern, String(replacement));
}, value);
}
export function useTranslation() {
const { locale } = useLocale();
const t = React.useCallback<TranslateFn>(
(key, fallback) => resolveTranslation(locale, key, fallback),
[locale],
);
const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
let replacements: ReplacementValues | undefined;
let fallback: string | undefined;
if (typeof arg2 === 'string' || arg2 === undefined) {
fallback = arg2 ?? arg3;
} else {
replacements = arg2;
fallback = arg3;
}
const raw = resolveTranslation(locale, key, fallback);
return applyReplacements(raw, replacements);
}, [locale]);
return React.useMemo(() => ({ t, locale }), [t, locale]);
}

View File

@@ -73,16 +73,18 @@ export default function HelpArticlePage() {
{article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)}
{servedFromCache && (
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">
{t('help.center.offlineBadge')}
</Badge>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
<div className="overflow-x-auto">
<div
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</div>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
{article.related && article.related.length > 0 && (
<section className="space-y-3">
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
@@ -95,7 +97,7 @@ export default function HelpArticlePage() {
asChild
>
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.slug}
{rel.title ?? rel.slug}
</Link>
</Button>
))}

View File

@@ -8,6 +8,7 @@ export default function LegalPage() {
const [loading, setLoading] = React.useState(true);
const [title, setTitle] = React.useState('');
const [body, setBody] = React.useState('');
const [html, setHtml] = React.useState('');
React.useEffect(() => {
if (!page) {
@@ -29,11 +30,13 @@ export default function LegalPage() {
const data = await res.json();
setTitle(data.title || '');
setBody(data.body_markdown || '');
setHtml(data.body_html || '');
} catch (error) {
if (!controller.signal.aborted) {
console.error('Failed to load legal page', error);
setTitle('');
setBody('');
setHtml('');
}
} finally {
if (!controller.signal.aborted) {
@@ -50,7 +53,7 @@ export default function LegalPage() {
return (
<Page title={title || fallbackTitle}>
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} html={html} />}
</Page>
);
}

View File

@@ -11,7 +11,7 @@ export type HelpArticleSummary = {
last_reviewed_at?: string;
owner?: string;
updated_at?: string;
related?: Array<{ slug: string }>;
related?: Array<{ slug: string; title?: string }>;
};
export type HelpArticleDetail = HelpArticleSummary & {