mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
fix(shared-help): harden help system with XSS protection, i18n, type safety, and reference implementation
- Add HTML sanitization via isomorphic-dompurify in parser layer to prevent XSS - Replace all hardcoded English strings with translations (FAQSection, KeyboardShortcuts, ChangelogEntry/Section) - Remove unsafe `as` type casting in loader.ts, use Zod-inferred generics instead - Add error logging in content loader (replaces silent catch blocks) - Fix HelpSearch blur handling (mousedown+preventDefault instead of setTimeout hack) - Add ARIA attributes to HelpSearch for accessibility - Derive FAQ categories from items instead of hardcoding all 6 - Fix null-safety in GettingStartedGuide.svelte - Fix unused appId variable in HelpPage.svelte, add scroll-reset on tab switch - Rebuild Contacts help page as reference implementation using shared HelpPage component - Add README with quick-start guide, props docs, and translations template Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8356ac634a
commit
42dd7d2a7a
18 changed files with 845 additions and 801 deletions
|
|
@ -1,173 +1,172 @@
|
|||
/**
|
||||
* Central help content loader for Contacts app
|
||||
* This file loads and merges the central help content from @manacore/shared-help-content
|
||||
* Help content for Contacts app
|
||||
* Uses @manacore/shared-help-types for type-safe content definition
|
||||
*/
|
||||
|
||||
import type { HelpContent } from '@manacore/shared-help-types';
|
||||
import { createEmptyContent } from '@manacore/shared-help-content';
|
||||
|
||||
/**
|
||||
* Central help content that applies to all Manacore apps
|
||||
* In a production setup, this would be loaded from the shared-help-content package's
|
||||
* Markdown files. For now, we provide the content inline for simplicity.
|
||||
*/
|
||||
export const centralHelpContent: HelpContent = {
|
||||
faq: [
|
||||
// Account FAQs
|
||||
{
|
||||
id: 'faq-account-001',
|
||||
question: 'How do I create an account?',
|
||||
answer: `<p>Creating an account is simple:</p>
|
||||
<ol>
|
||||
<li>Click the <strong>Sign Up</strong> button on the login page</li>
|
||||
<li>Enter your email address and choose a secure password</li>
|
||||
<li>Verify your email address by clicking the link we send you</li>
|
||||
<li>Complete your profile setup</li>
|
||||
</ol>
|
||||
<p>You can also sign up using your Google or Apple account for faster registration.</p>`,
|
||||
category: 'account',
|
||||
export function getContactsHelpContent(locale: string): HelpContent {
|
||||
const isDE = locale === 'de';
|
||||
|
||||
return {
|
||||
faq: [
|
||||
{
|
||||
id: 'faq-import',
|
||||
question: isDE
|
||||
? 'Wie importiere ich Kontakte aus Google?'
|
||||
: 'How do I import contacts from Google?',
|
||||
answer: isDE
|
||||
? '<p>Um Kontakte aus Google zu importieren: Gehe zu <strong>Daten > Import</strong>, wähle Google Kontakte, melde dich bei deinem Google-Konto an, wähle die Kontakte aus und klicke auf <strong>Importieren</strong>.</p>'
|
||||
: '<p>To import contacts from Google: Go to <strong>Data > Import</strong>, select Google Contacts, sign in to your Google account, select the contacts you want to import, and click <strong>Import</strong>.</p>',
|
||||
category: 'features',
|
||||
order: 1,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['import', 'google', 'kontakte'] : ['import', 'google', 'contacts'],
|
||||
},
|
||||
{
|
||||
id: 'faq-export',
|
||||
question: isDE ? 'Wie exportiere ich meine Kontakte?' : 'How do I export my contacts?',
|
||||
answer: isDE
|
||||
? '<p>Du kannst deine Kontakte in verschiedenen Formaten exportieren: Gehe zu <strong>Daten > Export</strong>, wähle das gewünschte Format (vCard, CSV, JSON) und klicke auf <strong>Exportieren</strong>.</p>'
|
||||
: '<p>You can export your contacts in various formats: Go to <strong>Data > Export</strong>, select the desired format (vCard, CSV, JSON), and click <strong>Export</strong>.</p>',
|
||||
category: 'features',
|
||||
order: 2,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['export', 'vcard', 'csv'] : ['export', 'vcard', 'csv'],
|
||||
},
|
||||
{
|
||||
id: 'faq-duplicates',
|
||||
question: isDE ? 'Wie finde ich doppelte Kontakte?' : 'How do I find duplicate contacts?',
|
||||
answer: isDE
|
||||
? '<p>Wir erkennen automatisch potenzielle Duplikate. Gehe zu <strong>Duplikate</strong> in der Seitenleiste, überprüfe die Vorschläge und wähle <strong>Zusammenführen</strong> oder <strong>Ignorieren</strong>.</p>'
|
||||
: '<p>We automatically detect potential duplicates. Go to <strong>Duplicates</strong> in the sidebar, review the suggestions, and choose to <strong>Merge</strong> or <strong>Ignore</strong>.</p>',
|
||||
category: 'features',
|
||||
order: 3,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['duplikate', 'zusammenführen'] : ['duplicates', 'merge'],
|
||||
},
|
||||
{
|
||||
id: 'faq-subscription',
|
||||
question: isDE ? 'Wie kann ich mein Abo kündigen?' : 'How do I cancel my subscription?',
|
||||
answer: isDE
|
||||
? '<p>Du kannst dein Abo jederzeit kündigen: Gehe zu <strong>Einstellungen > Abonnement > Abo verwalten > Abo kündigen</strong>. Dein Abo bleibt bis zum Ende des Abrechnungszeitraums aktiv.</p>'
|
||||
: '<p>You can cancel your subscription at any time: Go to <strong>Settings > Subscription > Manage Subscription > Cancel Subscription</strong>. Your subscription will remain active until the end of the billing period.</p>',
|
||||
category: 'billing',
|
||||
order: 4,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['abo', 'kündigung', 'abrechnung'] : ['subscription', 'cancel', 'billing'],
|
||||
},
|
||||
{
|
||||
id: 'faq-privacy',
|
||||
question: isDE ? 'Wie werden meine Daten geschützt?' : 'How is my data protected?',
|
||||
answer: isDE
|
||||
? '<p>Wir nehmen deinen Datenschutz ernst:</p><ul><li><strong>Verschlüsselung</strong>: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt</li><li><strong>DSGVO-konform</strong>: Wir halten uns an die EU-Datenschutzverordnung</li><li><strong>Kein Datenverkauf</strong>: Wir verkaufen niemals deine persönlichen Daten</li><li><strong>Datenexport</strong>: Du kannst jederzeit alle deine Daten exportieren</li><li><strong>Kontolöschung</strong>: Du kannst dein Konto und alle Daten dauerhaft löschen</li></ul>'
|
||||
: '<p>We take your privacy seriously:</p><ul><li><strong>Encryption</strong>: All data is encrypted in transit (TLS) and at rest</li><li><strong>GDPR Compliant</strong>: We follow EU data protection regulations</li><li><strong>No Data Selling</strong>: We never sell your personal data</li><li><strong>Data Export</strong>: You can export all your data at any time</li><li><strong>Account Deletion</strong>: You can permanently delete your account and all data</li></ul>',
|
||||
category: 'privacy',
|
||||
order: 5,
|
||||
language: isDE ? 'de' : 'en',
|
||||
featured: true,
|
||||
tags: isDE ? ['datenschutz', 'dsgvo', 'sicherheit'] : ['privacy', 'gdpr', 'security'],
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{
|
||||
id: 'feature-management',
|
||||
title: isDE ? 'Kontaktverwaltung' : 'Contact Management',
|
||||
description: isDE
|
||||
? 'Verwalte alle deine Kontakte an einem Ort'
|
||||
: 'Manage all your contacts in one place',
|
||||
icon: '👥',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Unbegrenzte Kontakte', 'Benutzerdefinierte Felder', 'Tags und Kategorien']
|
||||
: ['Unlimited contacts', 'Custom fields', 'Tags and categories'],
|
||||
content: '',
|
||||
order: 1,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-import-export',
|
||||
title: 'Import & Export',
|
||||
description: isDE
|
||||
? 'Importiere und exportiere Kontakte in verschiedenen Formaten'
|
||||
: 'Import and export contacts in various formats',
|
||||
icon: '📤',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Google Kontakte Sync', 'vCard Import/Export', 'CSV Import/Export']
|
||||
: ['Google Contacts sync', 'vCard import/export', 'CSV import/export'],
|
||||
content: '',
|
||||
order: 2,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-duplicates',
|
||||
title: isDE ? 'Duplikat-Erkennung' : 'Duplicate Detection',
|
||||
description: isDE
|
||||
? 'Automatische Erkennung und Zusammenführung von Duplikaten'
|
||||
: 'Automatic detection and merging of duplicates',
|
||||
icon: '🔍',
|
||||
category: 'advanced',
|
||||
highlights: isDE
|
||||
? ['Intelligente Erkennung', 'Ein-Klick Zusammenführung', 'Überprüfungsmodus']
|
||||
: ['Smart detection', 'One-click merge', 'Review mode'],
|
||||
content: '',
|
||||
order: 3,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-favorites',
|
||||
title: isDE ? 'Favoriten' : 'Favorites',
|
||||
description: isDE
|
||||
? 'Markiere wichtige Kontakte als Favoriten'
|
||||
: 'Mark important contacts as favorites',
|
||||
icon: '⭐',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Schnellzugriff', 'Verschiedene Ansichten', 'Sortierung']
|
||||
: ['Quick access', 'Multiple views', 'Sorting'],
|
||||
content: '',
|
||||
order: 4,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
id: 'shortcuts-general',
|
||||
category: 'general',
|
||||
title: isDE ? 'Allgemein' : 'General',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
shortcuts: [
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + K',
|
||||
action: isDE ? 'Suche öffnen' : 'Open search',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + N',
|
||||
action: isDE ? 'Neuer Kontakt' : 'New contact',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 1-6',
|
||||
action: isDE ? 'Navigation' : 'Navigation',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
gettingStarted: [],
|
||||
changelog: [],
|
||||
contact: {
|
||||
id: 'contact-support',
|
||||
title: isDE ? 'Support kontaktieren' : 'Contact Support',
|
||||
content: isDE
|
||||
? '<p>Unser Support-Team hilft dir bei allen Fragen.</p>'
|
||||
: '<p>Our support team is here to help you with any questions or issues.</p>',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
language: 'en',
|
||||
featured: true,
|
||||
tags: ['account', 'registration', 'signup'],
|
||||
supportEmail: 'support@mana.how',
|
||||
documentationUrl: 'https://mana.how/docs',
|
||||
responseTime: isDE ? 'Normalerweise innerhalb von 24 Stunden' : 'Usually within 24 hours',
|
||||
},
|
||||
{
|
||||
id: 'faq-account-001',
|
||||
question: 'Wie erstelle ich ein Konto?',
|
||||
answer: `<p>Die Kontoerstellung ist einfach:</p>
|
||||
<ol>
|
||||
<li>Klicke auf <strong>Registrieren</strong> auf der Anmeldeseite</li>
|
||||
<li>Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort</li>
|
||||
<li>Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden</li>
|
||||
<li>Vervollständige dein Profil</li>
|
||||
</ol>
|
||||
<p>Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.</p>`,
|
||||
category: 'account',
|
||||
order: 1,
|
||||
language: 'de',
|
||||
featured: true,
|
||||
tags: ['konto', 'registrierung', 'anmeldung'],
|
||||
},
|
||||
// Billing FAQs
|
||||
{
|
||||
id: 'faq-billing-001',
|
||||
question: 'How do I cancel my subscription?',
|
||||
answer: `<p>You can cancel your subscription at any time:</p>
|
||||
<ol>
|
||||
<li>Go to <strong>Settings</strong> > <strong>Subscription</strong></li>
|
||||
<li>Click <strong>Manage Subscription</strong></li>
|
||||
<li>Select <strong>Cancel Subscription</strong></li>
|
||||
<li>Confirm your cancellation</li>
|
||||
</ol>
|
||||
<p>Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.</p>`,
|
||||
category: 'billing',
|
||||
order: 1,
|
||||
language: 'en',
|
||||
featured: true,
|
||||
tags: ['subscription', 'cancel', 'billing'],
|
||||
},
|
||||
{
|
||||
id: 'faq-billing-001',
|
||||
question: 'Wie kann ich mein Abo kündigen?',
|
||||
answer: `<p>Du kannst dein Abo jederzeit kündigen:</p>
|
||||
<ol>
|
||||
<li>Gehe zu <strong>Einstellungen</strong> > <strong>Abonnement</strong></li>
|
||||
<li>Klicke auf <strong>Abo verwalten</strong></li>
|
||||
<li>Wähle <strong>Abo kündigen</strong></li>
|
||||
<li>Bestätige die Kündigung</li>
|
||||
</ol>
|
||||
<p>Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.</p>`,
|
||||
category: 'billing',
|
||||
order: 1,
|
||||
language: 'de',
|
||||
featured: true,
|
||||
tags: ['abo', 'kündigung', 'abrechnung'],
|
||||
},
|
||||
// Privacy FAQs
|
||||
{
|
||||
id: 'faq-privacy-001',
|
||||
question: 'How is my data protected?',
|
||||
answer: `<p>We take your privacy seriously:</p>
|
||||
<ul>
|
||||
<li><strong>Encryption</strong>: All data is encrypted in transit (TLS) and at rest</li>
|
||||
<li><strong>GDPR Compliant</strong>: We follow EU data protection regulations</li>
|
||||
<li><strong>No Data Selling</strong>: We never sell your personal data to third parties</li>
|
||||
<li><strong>Data Export</strong>: You can export all your data at any time</li>
|
||||
<li><strong>Account Deletion</strong>: You can permanently delete your account and all associated data</li>
|
||||
</ul>`,
|
||||
category: 'privacy',
|
||||
order: 1,
|
||||
language: 'en',
|
||||
featured: true,
|
||||
tags: ['privacy', 'data', 'security', 'gdpr'],
|
||||
},
|
||||
{
|
||||
id: 'faq-privacy-001',
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer: `<p>Wir nehmen deinen Datenschutz ernst:</p>
|
||||
<ul>
|
||||
<li><strong>Verschlüsselung</strong>: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt</li>
|
||||
<li><strong>DSGVO-konform</strong>: Wir halten uns an die EU-Datenschutzverordnung</li>
|
||||
<li><strong>Kein Datenverkauf</strong>: Wir verkaufen niemals deine persönlichen Daten an Dritte</li>
|
||||
<li><strong>Datenexport</strong>: Du kannst jederzeit alle deine Daten exportieren</li>
|
||||
<li><strong>Kontolöschung</strong>: Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen</li>
|
||||
</ul>`,
|
||||
category: 'privacy',
|
||||
order: 1,
|
||||
language: 'de',
|
||||
featured: true,
|
||||
tags: ['datenschutz', 'daten', 'sicherheit', 'dsgvo'],
|
||||
},
|
||||
],
|
||||
features: [],
|
||||
shortcuts: [],
|
||||
gettingStarted: [
|
||||
{
|
||||
id: 'guide-welcome',
|
||||
title: 'Getting Started',
|
||||
description: 'Learn the basics and get up and running quickly',
|
||||
content: `<h2>Create Your Account</h2>
|
||||
<p>Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup.</p>
|
||||
<h2>Explore the Dashboard</h2>
|
||||
<p>After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance.</p>
|
||||
<h2>Customize Your Settings</h2>
|
||||
<p>Visit the Settings page to personalize your experience.</p>`,
|
||||
difficulty: 'beginner',
|
||||
estimatedTime: '5 minutes',
|
||||
order: 1,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'guide-welcome',
|
||||
title: 'Erste Schritte',
|
||||
description: 'Lerne die Grundlagen und starte schnell durch',
|
||||
content: `<h2>Konto erstellen</h2>
|
||||
<p>Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen.</p>
|
||||
<h2>Dashboard erkunden</h2>
|
||||
<p>Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen kannst.</p>
|
||||
<h2>Einstellungen anpassen</h2>
|
||||
<p>Besuche die Einstellungen, um dein Erlebnis zu personalisieren.</p>`,
|
||||
difficulty: 'beginner',
|
||||
estimatedTime: '5 Minuten',
|
||||
order: 1,
|
||||
language: 'de',
|
||||
},
|
||||
],
|
||||
changelog: [],
|
||||
contact: {
|
||||
id: 'contact-support',
|
||||
title: 'Contact Support',
|
||||
content: `<h2>Need Help?</h2>
|
||||
<p>Our support team is here to help you with any questions or issues.</p>
|
||||
<h3>Before Contacting Us</h3>
|
||||
<ul>
|
||||
<li>Check the <strong>FAQ</strong> section for quick answers</li>
|
||||
<li>Browse our <strong>Getting Started</strong> guides</li>
|
||||
<li>Search the help center using the search bar</li>
|
||||
</ul>`,
|
||||
language: 'en',
|
||||
order: 1,
|
||||
supportEmail: 'support@mana.how',
|
||||
responseTime: 'Usually within 24 hours',
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,508 +1,187 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import {
|
||||
Question,
|
||||
Star,
|
||||
Command,
|
||||
Envelope,
|
||||
MagnifyingGlass,
|
||||
CaretDown,
|
||||
Check,
|
||||
Info,
|
||||
ChatCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowSquareOut,
|
||||
BookOpen,
|
||||
} from '@manacore/shared-icons';
|
||||
import { HelpPage } from '@manacore/shared-help-ui';
|
||||
import type { HelpPageTranslations } from '@manacore/shared-help-ui';
|
||||
import { getContactsHelpContent } from '$lib/content/help/index.js';
|
||||
|
||||
type HelpSection = 'faq' | 'features' | 'shortcuts' | 'contact';
|
||||
const content = $derived(getContactsHelpContent($locale ?? 'en'));
|
||||
|
||||
let activeSection = $state<HelpSection>('faq');
|
||||
let searchQuery = $state('');
|
||||
let expandedFaqId = $state<string | null>('faq-1');
|
||||
|
||||
// FAQ data
|
||||
const faqs = $derived(
|
||||
const translations: HelpPageTranslations = $derived(
|
||||
$locale === 'de'
|
||||
? [
|
||||
{
|
||||
id: 'faq-1',
|
||||
question: 'Wie importiere ich Kontakte aus Google?',
|
||||
answer:
|
||||
'Um Kontakte aus Google zu importieren: Gehe zu Daten > Import, wähle Google Kontakte, melde dich bei deinem Google-Konto an, wähle die Kontakte aus und klicke auf Importieren.',
|
||||
category: 'features',
|
||||
? {
|
||||
title: 'Hilfe & Support',
|
||||
subtitle: 'Finde Antworten und lerne die App kennen',
|
||||
searchPlaceholder: 'Hilfe durchsuchen...',
|
||||
sections: {
|
||||
faq: 'FAQ',
|
||||
features: 'Features',
|
||||
shortcuts: 'Tastenkürzel',
|
||||
gettingStarted: 'Erste Schritte',
|
||||
changelog: 'Änderungen',
|
||||
contact: 'Kontakt',
|
||||
},
|
||||
{
|
||||
id: 'faq-2',
|
||||
question: 'Wie exportiere ich meine Kontakte?',
|
||||
answer:
|
||||
'Du kannst deine Kontakte in verschiedenen Formaten exportieren: Gehe zu Daten > Export, wähle das gewünschte Format (vCard, CSV, JSON) und klicke auf Exportieren.',
|
||||
category: 'features',
|
||||
search: {
|
||||
noResults: 'Keine Ergebnisse für "{query}"',
|
||||
resultsCount: '{count} Ergebnisse',
|
||||
searching: 'Suche...',
|
||||
},
|
||||
{
|
||||
id: 'faq-3',
|
||||
question: 'Wie finde ich doppelte Kontakte?',
|
||||
answer:
|
||||
'Wir erkennen automatisch potenzielle Duplikate. Gehe zu Duplikate in der Seitenleiste, überprüfe die Vorschläge und wähle Zusammenführen oder Ignorieren.',
|
||||
category: 'features',
|
||||
faq: {
|
||||
noItems: 'Keine häufigen Fragen verfügbar.',
|
||||
allCategories: 'Alle',
|
||||
categories: {
|
||||
general: 'Allgemein',
|
||||
account: 'Konto',
|
||||
billing: 'Abrechnung',
|
||||
features: 'Funktionen',
|
||||
technical: 'Technisch',
|
||||
privacy: 'Datenschutz',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'faq-4',
|
||||
question: 'Wie kann ich mein Abo kündigen?',
|
||||
answer:
|
||||
'Du kannst dein Abo jederzeit kündigen: Gehe zu Einstellungen > Abonnement > Abo verwalten > Abo kündigen. Dein Abo bleibt bis zum Ende des Abrechnungszeitraums aktiv.',
|
||||
category: 'billing',
|
||||
features: {
|
||||
noItems: 'Keine Features verfügbar.',
|
||||
comingSoon: 'Demnächst',
|
||||
learnMore: 'Mehr erfahren',
|
||||
},
|
||||
{
|
||||
id: 'faq-5',
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer:
|
||||
'Wir nehmen deinen Datenschutz ernst: Alle Daten werden verschlüsselt (TLS), wir sind DSGVO-konform, verkaufen keine Daten und du kannst jederzeit alle Daten exportieren oder dein Konto löschen.',
|
||||
category: 'privacy',
|
||||
shortcuts: {
|
||||
noItems: 'Keine Tastenkürzel verfügbar.',
|
||||
columns: {
|
||||
shortcut: 'Kürzel',
|
||||
action: 'Aktion',
|
||||
description: 'Beschreibung',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'faq-1',
|
||||
question: 'How do I import contacts from Google?',
|
||||
answer:
|
||||
'To import contacts from Google: Go to Data > Import, select Google Contacts, sign in to your Google account, select the contacts you want to import, and click Import.',
|
||||
category: 'features',
|
||||
gettingStarted: {
|
||||
noItems: 'Keine Anleitungen verfügbar.',
|
||||
estimatedTime: 'Geschätzte Zeit',
|
||||
difficulty: {
|
||||
beginner: 'Einsteiger',
|
||||
intermediate: 'Fortgeschritten',
|
||||
advanced: 'Experte',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'faq-2',
|
||||
question: 'How do I export my contacts?',
|
||||
answer:
|
||||
'You can export your contacts in various formats: Go to Data > Export, select the desired format (vCard, CSV, JSON), and click Export.',
|
||||
category: 'features',
|
||||
changelog: {
|
||||
noItems: 'Keine Änderungen verfügbar.',
|
||||
showAll: 'Alle Versionen anzeigen',
|
||||
types: {
|
||||
major: 'Hauptversion',
|
||||
minor: 'Nebenversion',
|
||||
patch: 'Patch',
|
||||
beta: 'Beta',
|
||||
},
|
||||
labels: {
|
||||
features: 'Neue Funktionen',
|
||||
improvements: 'Verbesserungen',
|
||||
bugFixes: 'Fehlerbehebungen',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'faq-3',
|
||||
question: 'How do I find duplicate contacts?',
|
||||
answer:
|
||||
'We automatically detect potential duplicates. Go to Duplicates in the sidebar, review the suggestions, and choose to Merge or Ignore.',
|
||||
category: 'features',
|
||||
contact: {
|
||||
noInfo: 'Keine Kontaktinformationen verfügbar.',
|
||||
email: 'E-Mail senden',
|
||||
responseTime: 'Antwortzeit',
|
||||
},
|
||||
{
|
||||
id: 'faq-4',
|
||||
question: 'How do I cancel my subscription?',
|
||||
answer:
|
||||
'You can cancel your subscription at any time: Go to Settings > Subscription > Manage Subscription > Cancel Subscription. Your subscription will remain active until the end of the billing period.',
|
||||
category: 'billing',
|
||||
common: {
|
||||
back: 'Zurück',
|
||||
showMore: 'Mehr anzeigen',
|
||||
showLess: 'Weniger anzeigen',
|
||||
},
|
||||
{
|
||||
id: 'faq-5',
|
||||
question: 'How is my data protected?',
|
||||
answer:
|
||||
'We take your privacy seriously: All data is encrypted (TLS), we are GDPR compliant, we never sell data, and you can export all your data or delete your account at any time.',
|
||||
category: 'privacy',
|
||||
}
|
||||
: {
|
||||
title: 'Help & Support',
|
||||
subtitle: 'Find answers and learn how to use the app',
|
||||
searchPlaceholder: 'Search help...',
|
||||
sections: {
|
||||
faq: 'FAQ',
|
||||
features: 'Features',
|
||||
shortcuts: 'Shortcuts',
|
||||
gettingStarted: 'Getting Started',
|
||||
changelog: 'Changelog',
|
||||
contact: 'Contact',
|
||||
},
|
||||
]
|
||||
search: {
|
||||
noResults: 'No results for "{query}"',
|
||||
resultsCount: '{count} results',
|
||||
searching: 'Searching...',
|
||||
},
|
||||
faq: {
|
||||
noItems: 'No frequently asked questions available.',
|
||||
allCategories: 'All',
|
||||
categories: {
|
||||
general: 'General',
|
||||
account: 'Account',
|
||||
billing: 'Billing',
|
||||
features: 'Features',
|
||||
technical: 'Technical',
|
||||
privacy: 'Privacy',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
noItems: 'No features available.',
|
||||
comingSoon: 'Coming Soon',
|
||||
learnMore: 'Learn More',
|
||||
},
|
||||
shortcuts: {
|
||||
noItems: 'No keyboard shortcuts available.',
|
||||
columns: {
|
||||
shortcut: 'Shortcut',
|
||||
action: 'Action',
|
||||
description: 'Description',
|
||||
},
|
||||
},
|
||||
gettingStarted: {
|
||||
noItems: 'No guides available.',
|
||||
estimatedTime: 'Estimated time',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
noItems: 'No changelog available.',
|
||||
showAll: 'Show all releases',
|
||||
types: {
|
||||
major: 'Major',
|
||||
minor: 'Minor',
|
||||
patch: 'Patch',
|
||||
beta: 'Beta',
|
||||
},
|
||||
labels: {
|
||||
features: 'New Features',
|
||||
improvements: 'Improvements',
|
||||
bugFixes: 'Bug Fixes',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
noInfo: 'No contact information available.',
|
||||
email: 'Send email',
|
||||
responseTime: 'Response time',
|
||||
},
|
||||
common: {
|
||||
back: 'Back',
|
||||
showMore: 'Show more',
|
||||
showLess: 'Show less',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Features data
|
||||
const features = $derived(
|
||||
$locale === 'de'
|
||||
? [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Kontaktverwaltung',
|
||||
description: 'Verwalte alle deine Kontakte an einem Ort',
|
||||
highlights: [
|
||||
'Unbegrenzte Kontakte',
|
||||
'Benutzerdefinierte Felder',
|
||||
'Tags und Kategorien',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
title: 'Import & Export',
|
||||
description: 'Importiere und exportiere Kontakte in verschiedenen Formaten',
|
||||
highlights: ['Google Kontakte Sync', 'vCard Import/Export', 'CSV Import/Export'],
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Duplikat-Erkennung',
|
||||
description: 'Automatische Erkennung und Zusammenführung von Duplikaten',
|
||||
highlights: [
|
||||
'Intelligente Erkennung',
|
||||
'Ein-Klick Zusammenführung',
|
||||
'Überprüfungsmodus',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Favoriten',
|
||||
description: 'Markiere wichtige Kontakte als Favoriten',
|
||||
highlights: ['Schnellzugriff', 'Verschiedene Ansichten', 'Sortierung'],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Contact Management',
|
||||
description: 'Manage all your contacts in one place',
|
||||
highlights: ['Unlimited contacts', 'Custom fields', 'Tags and categories'],
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
title: 'Import & Export',
|
||||
description: 'Import and export contacts in various formats',
|
||||
highlights: ['Google Contacts sync', 'vCard import/export', 'CSV import/export'],
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Duplicate Detection',
|
||||
description: 'Automatic detection and merging of duplicates',
|
||||
highlights: ['Smart detection', 'One-click merge', 'Review mode'],
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Favorites',
|
||||
description: 'Mark important contacts as favorites',
|
||||
highlights: ['Quick access', 'Multiple views', 'Sorting'],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const shortcuts = [
|
||||
{ shortcut: 'Cmd/Ctrl + K', action: $locale === 'de' ? 'Suche öffnen' : 'Open search' },
|
||||
{ shortcut: 'Cmd/Ctrl + N', action: $locale === 'de' ? 'Neuer Kontakt' : 'New contact' },
|
||||
{ shortcut: 'Cmd/Ctrl + 1-6', action: $locale === 'de' ? 'Navigation' : 'Navigation' },
|
||||
];
|
||||
|
||||
// Translations
|
||||
const t = $derived({
|
||||
title: $locale === 'de' ? 'Hilfe & Support' : 'Help & Support',
|
||||
subtitle:
|
||||
$locale === 'de'
|
||||
? 'Finde Antworten und lerne die App kennen'
|
||||
: 'Find answers and learn how to use the app',
|
||||
searchPlaceholder: $locale === 'de' ? 'Hilfe durchsuchen...' : 'Search help...',
|
||||
sections: {
|
||||
faq: 'FAQ',
|
||||
features: 'Features',
|
||||
shortcuts: $locale === 'de' ? 'Tastenkürzel' : 'Shortcuts',
|
||||
contact: $locale === 'de' ? 'Kontakt' : 'Contact',
|
||||
},
|
||||
back: $locale === 'de' ? 'Zurück' : 'Back',
|
||||
contactTitle: $locale === 'de' ? 'Support kontaktieren' : 'Contact Support',
|
||||
contactDescription:
|
||||
$locale === 'de'
|
||||
? 'Unser Support-Team hilft dir bei allen Fragen.'
|
||||
: 'Our support team is here to help you.',
|
||||
email: $locale === 'de' ? 'E-Mail senden' : 'Send email',
|
||||
responseTime: $locale === 'de' ? 'Antwortzeit: 24 Stunden' : 'Response time: 24 hours',
|
||||
});
|
||||
|
||||
// Filter FAQs based on search
|
||||
const filteredFaqs = $derived(
|
||||
searchQuery.trim().length > 0
|
||||
? faqs.filter(
|
||||
(faq) =>
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: faqs
|
||||
);
|
||||
|
||||
function toggleFaq(id: string) {
|
||||
expandedFaqId = expandedFaqId === id ? null : id;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
const sections: { id: HelpSection; label: string }[] = $derived([
|
||||
{ id: 'faq', label: t.sections.faq },
|
||||
{ id: 'features', label: t.sections.features },
|
||||
{ id: 'shortcuts', label: t.sections.shortcuts },
|
||||
{ id: 'contact', label: t.sections.contact },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.title} | Contacts</title>
|
||||
<title>{translations.title} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-4 inline-flex items-center gap-1.5 rounded-full bg-white/80 px-3 py-1.5 text-sm font-medium text-gray-600 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:bg-white hover:text-gray-900 hover:shadow-md dark:bg-white/10 dark:text-gray-400 dark:ring-white/10 dark:hover:bg-white/20 dark:hover:text-gray-100"
|
||||
onclick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t.back}
|
||||
</button>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search - Glass Style -->
|
||||
<div class="relative mb-8">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={t.searchPlaceholder}
|
||||
class="w-full rounded-full border border-gray-200 bg-white/80 py-3 pl-11 pr-4 text-sm text-gray-900 shadow-sm backdrop-blur-sm placeholder:text-gray-500 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-400/20 dark:border-white/10 dark:bg-white/10 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-blue-500/50 dark:focus:bg-white/15 dark:focus:ring-blue-500/20"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<MagnifyingGlass size={18} class="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Pills -->
|
||||
<div class="mb-8 flex flex-wrap gap-2">
|
||||
{#each sections as section (section.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium shadow-sm backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:shadow-md {activeSection ===
|
||||
section.id
|
||||
? 'bg-blue-500 text-white ring-1 ring-blue-400 dark:bg-blue-600 dark:ring-blue-500'
|
||||
: 'bg-white/80 text-gray-700 ring-1 ring-gray-200 hover:bg-white dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20'}"
|
||||
onclick={() => (activeSection = section.id)}
|
||||
>
|
||||
{#if section.id === 'faq'}
|
||||
<Question size={16} />
|
||||
{:else if section.id === 'features'}
|
||||
<Star size={16} />
|
||||
{:else if section.id === 'shortcuts'}
|
||||
<Command size={16} />
|
||||
{:else if section.id === 'contact'}
|
||||
<Envelope size={16} />
|
||||
{/if}
|
||||
{section.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]">
|
||||
<!-- FAQ Section -->
|
||||
{#if activeSection === 'faq'}
|
||||
<div class="space-y-3">
|
||||
{#each filteredFaqs as faq (faq.id)}
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-white/80 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm transition-all dark:bg-white/5 dark:ring-white/10 {expandedFaqId ===
|
||||
faq.id
|
||||
? 'ring-blue-300 dark:ring-blue-500/30'
|
||||
: ''}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-gray-50/50 dark:hover:bg-white/5"
|
||||
onclick={() => toggleFaq(faq.id)}
|
||||
>
|
||||
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition-all duration-200 dark:bg-white/10 dark:text-gray-400 {expandedFaqId ===
|
||||
faq.id
|
||||
? 'rotate-180 bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
||||
: ''}"
|
||||
>
|
||||
<CaretDown size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expandedFaqId === faq.id}
|
||||
<div
|
||||
class="border-t border-gray-100 bg-gray-50/50 px-4 py-4 text-gray-600 dark:border-white/5 dark:bg-white/5 dark:text-gray-300"
|
||||
>
|
||||
{faq.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if filteredFaqs.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-2xl bg-white/80 py-12 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<MagnifyingGlass size={32} class="mb-3 text-gray-400" />
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{$locale === 'de' ? 'Keine Ergebnisse gefunden' : 'No results found'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Features Section -->
|
||||
{#if activeSection === 'features'}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each features as feature}
|
||||
<div
|
||||
class="group rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm transition-all hover:-translate-y-1 hover:shadow-lg dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-xl transition-transform group-hover:scale-110 dark:bg-blue-500/20"
|
||||
>
|
||||
{feature.icon}
|
||||
</span>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{feature.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
<ul class="space-y-2">
|
||||
{#each feature.highlights as highlight}
|
||||
<li class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/20"
|
||||
>
|
||||
<Check size={12} class="text-green-600 dark:text-green-400" />
|
||||
</span>
|
||||
{highlight}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shortcuts Section -->
|
||||
{#if activeSection === 'shortcuts'}
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-white/80 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<div class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{#each shortcuts as shortcut, i}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 transition-colors hover:bg-gray-50/50 dark:hover:bg-white/5"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">{shortcut.action}</span>
|
||||
<kbd
|
||||
class="ml-4 flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1.5 font-mono text-xs font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
|
||||
>
|
||||
{shortcut.shortcut}
|
||||
</kbd>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip -->
|
||||
<div
|
||||
class="mt-4 flex items-start gap-3 rounded-2xl bg-blue-50/80 p-4 ring-1 ring-blue-100 backdrop-blur-sm dark:bg-blue-500/10 dark:ring-blue-500/20"
|
||||
>
|
||||
<Info size={20} class="mt-0.5 flex-shrink-0 text-blue-500" />
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{$locale === 'de'
|
||||
? 'Tipp: Drücke Cmd/Ctrl + K, um jederzeit schnell zur Suche zu gelangen.'
|
||||
: 'Tip: Press Cmd/Ctrl + K to quickly access search anytime.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section -->
|
||||
{#if activeSection === 'contact'}
|
||||
<div class="space-y-6">
|
||||
<!-- Header Card -->
|
||||
<div
|
||||
class="rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 p-6 text-white shadow-lg dark:from-blue-600 dark:to-blue-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-white/20">
|
||||
<ChatCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{t.contactTitle}</h2>
|
||||
<p class="text-blue-100">{t.contactDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Options -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="mailto:support@mana.how"
|
||||
class="group flex items-center gap-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm transition-all hover:-translate-y-1 hover:shadow-lg dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-transform group-hover:scale-110 dark:bg-blue-500/20 dark:text-blue-400"
|
||||
>
|
||||
<Envelope size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{t.email}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">support@mana.how</p>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={18}
|
||||
class="ml-auto text-gray-400 transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-gray-200 backdrop-blur-sm dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100 text-green-600 dark:bg-green-500/20 dark:text-green-400"
|
||||
>
|
||||
<Clock size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{$locale === 'de' ? 'Antwortzeit' : 'Response Time'}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Info -->
|
||||
<div
|
||||
class="rounded-2xl bg-gray-50/80 p-5 ring-1 ring-gray-200 backdrop-blur-sm dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<h3 class="mb-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{$locale === 'de' ? 'Weitere Ressourcen' : 'Additional Resources'}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="https://mana.how"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-sm text-gray-600 transition-colors hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
>
|
||||
<ArrowSquareOut size={14} />
|
||||
{$locale === 'de' ? 'Website besuchen' : 'Visit Website'}
|
||||
</a>
|
||||
<a
|
||||
href="https://mana.how/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-sm text-gray-600 transition-colors hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
{$locale === 'de' ? 'Dokumentation' : 'Documentation'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<HelpPage
|
||||
{content}
|
||||
appName="Contacts"
|
||||
appId="contacts"
|
||||
{translations}
|
||||
showBackButton
|
||||
onBack={handleBack}
|
||||
showGettingStarted={false}
|
||||
showChangelog={false}
|
||||
defaultSection="faq"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,11 @@
|
|||
"@manacore/shared-help-types": "workspace:*",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"marked": "^15.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"zod": "^3.24.1"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export {
|
|||
// Content merger
|
||||
export { mergeContent, createEmptyContent } from './merger.js';
|
||||
|
||||
// Sanitization
|
||||
export { sanitizeHtml } from './sanitize.js';
|
||||
|
||||
// Search functionality
|
||||
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ import {
|
|||
gettingStartedFrontmatterSchema,
|
||||
changelogFrontmatterSchema,
|
||||
contactFrontmatterSchema,
|
||||
type FAQFrontmatter,
|
||||
type FeatureFrontmatter,
|
||||
type ShortcutsFrontmatter,
|
||||
type GettingStartedFrontmatter,
|
||||
type ChangelogFrontmatter,
|
||||
type ContactFrontmatter,
|
||||
} from '@manacore/shared-help-types';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { createEmptyContent } from './merger.js';
|
||||
|
|
@ -29,26 +35,28 @@ export interface LoaderOptions {
|
|||
locale: SupportedLanguage;
|
||||
/** Fallback locale if content not found */
|
||||
fallbackLocale?: SupportedLanguage;
|
||||
/** Optional error callback */
|
||||
onError?: (path: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse FAQ content from raw Markdown
|
||||
*/
|
||||
export function parseFAQContent(rawContent: string): FAQItem {
|
||||
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<FAQFrontmatter>(rawContent, faqFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
question: fm.question as string,
|
||||
category: fm.category as FAQItem['category'],
|
||||
featured: fm.featured as boolean | undefined,
|
||||
tags: fm.tags as string[] | undefined,
|
||||
relatedFaqs: fm.relatedFaqs as string[] | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
question: fm.question,
|
||||
category: fm.category,
|
||||
featured: fm.featured,
|
||||
tags: fm.tags,
|
||||
relatedFaqs: fm.relatedFaqs,
|
||||
answer: parsed.html,
|
||||
};
|
||||
}
|
||||
|
|
@ -57,23 +65,23 @@ export function parseFAQContent(rawContent: string): FAQItem {
|
|||
* Parse Feature content from raw Markdown
|
||||
*/
|
||||
export function parseFeatureContent(rawContent: string): FeatureItem {
|
||||
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<FeatureFrontmatter>(rawContent, featureFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
icon: fm.icon as string | undefined,
|
||||
category: fm.category as FeatureItem['category'],
|
||||
available: fm.available as boolean | undefined,
|
||||
comingSoon: fm.comingSoon as boolean | undefined,
|
||||
highlights: fm.highlights as string[] | undefined,
|
||||
learnMoreUrl: fm.learnMoreUrl as string | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
title: fm.title,
|
||||
description: fm.description,
|
||||
icon: fm.icon,
|
||||
category: fm.category,
|
||||
available: fm.available,
|
||||
comingSoon: fm.comingSoon,
|
||||
highlights: fm.highlights,
|
||||
learnMoreUrl: fm.learnMoreUrl,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
|
@ -82,21 +90,21 @@ export function parseFeatureContent(rawContent: string): FeatureItem {
|
|||
* Parse Shortcuts content from raw Markdown
|
||||
*/
|
||||
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
|
||||
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<ShortcutsFrontmatter>(rawContent, shortcutsFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
|
||||
// Parse markdown table to extract shortcuts
|
||||
const shortcuts = parseShortcutsTable(parsed.content);
|
||||
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
category: fm.category as ShortcutsItem['category'],
|
||||
title: fm.title as string | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
category: fm.category,
|
||||
title: fm.title,
|
||||
shortcuts,
|
||||
};
|
||||
}
|
||||
|
|
@ -114,8 +122,8 @@ function parseShortcutsTable(
|
|||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip header separator
|
||||
if (trimmed.match(/^\|[-:\s|]+\|$/)) {
|
||||
// Skip header separator (flexible: allows spaces around dashes)
|
||||
if (trimmed.match(/^\|[\s\-:|]+\|$/)) {
|
||||
inTable = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -147,24 +155,27 @@ function parseShortcutsTable(
|
|||
* Parse Getting Started guide content from raw Markdown
|
||||
*/
|
||||
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
|
||||
const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<GettingStartedFrontmatter>(
|
||||
rawContent,
|
||||
gettingStartedFrontmatterSchema
|
||||
);
|
||||
const fm = parsed.frontmatter;
|
||||
|
||||
// Extract steps from content (h2 headers)
|
||||
const steps = parseGuideSteps(parsed.content);
|
||||
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
difficulty: fm.difficulty as GettingStartedItem['difficulty'],
|
||||
estimatedTime: fm.estimatedTime as string | undefined,
|
||||
prerequisites: fm.prerequisites as string[] | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
title: fm.title,
|
||||
description: fm.description,
|
||||
difficulty: fm.difficulty,
|
||||
estimatedTime: fm.estimatedTime,
|
||||
prerequisites: fm.prerequisites,
|
||||
content: parsed.html,
|
||||
steps,
|
||||
};
|
||||
|
|
@ -180,6 +191,10 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
|
|||
for (let i = 1; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const newlineIndex = section.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
steps.push({ title: section.trim(), content: '' });
|
||||
continue;
|
||||
}
|
||||
const title = section.substring(0, newlineIndex).trim();
|
||||
const stepContent = section.substring(newlineIndex + 1).trim();
|
||||
|
||||
|
|
@ -193,23 +208,23 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
|
|||
* Parse Changelog content from raw Markdown
|
||||
*/
|
||||
export function parseChangelogContent(rawContent: string): ChangelogItem {
|
||||
const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<ChangelogFrontmatter>(rawContent, changelogFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
version: fm.version as string,
|
||||
title: fm.title as string,
|
||||
releaseDate: fm.releaseDate as Date,
|
||||
type: fm.type as ChangelogItem['type'],
|
||||
summary: fm.summary as string | undefined,
|
||||
highlighted: fm.highlighted as boolean | undefined,
|
||||
changes: fm.changes as ChangelogItem['changes'],
|
||||
platforms: fm.platforms as string[] | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
version: fm.version,
|
||||
title: fm.title,
|
||||
releaseDate: fm.releaseDate,
|
||||
type: fm.type,
|
||||
summary: fm.summary,
|
||||
highlighted: fm.highlighted,
|
||||
changes: fm.changes,
|
||||
platforms: fm.platforms,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
|
@ -218,22 +233,22 @@ export function parseChangelogContent(rawContent: string): ChangelogItem {
|
|||
* Parse Contact content from raw Markdown
|
||||
*/
|
||||
export function parseContactContent(rawContent: string): ContactInfo {
|
||||
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
const parsed = parseMarkdown<ContactFrontmatter>(rawContent, contactFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
supportEmail: fm.supportEmail as string | undefined,
|
||||
supportUrl: fm.supportUrl as string | undefined,
|
||||
discordUrl: fm.discordUrl as string | undefined,
|
||||
twitterUrl: fm.twitterUrl as string | undefined,
|
||||
documentationUrl: fm.documentationUrl as string | undefined,
|
||||
responseTime: fm.responseTime as string | undefined,
|
||||
id: fm.id,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
title: fm.title,
|
||||
supportEmail: fm.supportEmail,
|
||||
supportUrl: fm.supportUrl,
|
||||
discordUrl: fm.discordUrl,
|
||||
twitterUrl: fm.twitterUrl,
|
||||
documentationUrl: fm.documentationUrl,
|
||||
responseTime: fm.responseTime,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
|
@ -247,7 +262,7 @@ export function loadHelpContentFromFiles(
|
|||
options: LoaderOptions
|
||||
): HelpContent {
|
||||
const content = createEmptyContent();
|
||||
const { locale, fallbackLocale = 'en' } = options;
|
||||
const { locale, fallbackLocale = 'en', onError } = options;
|
||||
|
||||
for (const [path, rawContent] of Object.entries(files)) {
|
||||
try {
|
||||
|
|
@ -283,8 +298,12 @@ export function loadHelpContentFromFiles(
|
|||
content.contact = contact;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that fail to parse
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(path, error);
|
||||
} else {
|
||||
console.warn('[shared-help] Failed to parse:', path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import type { ZodSchema } from 'zod';
|
||||
import type { ZodType, ZodTypeDef } from 'zod';
|
||||
import { sanitizeHtml } from './sanitize.js';
|
||||
|
||||
export interface ParsedContent<T> {
|
||||
frontmatter: T;
|
||||
|
|
@ -23,7 +24,7 @@ export interface ParseOptions {
|
|||
*/
|
||||
export function parseMarkdown<T>(
|
||||
rawContent: string,
|
||||
schema?: ZodSchema<T>,
|
||||
schema?: ZodType<T, ZodTypeDef, unknown>,
|
||||
options: ParseOptions = { renderHtml: true }
|
||||
): ParsedContent<T> {
|
||||
const { data, content } = matter(rawContent);
|
||||
|
|
@ -40,8 +41,8 @@ export function parseMarkdown<T>(
|
|||
frontmatter = data as T;
|
||||
}
|
||||
|
||||
// Render HTML if requested
|
||||
const html = options.renderHtml ? (marked.parse(content) as string) : '';
|
||||
// Render HTML if requested, then sanitize to prevent XSS
|
||||
const html = options.renderHtml ? sanitizeHtml(marked.parse(content) as string) : '';
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
|
|
@ -55,7 +56,7 @@ export function parseMarkdown<T>(
|
|||
*/
|
||||
export function parseMarkdownFiles<T>(
|
||||
files: { filename: string; content: string }[],
|
||||
schema?: ZodSchema<T>,
|
||||
schema?: ZodType<T, ZodTypeDef, unknown>,
|
||||
options?: ParseOptions
|
||||
): Array<ParsedContent<T> & { filename: string }> {
|
||||
return files.map(({ filename, content }) => ({
|
||||
|
|
|
|||
53
packages/shared-help-content/src/sanitize.ts
Normal file
53
packages/shared-help-content/src/sanitize.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* HTML Sanitization
|
||||
* Prevents XSS when rendering Markdown-generated HTML via {@html}
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks.
|
||||
* Allows safe HTML tags commonly used in help content (headings, lists, links, etc.)
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'p',
|
||||
'br',
|
||||
'hr',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'a',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'code',
|
||||
'pre',
|
||||
'blockquote',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'mark',
|
||||
'kbd',
|
||||
'img',
|
||||
'span',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'title', 'class', 'id'],
|
||||
ADD_ATTR: ['target'],
|
||||
});
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
SearchIndexConfig,
|
||||
} from '@manacore/shared-help-types';
|
||||
import { generateExcerpt, stripHtml } from './parser.js';
|
||||
import { sanitizeHtml } from './sanitize.js';
|
||||
|
||||
const DEFAULT_CONFIG: SearchIndexConfig = {
|
||||
titleWeight: 2,
|
||||
|
|
@ -135,8 +136,10 @@ function findOriginalItem(
|
|||
*/
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!query.trim()) return text;
|
||||
// Sanitize text first, then apply highlighting
|
||||
const safeText = sanitizeHtml(text);
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
return text.replace(regex, '<mark>$1</mark>');
|
||||
return safeText.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
249
packages/shared-help-ui/README.md
Normal file
249
packages/shared-help-ui/README.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# @manacore/shared-help-ui
|
||||
|
||||
Shared Svelte 5 help page components for Manacore web apps.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add dependencies to your web app
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@manacore/shared-help-ui": "workspace:*",
|
||||
"@manacore/shared-help-content": "workspace:*",
|
||||
"@manacore/shared-help-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create help content
|
||||
|
||||
Create `src/lib/content/help/index.ts`:
|
||||
|
||||
```typescript
|
||||
import type { HelpContent } from '@manacore/shared-help-types';
|
||||
|
||||
export function getAppHelpContent(locale: string): HelpContent {
|
||||
const isDE = locale === 'de';
|
||||
return {
|
||||
faq: [
|
||||
{
|
||||
id: 'faq-example',
|
||||
question: isDE ? 'Wie funktioniert X?' : 'How does X work?',
|
||||
answer: isDE ? '<p>So funktioniert X...</p>' : '<p>X works like this...</p>',
|
||||
category: 'features',
|
||||
order: 1,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: ['example'],
|
||||
},
|
||||
],
|
||||
features: [],
|
||||
shortcuts: [
|
||||
{
|
||||
id: 'shortcuts-general',
|
||||
category: 'general',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
shortcuts: [
|
||||
{ shortcut: 'Cmd/Ctrl + K', action: isDE ? 'Suche' : 'Search' },
|
||||
],
|
||||
},
|
||||
],
|
||||
gettingStarted: [],
|
||||
changelog: [],
|
||||
contact: {
|
||||
id: 'contact',
|
||||
title: isDE ? 'Support' : 'Support',
|
||||
content: '',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
supportEmail: 'support@mana.how',
|
||||
responseTime: isDE ? 'Innerhalb von 24 Stunden' : 'Within 24 hours',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create the help page route
|
||||
|
||||
Create `src/routes/(app)/help/+page.svelte`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { HelpPage } from '@manacore/shared-help-ui';
|
||||
import type { HelpPageTranslations } from '@manacore/shared-help-ui';
|
||||
import { getAppHelpContent } from '$lib/content/help/index.js';
|
||||
|
||||
const content = $derived(getAppHelpContent($locale ?? 'en'));
|
||||
const translations: HelpPageTranslations = $derived(/* see translations template below */);
|
||||
</script>
|
||||
|
||||
<HelpPage
|
||||
{content}
|
||||
appName="MyApp"
|
||||
appId="myapp"
|
||||
{translations}
|
||||
showBackButton
|
||||
onBack={() => goto('/')}
|
||||
showGettingStarted={false}
|
||||
showChangelog={false}
|
||||
/>
|
||||
```
|
||||
|
||||
## HelpPage Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `content` | `HelpContent` | required | Help content data |
|
||||
| `appName` | `string` | required | Display name of the app |
|
||||
| `appId` | `string` | required | App identifier |
|
||||
| `translations` | `HelpPageTranslations` | required | UI translations |
|
||||
| `searchEnabled` | `boolean` | `true` | Show search bar |
|
||||
| `showFAQ` | `boolean` | `true` | Show FAQ section |
|
||||
| `showFeatures` | `boolean` | `true` | Show Features section |
|
||||
| `showShortcuts` | `boolean` | `true` | Show Shortcuts section |
|
||||
| `showGettingStarted` | `boolean` | `true` | Show Getting Started section |
|
||||
| `showChangelog` | `boolean` | `true` | Show Changelog section |
|
||||
| `showContact` | `boolean` | `true` | Show Contact section |
|
||||
| `defaultSection` | `HelpSection` | `'faq'` | Initially active section |
|
||||
| `showBackButton` | `boolean` | `false` | Show back navigation |
|
||||
| `onBack` | `() => void` | - | Back button callback |
|
||||
|
||||
Sections with empty content are automatically hidden.
|
||||
|
||||
## Translations Template
|
||||
|
||||
```typescript
|
||||
const translations: HelpPageTranslations = {
|
||||
title: 'Help & Support',
|
||||
subtitle: 'Find answers and learn how to use the app',
|
||||
searchPlaceholder: 'Search help...',
|
||||
sections: {
|
||||
faq: 'FAQ',
|
||||
features: 'Features',
|
||||
shortcuts: 'Shortcuts',
|
||||
gettingStarted: 'Getting Started',
|
||||
changelog: 'Changelog',
|
||||
contact: 'Contact',
|
||||
},
|
||||
search: {
|
||||
noResults: 'No results for "{query}"',
|
||||
resultsCount: '{count} results',
|
||||
searching: 'Searching...',
|
||||
},
|
||||
faq: {
|
||||
noItems: 'No FAQs available.',
|
||||
allCategories: 'All',
|
||||
categories: {
|
||||
general: 'General',
|
||||
account: 'Account',
|
||||
billing: 'Billing',
|
||||
features: 'Features',
|
||||
technical: 'Technical',
|
||||
privacy: 'Privacy',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
noItems: 'No features available.',
|
||||
comingSoon: 'Coming Soon',
|
||||
learnMore: 'Learn More',
|
||||
},
|
||||
shortcuts: {
|
||||
noItems: 'No shortcuts available.',
|
||||
columns: {
|
||||
shortcut: 'Shortcut',
|
||||
action: 'Action',
|
||||
description: 'Description',
|
||||
},
|
||||
},
|
||||
gettingStarted: {
|
||||
noItems: 'No guides available.',
|
||||
estimatedTime: 'Estimated time',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
noItems: 'No changelog available.',
|
||||
showAll: 'Show all releases',
|
||||
types: { major: 'Major', minor: 'Minor', patch: 'Patch', beta: 'Beta' },
|
||||
labels: {
|
||||
features: 'New Features',
|
||||
improvements: 'Improvements',
|
||||
bugFixes: 'Bug Fixes',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
noInfo: 'No contact info available.',
|
||||
email: 'Send email',
|
||||
responseTime: 'Response time',
|
||||
},
|
||||
common: {
|
||||
back: 'Back',
|
||||
showMore: 'Show more',
|
||||
showLess: 'Show less',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
### FAQ
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // Unique ID
|
||||
question: string; // The question
|
||||
answer: string; // HTML answer (auto-sanitized)
|
||||
category: 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
|
||||
order: number;
|
||||
language: 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||
featured?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Shortcuts
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string;
|
||||
category: 'navigation' | 'editing' | 'general' | 'app-specific';
|
||||
language: string;
|
||||
order: number;
|
||||
shortcuts: Array<{
|
||||
shortcut: string; // e.g. "Cmd/Ctrl + K"
|
||||
action: string; // e.g. "Open search"
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Contact
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string; // HTML (auto-sanitized)
|
||||
language: string;
|
||||
order: number;
|
||||
supportEmail?: string;
|
||||
supportUrl?: string;
|
||||
discordUrl?: string;
|
||||
documentationUrl?: string;
|
||||
responseTime?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
All HTML content is automatically sanitized via `isomorphic-dompurify` in the parser layer.
|
||||
Content passed through `{@html}` in components is safe against XSS.
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See `apps/contacts/apps/web/src/routes/(app)/help/+page.svelte` for a complete working example.
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogItem } from '@manacore/shared-help-types';
|
||||
import type { ChangelogEntryProps } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
item: ChangelogItem;
|
||||
typeLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
let { item, typeLabels }: Props = $props();
|
||||
let { item, translations }: ChangelogEntryProps = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
|
|
@ -32,6 +27,9 @@
|
|||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
const typeLabels = translations.changelog.types;
|
||||
const changeLabels = translations.changelog.labels;
|
||||
</script>
|
||||
|
||||
<div class="border-b border-gray-200 py-4 dark:border-gray-700">
|
||||
|
|
@ -83,7 +81,7 @@
|
|||
{#if item.changes.features && item.changes.features.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
New Features
|
||||
{changeLabels.features}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.features as change}
|
||||
|
|
@ -104,7 +102,7 @@
|
|||
{#if item.changes.improvements && item.changes.improvements.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
Improvements
|
||||
{changeLabels.improvements}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.improvements as change}
|
||||
|
|
@ -124,7 +122,9 @@
|
|||
|
||||
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">Bug Fixes</h5>
|
||||
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">
|
||||
{changeLabels.bugFixes}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.bugfixes as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
{:else}
|
||||
<div>
|
||||
{#each displayedItems() as item (item.id)}
|
||||
<ChangelogEntry {item} typeLabels={translations.changelog.types} />
|
||||
<ChangelogEntry {item} {translations} />
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
Show all releases ({items.length - maxItems} more)
|
||||
{translations.changelog.showAll} ({items.length - maxItems})
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,8 @@
|
|||
let selectedCategory = $state<FAQCategory | 'all'>('all');
|
||||
let showAll = $state(false);
|
||||
|
||||
const categories: FAQCategory[] = [
|
||||
'general',
|
||||
'account',
|
||||
'billing',
|
||||
'features',
|
||||
'technical',
|
||||
'privacy',
|
||||
];
|
||||
// Derive categories from actual items instead of hardcoding
|
||||
const categories = $derived([...new Set(items.map((item) => item.category))] as FAQCategory[]);
|
||||
|
||||
const filteredItems = $derived(() => {
|
||||
let result = items;
|
||||
|
|
@ -62,27 +56,24 @@
|
|||
class:dark:text-gray-400={selectedCategory !== 'all'}
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
All
|
||||
{translations.faq.allCategories}
|
||||
</button>
|
||||
{#each categories as category}
|
||||
{@const hasItems = items.some((item) => item.category === category)}
|
||||
{#if hasItems}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === category}
|
||||
class:text-primary-700={selectedCategory === category}
|
||||
class:dark:bg-primary-900={selectedCategory === category}
|
||||
class:dark:text-primary-300={selectedCategory === category}
|
||||
class:bg-gray-100={selectedCategory !== category}
|
||||
class:text-gray-600={selectedCategory !== category}
|
||||
class:dark:bg-gray-800={selectedCategory !== category}
|
||||
class:dark:text-gray-400={selectedCategory !== category}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === category}
|
||||
class:text-primary-700={selectedCategory === category}
|
||||
class:dark:bg-primary-900={selectedCategory === category}
|
||||
class:dark:text-primary-300={selectedCategory === category}
|
||||
class:bg-gray-100={selectedCategory !== category}
|
||||
class:text-gray-600={selectedCategory !== category}
|
||||
class:dark:bg-gray-800={selectedCategory !== category}
|
||||
class:dark:text-gray-400={selectedCategory !== category}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
|
||||
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
|
||||
|
||||
const selectedGuide = $derived(() => {
|
||||
return items.find((item) => item.id === selectedGuideId) ?? null;
|
||||
});
|
||||
const guide = $derived(items.find((item) => item.id === selectedGuideId) ?? null);
|
||||
|
||||
function getDifficultyLabel(difficulty: string): string {
|
||||
return (
|
||||
|
|
@ -67,8 +65,7 @@
|
|||
|
||||
<!-- Guide Content -->
|
||||
<div class="flex-1">
|
||||
{#if selectedGuide()}
|
||||
{@const guide = selectedGuide()}
|
||||
{#if guide}
|
||||
<div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{guide.title}
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@
|
|||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay to allow click on result
|
||||
setTimeout(() => {
|
||||
showResults = false;
|
||||
}, 200);
|
||||
function handleBlur(event: FocusEvent) {
|
||||
// Only close if focus moves outside the search container
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
if (relatedTarget?.closest('[data-help-search]')) return;
|
||||
showResults = false;
|
||||
}
|
||||
|
||||
function getTypeIcon(type: string): string {
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative" data-help-search>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -118,10 +118,19 @@
|
|||
onblur={handleBlur}
|
||||
placeholder={placeholder ?? translations.search.noResults}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
aria-label={placeholder ?? translations.search.noResults}
|
||||
role="combobox"
|
||||
aria-expanded={showResults}
|
||||
aria-haspopup="listbox"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{#if isSearching}
|
||||
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
|
|
@ -131,7 +140,13 @@
|
|||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
@ -146,6 +161,7 @@
|
|||
{#if showResults}
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
role="listbox"
|
||||
>
|
||||
{#if results.length === 0}
|
||||
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -154,17 +170,21 @@
|
|||
{:else}
|
||||
<ul class="max-h-96 overflow-auto py-2">
|
||||
{#each results as result, index (result.id)}
|
||||
<li>
|
||||
<li role="option" aria-selected={selectedIndex === index}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
|
||||
index
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: ''}"
|
||||
onclick={() => selectResult(result)}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
selectResult(result);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
|
||||
aria-label={getTypeLabel(result.type)}
|
||||
>
|
||||
{getTypeIcon(result.type)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -25,9 +25,15 @@
|
|||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.shortcut}</th
|
||||
>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.action}</th
|
||||
>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.description}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type {
|
|||
FeaturesOverviewProps,
|
||||
KeyboardShortcutsProps,
|
||||
GettingStartedGuideProps,
|
||||
ChangelogEntryProps,
|
||||
ChangelogSectionProps,
|
||||
ContactSectionProps,
|
||||
HelpSearchProps,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
let {
|
||||
content,
|
||||
appName,
|
||||
appId: _appId,
|
||||
appId,
|
||||
translations,
|
||||
searchEnabled = true,
|
||||
showFAQ = true,
|
||||
|
|
@ -57,9 +57,13 @@
|
|||
|
||||
const visibleSections = $derived(sections.filter((s) => s.show));
|
||||
|
||||
let contentRef: HTMLDivElement;
|
||||
|
||||
function setActiveSection(section: HelpSection) {
|
||||
activeSection = section;
|
||||
onSectionChange?.(section);
|
||||
// Scroll content area into view on tab change
|
||||
contentRef?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleSearchResultSelect(result: SearchResult) {
|
||||
|
|
@ -151,7 +155,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]">
|
||||
<div class="min-h-[400px]" bind:this={contentRef}>
|
||||
{#if activeSection === 'faq' && showFAQ}
|
||||
<FAQSection items={content.faq} {translations} expandFirst />
|
||||
{:else if activeSection === 'features' && showFeatures}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
allCategories: string;
|
||||
categories: {
|
||||
general: string;
|
||||
account: string;
|
||||
|
|
@ -43,6 +44,11 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
columns: {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
|
|
@ -55,12 +61,18 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
showAll: string;
|
||||
types: {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
beta: string;
|
||||
};
|
||||
labels: {
|
||||
features: string;
|
||||
improvements: string;
|
||||
bugFixes: string;
|
||||
};
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
|
|
@ -113,6 +125,11 @@ export interface FAQSectionProps {
|
|||
expandFirst?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangelogEntryProps {
|
||||
item: HelpContent['changelog'][number];
|
||||
translations: Pick<HelpPageTranslations, 'changelog'>;
|
||||
}
|
||||
|
||||
export interface FeaturesOverviewProps {
|
||||
items: HelpContent['features'];
|
||||
translations: Pick<HelpPageTranslations, 'features'>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue