mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
fix(packages): fix type errors in consolidated packages + add missing files
- credits: fix mobile import paths (./operations → ../operations) - feedback: fix createFeedbackService import (./feedback → ./api), recover missing types.ts from git history - help: add package files (were untracked after consolidation) - Update lockfile after package restructuring All packages pass tsc --noEmit (excluding expected .svelte imports). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cbd19c24ed
commit
5e05c532a2
30 changed files with 3917 additions and 6341 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { formatCreditCost } from './operations';
|
||||
import { formatCreditCost } from '../operations';
|
||||
|
||||
interface CreditBalanceProps {
|
||||
/** Current credit balance */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
|
||||
import { formatCreditCost } from './operations';
|
||||
import { formatCreditCost } from '../operations';
|
||||
|
||||
interface CreditToastProps {
|
||||
/** The operation name or description */
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import type {
|
|||
FeedbackResponse,
|
||||
FeedbackListResponse,
|
||||
VoteResponse,
|
||||
} from './feedback';
|
||||
} from './api';
|
||||
import type { FeedbackServiceConfig } from './types';
|
||||
|
||||
/**
|
||||
|
|
|
|||
36
packages/help/package.json
Normal file
36
packages/help/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@manacore/help",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Unified help package — types, content utilities, and UI components",
|
||||
"type": "module",
|
||||
"svelte": "./src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"svelte": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-i18n": "^4.0.0"
|
||||
}
|
||||
}
|
||||
152
packages/help/src/components/ChangelogEntry.svelte
Normal file
152
packages/help/src/components/ChangelogEntry.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogEntryProps } from '../types.js';
|
||||
|
||||
let { item, translations }: ChangelogEntryProps = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'major':
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
|
||||
case 'minor':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'patch':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'beta':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between text-left"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
v{item.version}
|
||||
</span>
|
||||
<span class={`rounded-full px-2 py-0.5 text-xs font-medium ${getTypeColor(item.type)}`}>
|
||||
{typeLabels[item.type] ?? item.type}
|
||||
</span>
|
||||
{#if item.highlighted}
|
||||
<span
|
||||
class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h4 class="mt-1 font-medium text-gray-800 dark:text-gray-200">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(item.releaseDate)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="mt-1 text-gray-500 transition-transform duration-200 dark:text-gray-400"
|
||||
class:rotate-180={expanded}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="mt-4 space-y-4">
|
||||
{#if item.summary}
|
||||
<p class="text-gray-600 dark:text-gray-400">{item.summary}</p>
|
||||
{/if}
|
||||
|
||||
{#if item.changes}
|
||||
{#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">
|
||||
{changeLabels.features}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.features as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-500">+</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{changeLabels.improvements}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.improvements as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-blue-500">↑</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{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">
|
||||
<span class="text-red-500">×</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if item.content}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html item.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
45
packages/help/src/components/ChangelogSection.svelte
Normal file
45
packages/help/src/components/ChangelogSection.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogSectionProps } from '../types.js';
|
||||
import ChangelogEntry from './ChangelogEntry.svelte';
|
||||
|
||||
let { items, translations, maxItems = 10 }: ChangelogSectionProps = $props();
|
||||
|
||||
let showAll = $state(false);
|
||||
|
||||
const sortedItems = $derived(() => {
|
||||
return [...items].sort(
|
||||
(a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
const displayedItems = $derived(() => {
|
||||
if (showAll) return sortedItems();
|
||||
return sortedItems().slice(0, maxItems);
|
||||
});
|
||||
|
||||
const hasMore = $derived(items.length > maxItems && !showAll);
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.changelog.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div>
|
||||
{#each displayedItems() as item (item.id)}
|
||||
<ChangelogEntry {item} {translations} />
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
{translations.changelog.showAll} ({items.length - maxItems})
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
123
packages/help/src/components/ContactSection.svelte
Normal file
123
packages/help/src/components/ContactSection.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import type { ContactSectionProps } from '../types.js';
|
||||
|
||||
let { contact, translations }: ContactSectionProps = $props();
|
||||
</script>
|
||||
|
||||
{#if !contact}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.contact.noInfo}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html contact.content}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#if contact.supportEmail}
|
||||
<a
|
||||
href={`mailto:${contact.supportEmail}`}
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-400 flex h-10 w-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translations.contact.email}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.supportEmail}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if contact.responseTime}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translations.contact.responseTime}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contact.discordUrl}
|
||||
<a
|
||||
href={contact.discordUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Discord</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Join our community</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if contact.documentationUrl}
|
||||
<a
|
||||
href={contact.documentationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Documentation</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Read the docs</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
46
packages/help/src/components/FAQItem.svelte
Normal file
46
packages/help/src/components/FAQItem.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { FAQItem } from './content';
|
||||
|
||||
interface Props {
|
||||
item: FAQItem;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { item, expanded = false, onToggle }: Props = $props();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggle?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
aria-expanded={expanded}
|
||||
onclick={onToggle}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.question}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 text-gray-500 transition-transform duration-200 dark:text-gray-400"
|
||||
class:rotate-180={expanded}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none pb-4 text-gray-600 dark:text-gray-300">
|
||||
{@html item.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
108
packages/help/src/components/FAQSection.svelte
Normal file
108
packages/help/src/components/FAQSection.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script lang="ts">
|
||||
import type { FAQItem, FAQCategory } from './content';
|
||||
import type { FAQSectionProps } from '../types.js';
|
||||
import FAQItemComponent from './FAQItem.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
translations,
|
||||
showCategories = true,
|
||||
maxItems,
|
||||
expandFirst = false,
|
||||
}: FAQSectionProps = $props();
|
||||
|
||||
let expandedId = $state<string | null>(expandFirst && items.length > 0 ? items[0].id : null);
|
||||
let selectedCategory = $state<FAQCategory | 'all'>('all');
|
||||
let showAll = $state(false);
|
||||
|
||||
// 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;
|
||||
if (selectedCategory !== 'all') {
|
||||
result = result.filter((item) => item.category === selectedCategory);
|
||||
}
|
||||
if (maxItems && !showAll) {
|
||||
result = result.slice(0, maxItems);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const hasMore = $derived(maxItems ? items.length > maxItems && !showAll : false);
|
||||
|
||||
function toggleItem(id: string) {
|
||||
expandedId = expandedId === id ? null : id;
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: FAQCategory): string {
|
||||
return translations.faq.categories[category] ?? category;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if showCategories && items.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === 'all'}
|
||||
class:text-primary-700={selectedCategory === 'all'}
|
||||
class:dark:bg-primary-900={selectedCategory === 'all'}
|
||||
class:dark:text-primary-300={selectedCategory === 'all'}
|
||||
class:bg-gray-100={selectedCategory !== 'all'}
|
||||
class:text-gray-600={selectedCategory !== 'all'}
|
||||
class:dark:bg-gray-800={selectedCategory !== 'all'}
|
||||
class:dark:text-gray-400={selectedCategory !== 'all'}
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
{translations.faq.allCategories}
|
||||
</button>
|
||||
{#each categories as category}
|
||||
<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}
|
||||
|
||||
{#if filteredItems().length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.faq.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each filteredItems() as item (item.id)}
|
||||
<FAQItemComponent
|
||||
{item}
|
||||
expanded={expandedId === item.id}
|
||||
onToggle={() => toggleItem(item.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
{translations.common.showMore}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
68
packages/help/src/components/FeatureCard.svelte
Normal file
68
packages/help/src/components/FeatureCard.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts">
|
||||
import type { FeatureItem } from './content';
|
||||
|
||||
interface Props {
|
||||
item: FeatureItem;
|
||||
learnMoreLabel?: string;
|
||||
comingSoonLabel?: string;
|
||||
}
|
||||
|
||||
let { item, learnMoreLabel = 'Learn more', comingSoonLabel = 'Coming soon' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{#if item.comingSoon}
|
||||
<span
|
||||
class="absolute right-3 top-3 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{comingSoonLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
{#if item.icon}
|
||||
<span class="text-2xl">{item.icon}</span>
|
||||
{/if}
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{#if item.highlights && item.highlights.length > 0}
|
||||
<ul class="mb-3 space-y-1">
|
||||
{#each item.highlights as highlight}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
class="text-primary-500 mt-0.5 h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{highlight}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if item.learnMoreUrl}
|
||||
<a
|
||||
href={item.learnMoreUrl}
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
>
|
||||
{learnMoreLabel} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
50
packages/help/src/components/FeaturesOverview.svelte
Normal file
50
packages/help/src/components/FeaturesOverview.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { FeaturesOverviewProps } from '../types.js';
|
||||
import FeatureCard from './FeatureCard.svelte';
|
||||
|
||||
let { items, translations }: FeaturesOverviewProps = $props();
|
||||
|
||||
const groupedItems = $derived(() => {
|
||||
const groups: Record<string, typeof items> = {
|
||||
'getting-started': [],
|
||||
core: [],
|
||||
advanced: [],
|
||||
integration: [],
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const category = item.category || 'core';
|
||||
if (groups[category]) {
|
||||
groups[category].push(item);
|
||||
} else {
|
||||
groups.core.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const hasItems = $derived(items.length > 0);
|
||||
</script>
|
||||
|
||||
{#if !hasItems}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.features.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
{#each Object.entries(groupedItems()) as [_category, categoryItems]}
|
||||
{#if categoryItems.length > 0}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each categoryItems as item (item.id)}
|
||||
<FeatureCard
|
||||
{item}
|
||||
learnMoreLabel={translations.features.learnMore}
|
||||
comingSoonLabel={translations.features.comingSoon}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
108
packages/help/src/components/GettingStartedGuide.svelte
Normal file
108
packages/help/src/components/GettingStartedGuide.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script lang="ts">
|
||||
import type { GettingStartedGuideProps } from '../types.js';
|
||||
|
||||
let { items, translations }: GettingStartedGuideProps = $props();
|
||||
|
||||
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
|
||||
|
||||
const guide = $derived(items.find((item) => item.id === selectedGuideId) ?? null);
|
||||
|
||||
function getDifficultyLabel(difficulty: string): string {
|
||||
return (
|
||||
translations.gettingStarted.difficulty[
|
||||
difficulty as keyof typeof translations.gettingStarted.difficulty
|
||||
] ?? difficulty
|
||||
);
|
||||
}
|
||||
|
||||
function getDifficultyColor(difficulty: string): string {
|
||||
switch (difficulty) {
|
||||
case 'beginner':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'intermediate':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
case 'advanced':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.gettingStarted.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6 lg:flex-row">
|
||||
<!-- Guide List -->
|
||||
<div class="w-full space-y-2 lg:w-64">
|
||||
{#each items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedGuideId ===
|
||||
item.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}"
|
||||
onclick={() => (selectedGuideId = item.id)}
|
||||
>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class={`rounded-full px-2 py-0.5 ${getDifficultyColor(item.difficulty)}`}>
|
||||
{getDifficultyLabel(item.difficulty)}
|
||||
</span>
|
||||
{#if item.estimatedTime}
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{item.estimatedTime}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Guide Content -->
|
||||
<div class="flex-1">
|
||||
{#if guide}
|
||||
<div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{guide.title}
|
||||
</h3>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
|
||||
{#if guide.steps && guide.steps.length > 0}
|
||||
<div class="space-y-6">
|
||||
{#each guide.steps as step, index}
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
class="bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{step.title}
|
||||
</h4>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html guide.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
218
packages/help/src/components/HelpSearch.svelte
Normal file
218
packages/help/src/components/HelpSearch.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
import type { HelpSearchProps } from '../types.js';
|
||||
import type { SearchResult } from './content';
|
||||
import { createSearcher } from './loader';
|
||||
|
||||
let { content, translations, placeholder, onResultSelect }: HelpSearchProps = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let showResults = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
|
||||
const searcher = $derived(createSearcher(content));
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
query = target.value;
|
||||
selectedIndex = -1;
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
results = [];
|
||||
showResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching = true;
|
||||
debounceTimer = setTimeout(() => {
|
||||
results = searcher(query, { limit: 8 });
|
||||
isSearching = false;
|
||||
showResults = true;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!showResults || results.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
selectResult(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showResults = false;
|
||||
selectedIndex = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(result: SearchResult) {
|
||||
onResultSelect(result);
|
||||
query = '';
|
||||
results = [];
|
||||
showResults = false;
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return '?';
|
||||
case 'feature':
|
||||
return '★';
|
||||
case 'guide':
|
||||
return '📖';
|
||||
case 'changelog':
|
||||
return '📋';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return 'FAQ';
|
||||
case 'feature':
|
||||
return 'Feature';
|
||||
case 'guide':
|
||||
return 'Guide';
|
||||
case 'changelog':
|
||||
return 'Changelog';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" data-help-search>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
onfocus={() => query.length >= 2 && (showResults = true)}
|
||||
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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<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"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
{translations.search.noResults.replace('{query}', query)}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="max-h-96 overflow-auto py-2">
|
||||
{#each results as result, index (result.id)}
|
||||
<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'
|
||||
: ''}"
|
||||
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>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{@html result.highlight ?? result.title}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{getTypeLabel(result.type)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{result.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div
|
||||
class="border-t border-gray-200 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{translations.search.resultsCount.replace('{count}', String(results.length))}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
60
packages/help/src/components/KeyboardShortcuts.svelte
Normal file
60
packages/help/src/components/KeyboardShortcuts.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import type { KeyboardShortcutsProps } from '../types.js';
|
||||
|
||||
let { items, translations }: KeyboardShortcutsProps = $props();
|
||||
|
||||
const allShortcuts = $derived(() => {
|
||||
return items.flatMap((item) =>
|
||||
item.shortcuts.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
const hasItems = $derived(allShortcuts().length > 0);
|
||||
</script>
|
||||
|
||||
{#if !hasItems}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.shortcuts.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<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"
|
||||
>{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">
|
||||
{#each allShortcuts() as shortcut}
|
||||
<tr>
|
||||
<td class="py-3 pr-4">
|
||||
<kbd
|
||||
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{shortcut.shortcut}
|
||||
</kbd>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-900 dark:text-gray-100">
|
||||
{shortcut.action}
|
||||
</td>
|
||||
<td class="py-3 text-gray-600 dark:text-gray-400">
|
||||
{shortcut.description || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
152
packages/help/src/content.ts
Normal file
152
packages/help/src/content.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Help Content Type Definitions
|
||||
* Defines the structure for all help content types
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Base Types
|
||||
// ============================================================================
|
||||
|
||||
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||
|
||||
export type FAQCategory = 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
|
||||
|
||||
export type FeatureCategory = 'getting-started' | 'core' | 'advanced' | 'integration';
|
||||
|
||||
export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced';
|
||||
|
||||
export type ChangelogType = 'major' | 'minor' | 'patch' | 'beta';
|
||||
|
||||
export type ShortcutCategory = 'navigation' | 'editing' | 'general' | 'app-specific';
|
||||
|
||||
// ============================================================================
|
||||
// Content Item Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BaseContentItem {
|
||||
id: string;
|
||||
language: SupportedLanguage;
|
||||
order?: number;
|
||||
appSpecific?: boolean;
|
||||
apps?: string[];
|
||||
lastUpdated?: Date;
|
||||
}
|
||||
|
||||
export interface FAQItem extends BaseContentItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
category: FAQCategory;
|
||||
featured?: boolean;
|
||||
tags?: string[];
|
||||
relatedFaqs?: string[];
|
||||
}
|
||||
|
||||
export interface FeatureItem extends BaseContentItem {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
icon?: string;
|
||||
category: FeatureCategory;
|
||||
available?: boolean;
|
||||
comingSoon?: boolean;
|
||||
highlights?: string[];
|
||||
learnMoreUrl?: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShortcutsItem extends BaseContentItem {
|
||||
category: ShortcutCategory;
|
||||
title?: string;
|
||||
shortcuts: KeyboardShortcut[];
|
||||
}
|
||||
|
||||
export interface GuideStep {
|
||||
title: string;
|
||||
content: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface GettingStartedItem extends BaseContentItem {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
difficulty: GuideDifficulty;
|
||||
estimatedTime?: string;
|
||||
prerequisites?: string[];
|
||||
steps?: GuideStep[];
|
||||
}
|
||||
|
||||
export interface ChangelogChange {
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ChangelogItem extends BaseContentItem {
|
||||
version: string;
|
||||
title: string;
|
||||
releaseDate: Date;
|
||||
type: ChangelogType;
|
||||
summary?: string;
|
||||
content: string;
|
||||
highlighted?: boolean;
|
||||
changes?: {
|
||||
features?: ChangelogChange[];
|
||||
improvements?: ChangelogChange[];
|
||||
bugfixes?: ChangelogChange[];
|
||||
};
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface ContactInfo extends BaseContentItem {
|
||||
title: string;
|
||||
content: string;
|
||||
supportEmail?: string;
|
||||
supportUrl?: string;
|
||||
discordUrl?: string;
|
||||
twitterUrl?: string;
|
||||
documentationUrl?: string;
|
||||
responseTime?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregated Content Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpContent {
|
||||
faq: FAQItem[];
|
||||
features: FeatureItem[];
|
||||
shortcuts: ShortcutsItem[];
|
||||
gettingStarted: GettingStartedItem[];
|
||||
changelog: ChangelogItem[];
|
||||
contact: ContactInfo | null;
|
||||
}
|
||||
|
||||
export interface AppHelpContent {
|
||||
appId: string;
|
||||
appName: string;
|
||||
content: HelpContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpContentConfig {
|
||||
appId: string;
|
||||
locale: SupportedLanguage;
|
||||
fallbackLocale?: SupportedLanguage;
|
||||
includeAppSpecific?: boolean;
|
||||
}
|
||||
|
||||
export interface MergeContentOptions {
|
||||
appId: string;
|
||||
locale: SupportedLanguage;
|
||||
/** If true, app-specific content replaces central content with same ID */
|
||||
overrideById?: boolean;
|
||||
}
|
||||
70
packages/help/src/index.ts
Normal file
70
packages/help/src/index.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* @manacore/help — Unified help package
|
||||
*
|
||||
* Consolidates shared-help-types + shared-help-content + shared-help-ui.
|
||||
*/
|
||||
|
||||
// === Types (from shared-help-types) ===
|
||||
export * from './content';
|
||||
export * from './schemas';
|
||||
export * from './search-types';
|
||||
export { getManaFAQs, getManaFeature } from './mana-faq';
|
||||
export { getPrivacyFAQs, type PrivacyFAQOptions } from './privacy-faq';
|
||||
|
||||
// === Content utilities (from shared-help-content) ===
|
||||
export {
|
||||
parseMarkdown,
|
||||
parseMarkdownFiles,
|
||||
stripHtml,
|
||||
generateExcerpt,
|
||||
type ParsedContent,
|
||||
type ParseOptions,
|
||||
} from './parser';
|
||||
|
||||
export {
|
||||
parseFAQContent,
|
||||
parseFeatureContent,
|
||||
parseShortcutsContent,
|
||||
parseGettingStartedContent,
|
||||
parseChangelogContent,
|
||||
parseContactContent,
|
||||
loadHelpContentFromFiles,
|
||||
type LoaderOptions,
|
||||
} from './loader';
|
||||
|
||||
export { mergeContent, createEmptyContent } from './merger';
|
||||
export { sanitizeHtml } from './sanitize';
|
||||
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search-engine';
|
||||
|
||||
// === UI Components (from shared-help-ui) ===
|
||||
export { default as HelpPage } from './pages/HelpPage.svelte';
|
||||
export { default as FAQSection } from './components/FAQSection.svelte';
|
||||
export { default as FAQItem } from './components/FAQItem.svelte';
|
||||
export { default as FeaturesOverview } from './components/FeaturesOverview.svelte';
|
||||
export { default as FeatureCard } from './components/FeatureCard.svelte';
|
||||
export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte';
|
||||
export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte';
|
||||
export { default as ChangelogSection } from './components/ChangelogSection.svelte';
|
||||
export { default as ChangelogEntry } from './components/ChangelogEntry.svelte';
|
||||
export { default as ContactSection } from './components/ContactSection.svelte';
|
||||
export { default as HelpSearch } from './components/HelpSearch.svelte';
|
||||
|
||||
export {
|
||||
defaultTranslationsDE,
|
||||
defaultTranslationsEN,
|
||||
getHelpTranslations,
|
||||
} from './translations';
|
||||
|
||||
export type {
|
||||
HelpPageProps,
|
||||
HelpPageTranslations,
|
||||
HelpSection,
|
||||
FAQSectionProps,
|
||||
FeaturesOverviewProps,
|
||||
KeyboardShortcutsProps,
|
||||
GettingStartedGuideProps,
|
||||
ChangelogEntryProps,
|
||||
ChangelogSectionProps,
|
||||
ContactSectionProps,
|
||||
HelpSearchProps,
|
||||
} from './ui-types';
|
||||
311
packages/help/src/loader.ts
Normal file
311
packages/help/src/loader.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Content Loader
|
||||
* Utilities for loading help content from various sources
|
||||
*/
|
||||
|
||||
import type {
|
||||
HelpContent,
|
||||
FAQItem,
|
||||
FeatureItem,
|
||||
ShortcutsItem,
|
||||
GettingStartedItem,
|
||||
ChangelogItem,
|
||||
ContactInfo,
|
||||
SupportedLanguage,
|
||||
} from './content';
|
||||
import {
|
||||
faqFrontmatterSchema,
|
||||
featureFrontmatterSchema,
|
||||
shortcutsFrontmatterSchema,
|
||||
gettingStartedFrontmatterSchema,
|
||||
changelogFrontmatterSchema,
|
||||
contactFrontmatterSchema,
|
||||
type FAQFrontmatter,
|
||||
type FeatureFrontmatter,
|
||||
type ShortcutsFrontmatter,
|
||||
type GettingStartedFrontmatter,
|
||||
type ChangelogFrontmatter,
|
||||
type ContactFrontmatter,
|
||||
} from './content';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { createEmptyContent } from './merger.js';
|
||||
|
||||
export interface LoaderOptions {
|
||||
/** Locale to load */
|
||||
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<FAQFrontmatter>(rawContent, faqFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Feature content from raw Markdown
|
||||
*/
|
||||
export function parseFeatureContent(rawContent: string): FeatureItem {
|
||||
const parsed = parseMarkdown<FeatureFrontmatter>(rawContent, featureFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Shortcuts content from raw Markdown
|
||||
*/
|
||||
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
|
||||
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,
|
||||
language: fm.language,
|
||||
order: fm.order,
|
||||
appSpecific: fm.appSpecific,
|
||||
apps: fm.apps,
|
||||
lastUpdated: fm.lastUpdated,
|
||||
category: fm.category,
|
||||
title: fm.title,
|
||||
shortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown table into keyboard shortcuts
|
||||
*/
|
||||
function parseShortcutsTable(
|
||||
content: string
|
||||
): Array<{ shortcut: string; action: string; description?: string }> {
|
||||
const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
let inTable = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip header separator (flexible: allows spaces around dashes)
|
||||
if (trimmed.match(/^\|[\s\-:|]+\|$/)) {
|
||||
inTable = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse table row
|
||||
if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
||||
const cells = trimmed
|
||||
.slice(1, -1)
|
||||
.split('|')
|
||||
.map((cell) => cell.trim());
|
||||
|
||||
if (cells.length >= 2) {
|
||||
shortcuts.push({
|
||||
shortcut: cells[0],
|
||||
action: cells[1],
|
||||
description: cells[2] || undefined,
|
||||
});
|
||||
}
|
||||
} else if (inTable && !trimmed.startsWith('|')) {
|
||||
// End of table
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Getting Started guide content from raw Markdown
|
||||
*/
|
||||
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse guide steps from markdown content (h2 headers)
|
||||
*/
|
||||
function parseGuideSteps(content: string): Array<{ title: string; content: string }> {
|
||||
const steps: Array<{ title: string; content: string }> = [];
|
||||
const sections = content.split(/^## /m);
|
||||
|
||||
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();
|
||||
|
||||
steps.push({ title, content: stepContent });
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Changelog content from raw Markdown
|
||||
*/
|
||||
export function parseChangelogContent(rawContent: string): ChangelogItem {
|
||||
const parsed = parseMarkdown<ChangelogFrontmatter>(rawContent, changelogFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Contact content from raw Markdown
|
||||
*/
|
||||
export function parseContactContent(rawContent: string): ContactInfo {
|
||||
const parsed = parseMarkdown<ContactFrontmatter>(rawContent, contactFrontmatterSchema);
|
||||
const fm = parsed.frontmatter;
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load help content from a map of file paths to content
|
||||
* This is the main entry point for content loading
|
||||
*/
|
||||
export function loadHelpContentFromFiles(
|
||||
files: Record<string, string>,
|
||||
options: LoaderOptions
|
||||
): HelpContent {
|
||||
const content = createEmptyContent();
|
||||
const { locale, fallbackLocale = 'en', onError } = options;
|
||||
|
||||
for (const [path, rawContent] of Object.entries(files)) {
|
||||
try {
|
||||
// Determine content type from path
|
||||
if (path.includes('/faq/')) {
|
||||
const faq = parseFAQContent(rawContent);
|
||||
if (faq.language === locale || faq.language === fallbackLocale) {
|
||||
content.faq.push(faq);
|
||||
}
|
||||
} else if (path.includes('/features/')) {
|
||||
const feature = parseFeatureContent(rawContent);
|
||||
if (feature.language === locale || feature.language === fallbackLocale) {
|
||||
content.features.push(feature);
|
||||
}
|
||||
} else if (path.includes('/shortcuts/')) {
|
||||
const shortcuts = parseShortcutsContent(rawContent);
|
||||
if (shortcuts.language === locale || shortcuts.language === fallbackLocale) {
|
||||
content.shortcuts.push(shortcuts);
|
||||
}
|
||||
} else if (path.includes('/getting-started/')) {
|
||||
const guide = parseGettingStartedContent(rawContent);
|
||||
if (guide.language === locale || guide.language === fallbackLocale) {
|
||||
content.gettingStarted.push(guide);
|
||||
}
|
||||
} else if (path.includes('/changelog/')) {
|
||||
const changelog = parseChangelogContent(rawContent);
|
||||
if (changelog.language === locale || changelog.language === fallbackLocale) {
|
||||
content.changelog.push(changelog);
|
||||
}
|
||||
} else if (path.includes('/contact/')) {
|
||||
const contact = parseContactContent(rawContent);
|
||||
if (contact.language === locale || contact.language === fallbackLocale) {
|
||||
content.contact = contact;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(path, error);
|
||||
} else {
|
||||
console.warn('[shared-help] Failed to parse:', path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
95
packages/help/src/mana-faq.ts
Normal file
95
packages/help/src/mana-faq.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Shared Mana/Credits FAQ content
|
||||
* Reusable across all apps that have a /mana subscription page
|
||||
*/
|
||||
|
||||
import type { FAQItem, FeatureItem } from './content.js';
|
||||
|
||||
/**
|
||||
* Returns shared FAQ items explaining the Mana credit system.
|
||||
* Apps can spread these into their help content's faq array.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getManaFAQs } from './content';
|
||||
*
|
||||
* export function getMyAppHelpContent(locale: string): HelpContent {
|
||||
* return {
|
||||
* faq: [
|
||||
* ...appSpecificFaqs,
|
||||
* ...getManaFAQs(locale),
|
||||
* ],
|
||||
* ...
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getManaFAQs(locale: string): FAQItem[] {
|
||||
const isDE = locale === 'de';
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'faq-mana-what',
|
||||
question: isDE ? 'Was ist Mana?' : 'What is Mana?',
|
||||
answer: isDE
|
||||
? '<p><strong>Mana</strong> ist die universelle Währung im ManaCore-Ökosystem. Mit Mana bezahlst du für Premium-Funktionen wie KI-Generierungen, Cloud-Modelle und erweiterte Features — app-übergreifend mit einem einzigen Guthaben.</p><ul><li>1 Mana ≈ 1 Cent (im Abo)</li><li>Dein Guthaben gilt in <strong>allen ManaCore-Apps</strong></li><li>Jede App zeigt deinen aktuellen Stand unter <strong>Mana</strong> im Account-Menü</li></ul>'
|
||||
: '<p><strong>Mana</strong> is the universal currency in the ManaCore ecosystem. With Mana you pay for premium features like AI generations, cloud models, and advanced features — across all apps with a single balance.</p><ul><li>1 Mana ≈ 1 cent (with subscription)</li><li>Your balance works across <strong>all ManaCore apps</strong></li><li>Each app shows your current balance under <strong>Mana</strong> in the account menu</li></ul>',
|
||||
category: 'billing',
|
||||
order: 90,
|
||||
language: isDE ? 'de' : 'en',
|
||||
featured: true,
|
||||
tags: isDE
|
||||
? ['mana', 'credits', 'guthaben', 'währung']
|
||||
: ['mana', 'credits', 'balance', 'currency'],
|
||||
},
|
||||
{
|
||||
id: 'faq-mana-get',
|
||||
question: isDE ? 'Wie bekomme ich Mana?' : 'How do I get Mana?',
|
||||
answer: isDE
|
||||
? '<p>Es gibt drei Wege, Mana zu erhalten:</p><ul><li><strong>Free-Tier</strong>: Jeder Nutzer erhält <strong>50 Mana/Monat</strong> kostenlos</li><li><strong>Mana Quelle</strong> (Abo): Monatliche Mana-Pakete von 500 bis 10.000 Mana (ab 4,99€/Monat, 20% Jahresrabatt)</li><li><strong>Mana Trank</strong> (Einmalkauf): Sofort-Pakete von 350 bis 2.800 Mana (ab 4,90€)</li></ul><p>Abonnenten können außerdem Mana an andere Nutzer <strong>verschenken</strong>.</p>'
|
||||
: '<p>There are three ways to get Mana:</p><ul><li><strong>Free tier</strong>: Every user receives <strong>50 Mana/month</strong> for free</li><li><strong>Mana Source</strong> (subscription): Monthly Mana packages from 500 to 10,000 Mana (from €4.99/month, 20% yearly discount)</li><li><strong>Mana Potion</strong> (one-time purchase): Instant packages from 350 to 2,800 Mana (from €4.90)</li></ul><p>Subscribers can also <strong>gift</strong> Mana to other users.</p>',
|
||||
category: 'billing',
|
||||
order: 91,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['mana', 'kaufen', 'abo', 'kostenlos', 'stream', 'trank']
|
||||
: ['mana', 'buy', 'subscription', 'free', 'stream', 'potion'],
|
||||
},
|
||||
{
|
||||
id: 'faq-mana-use',
|
||||
question: isDE ? 'Wofür wird Mana verwendet?' : 'What is Mana used for?',
|
||||
answer: isDE
|
||||
? '<p>Mana wird für Premium-Funktionen innerhalb der ManaCore-Apps verwendet:</p><ul><li><strong>Chat</strong>: Cloud-KI-Modelle (Claude, GPT, DeepSeek) — lokale Modelle sind kostenlos</li><li><strong>Picture</strong>: KI-Bildgenerierungen (nach 3 kostenlosen Generierungen)</li><li><strong>Context</strong>: KI-Textgenerierung und -analyse</li><li><strong>Weitere Apps</strong>: KI-gestützte Features in Planta, Questions, etc.</li></ul><p>Basis-Funktionen wie Aufgaben, Kalender, Kontakte, Dateien und Chats mit lokalen Modellen sind <strong>immer kostenlos</strong>.</p>'
|
||||
: '<p>Mana is used for premium features within ManaCore apps:</p><ul><li><strong>Chat</strong>: Cloud AI models (Claude, GPT, DeepSeek) — local models are free</li><li><strong>Picture</strong>: AI image generations (after 3 free generations)</li><li><strong>Context</strong>: AI text generation and analysis</li><li><strong>More apps</strong>: AI-powered features in Planta, Questions, etc.</li></ul><p>Core features like tasks, calendar, contacts, files, and chats with local models are <strong>always free</strong>.</p>',
|
||||
category: 'billing',
|
||||
order: 92,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['mana', 'verbrauch', 'kosten', 'kostenlos']
|
||||
: ['mana', 'usage', 'costs', 'free'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shared feature item for the Mana credit system.
|
||||
*/
|
||||
export function getManaFeature(locale: string): FeatureItem {
|
||||
const isDE = locale === 'de';
|
||||
|
||||
return {
|
||||
id: 'feature-mana',
|
||||
title: isDE ? 'Mana-Credits' : 'Mana Credits',
|
||||
description: isDE
|
||||
? 'Universelles Guthaben für Premium-Features in allen ManaCore-Apps — 150 Mana/Monat kostenlos.'
|
||||
: 'Universal balance for premium features across all ManaCore apps — 150 Mana/month for free.',
|
||||
icon: '✨',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['150 Mana/Monat kostenlos', 'App-übergreifend', 'Abos & Einmalkäufe', 'Mana verschenken']
|
||||
: ['150 Mana/month free', 'Cross-app', 'Subscriptions & one-time', 'Gift Mana'],
|
||||
content: '',
|
||||
order: 99,
|
||||
language: isDE ? 'de' : 'en',
|
||||
};
|
||||
}
|
||||
119
packages/help/src/merger.ts
Normal file
119
packages/help/src/merger.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Content Merger
|
||||
* Merges central help content with app-specific content
|
||||
*/
|
||||
|
||||
import type { HelpContent, MergeContentOptions } from './content';
|
||||
|
||||
/**
|
||||
* Filter content items by locale and app
|
||||
*/
|
||||
function filterItems<T extends { language: string; appSpecific?: boolean; apps?: string[] }>(
|
||||
items: T[],
|
||||
locale: string,
|
||||
appId: string
|
||||
): T[] {
|
||||
return items.filter((item) => {
|
||||
// Filter by language
|
||||
if (item.language !== locale) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include non-app-specific items
|
||||
if (!item.appSpecific) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Include app-specific items for this app
|
||||
return item.apps?.includes(appId) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two arrays, optionally replacing items with same ID
|
||||
*/
|
||||
function mergeArrays<T extends { id: string }>(
|
||||
central: T[],
|
||||
appSpecific: T[],
|
||||
overrideById: boolean
|
||||
): T[] {
|
||||
if (!overrideById) {
|
||||
return [...central, ...appSpecific];
|
||||
}
|
||||
|
||||
const appIds = new Set(appSpecific.map((item) => item.id));
|
||||
const filtered = central.filter((item) => !appIds.has(item.id));
|
||||
return [...filtered, ...appSpecific];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by order property
|
||||
*/
|
||||
function sortByOrder<T extends { order?: number }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge central help content with app-specific content
|
||||
*/
|
||||
export function mergeContent(
|
||||
central: HelpContent,
|
||||
appSpecific: Partial<HelpContent>,
|
||||
options: MergeContentOptions
|
||||
): HelpContent {
|
||||
const { appId, locale, overrideById = true } = options;
|
||||
|
||||
// Filter central content by locale and app
|
||||
const filteredCentral: HelpContent = {
|
||||
faq: filterItems(central.faq, locale, appId),
|
||||
features: filterItems(central.features, locale, appId),
|
||||
shortcuts: filterItems(central.shortcuts, locale, appId),
|
||||
gettingStarted: filterItems(central.gettingStarted, locale, appId),
|
||||
changelog: filterItems(central.changelog, locale, appId),
|
||||
contact: central.contact?.language === locale ? central.contact : null,
|
||||
};
|
||||
|
||||
// Filter app-specific content
|
||||
const filteredApp: Partial<HelpContent> = {
|
||||
faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [],
|
||||
features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [],
|
||||
shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [],
|
||||
gettingStarted: appSpecific.gettingStarted
|
||||
? filterItems(appSpecific.gettingStarted, locale, appId)
|
||||
: [],
|
||||
changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [],
|
||||
contact: appSpecific.contact?.language === locale ? appSpecific.contact : null,
|
||||
};
|
||||
|
||||
// Merge and sort
|
||||
return {
|
||||
faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)),
|
||||
features: sortByOrder(
|
||||
mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById)
|
||||
),
|
||||
shortcuts: sortByOrder(
|
||||
mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById)
|
||||
),
|
||||
gettingStarted: sortByOrder(
|
||||
mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById)
|
||||
),
|
||||
changelog: sortByOrder(
|
||||
mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById)
|
||||
),
|
||||
contact: filteredApp.contact ?? filteredCentral.contact,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty HelpContent object
|
||||
*/
|
||||
export function createEmptyContent(): HelpContent {
|
||||
return {
|
||||
faq: [],
|
||||
features: [],
|
||||
shortcuts: [],
|
||||
gettingStarted: [],
|
||||
changelog: [],
|
||||
contact: null,
|
||||
};
|
||||
}
|
||||
173
packages/help/src/pages/HelpPage.svelte
Normal file
173
packages/help/src/pages/HelpPage.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
import type { HelpPageProps, HelpSection } from '../types.js';
|
||||
import type { SearchResult } from './content';
|
||||
import HelpSearch from '../components/HelpSearch.svelte';
|
||||
import FAQSection from '../components/FAQSection.svelte';
|
||||
import FeaturesOverview from '../components/FeaturesOverview.svelte';
|
||||
import KeyboardShortcuts from '../components/KeyboardShortcuts.svelte';
|
||||
import GettingStartedGuide from '../components/GettingStartedGuide.svelte';
|
||||
import ChangelogSection from '../components/ChangelogSection.svelte';
|
||||
import ContactSection from '../components/ContactSection.svelte';
|
||||
|
||||
let {
|
||||
content,
|
||||
appName,
|
||||
appId,
|
||||
translations,
|
||||
searchEnabled = true,
|
||||
showFAQ = true,
|
||||
showFeatures = true,
|
||||
showShortcuts = true,
|
||||
showGettingStarted = true,
|
||||
showChangelog = true,
|
||||
showContact = true,
|
||||
defaultSection = 'faq',
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
onSectionChange,
|
||||
onSearch,
|
||||
}: HelpPageProps = $props();
|
||||
|
||||
let activeSection = $state<HelpSection>(defaultSection);
|
||||
|
||||
const sections: { id: HelpSection; label: string; show: boolean }[] = $derived([
|
||||
{ id: 'faq', label: translations.sections.faq, show: showFAQ && content.faq.length > 0 },
|
||||
{
|
||||
id: 'features',
|
||||
label: translations.sections.features,
|
||||
show: showFeatures && content.features.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
label: translations.sections.shortcuts,
|
||||
show: showShortcuts && content.shortcuts.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'getting-started',
|
||||
label: translations.sections.gettingStarted,
|
||||
show: showGettingStarted && content.gettingStarted.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'changelog',
|
||||
label: translations.sections.changelog,
|
||||
show: showChangelog && content.changelog.length > 0,
|
||||
},
|
||||
{ id: 'contact', label: translations.sections.contact, show: showContact && !!content.contact },
|
||||
]);
|
||||
|
||||
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) {
|
||||
// Navigate to the appropriate section based on result type
|
||||
switch (result.type) {
|
||||
case 'faq':
|
||||
setActiveSection('faq');
|
||||
break;
|
||||
case 'feature':
|
||||
setActiveSection('features');
|
||||
break;
|
||||
case 'guide':
|
||||
setActiveSection('getting-started');
|
||||
break;
|
||||
case 'changelog':
|
||||
setActiveSection('changelog');
|
||||
break;
|
||||
}
|
||||
onSearch?.(result.title, [result]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
{#if showBackButton}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-4 flex items-center gap-1 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onclick={onBack}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{translations.common.back}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{translations.title}
|
||||
</h1>
|
||||
{#if translations.subtitle}
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{translations.subtitle} - {appName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if searchEnabled}
|
||||
<div class="mb-8">
|
||||
<HelpSearch
|
||||
{content}
|
||||
{translations}
|
||||
placeholder={translations.searchPlaceholder}
|
||||
onResultSelect={handleSearchResultSelect}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
{#if visibleSections.length > 1}
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Help sections">
|
||||
{#each visibleSections as section (section.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors"
|
||||
class:border-primary-500={activeSection === section.id}
|
||||
class:text-primary-600={activeSection === section.id}
|
||||
class:dark:text-primary-400={activeSection === section.id}
|
||||
class:border-transparent={activeSection !== section.id}
|
||||
class:text-gray-500={activeSection !== section.id}
|
||||
class:hover:text-gray-700={activeSection !== section.id}
|
||||
class:dark:text-gray-400={activeSection !== section.id}
|
||||
class:dark:hover:text-gray-300={activeSection !== section.id}
|
||||
onclick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]" bind:this={contentRef}>
|
||||
{#if activeSection === 'faq' && showFAQ}
|
||||
<FAQSection items={content.faq} {translations} expandFirst />
|
||||
{:else if activeSection === 'features' && showFeatures}
|
||||
<FeaturesOverview items={content.features} {translations} />
|
||||
{:else if activeSection === 'shortcuts' && showShortcuts}
|
||||
<KeyboardShortcuts items={content.shortcuts} {translations} />
|
||||
{:else if activeSection === 'getting-started' && showGettingStarted}
|
||||
<GettingStartedGuide items={content.gettingStarted} {translations} />
|
||||
{:else if activeSection === 'changelog' && showChangelog}
|
||||
<ChangelogSection items={content.changelog} {translations} />
|
||||
{:else if activeSection === 'contact' && showContact}
|
||||
<ContactSection contact={content.contact} {translations} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
87
packages/help/src/parser.ts
Normal file
87
packages/help/src/parser.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Markdown + Frontmatter Parser
|
||||
* Parses Markdown files with YAML frontmatter
|
||||
*/
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import type { ZodType, ZodTypeDef } from 'zod';
|
||||
import { sanitizeHtml } from './sanitize.js';
|
||||
|
||||
export interface ParsedContent<T> {
|
||||
frontmatter: T;
|
||||
content: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
/** Convert Markdown to HTML */
|
||||
renderHtml?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Markdown file with frontmatter
|
||||
*/
|
||||
export function parseMarkdown<T>(
|
||||
rawContent: string,
|
||||
schema?: ZodType<T, ZodTypeDef, unknown>,
|
||||
options: ParseOptions = { renderHtml: true }
|
||||
): ParsedContent<T> {
|
||||
const { data, content } = matter(rawContent);
|
||||
|
||||
// Validate frontmatter if schema provided
|
||||
let frontmatter: T;
|
||||
if (schema) {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid frontmatter: ${result.error.message}`);
|
||||
}
|
||||
frontmatter = result.data;
|
||||
} else {
|
||||
frontmatter = data as T;
|
||||
}
|
||||
|
||||
// Render HTML if requested, then sanitize to prevent XSS
|
||||
const html = options.renderHtml ? sanitizeHtml(marked.parse(content) as string) : '';
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
content: content.trim(),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multiple Markdown files
|
||||
*/
|
||||
export function parseMarkdownFiles<T>(
|
||||
files: { filename: string; content: string }[],
|
||||
schema?: ZodType<T, ZodTypeDef, unknown>,
|
||||
options?: ParseOptions
|
||||
): Array<ParsedContent<T> & { filename: string }> {
|
||||
return files.map(({ filename, content }) => ({
|
||||
filename,
|
||||
...parseMarkdown<T>(content, schema, options),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from HTML (for search indexing)
|
||||
*/
|
||||
export function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate excerpt from content
|
||||
*/
|
||||
export function generateExcerpt(content: string, maxLength = 150): string {
|
||||
const text = stripHtml(content);
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength).trim() + '...';
|
||||
}
|
||||
109
packages/help/src/privacy-faq.ts
Normal file
109
packages/help/src/privacy-faq.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Shared Privacy, Data Protection & Tech Independence FAQ content
|
||||
* Reusable across all apps — covers GDPR, encryption, self-hosting,
|
||||
* tech stack independence, data export, account deletion
|
||||
*/
|
||||
|
||||
import type { FAQItem } from './content.js';
|
||||
|
||||
export interface PrivacyFAQOptions {
|
||||
/**
|
||||
* What kind of data does the app handle?
|
||||
* Used in the main privacy FAQ to make it specific.
|
||||
* @example 'Aufgaben' / 'tasks', 'Dateien' / 'files', 'Chats' / 'chats'
|
||||
*/
|
||||
dataTypeDE: string;
|
||||
dataTypeEN: string;
|
||||
|
||||
/**
|
||||
* Optional extra bullet points for the main privacy FAQ (app-specific).
|
||||
* Each entry is a single `<li>` string (without the `<li>` tags).
|
||||
* @example ['<strong>Lokale Modelle</strong>: Deine Daten verlassen nie unseren Server']
|
||||
*/
|
||||
extraBulletsDE?: string[];
|
||||
extraBulletsEN?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns shared FAQ items covering privacy, data protection, and tech independence.
|
||||
* The main privacy FAQ can be customized per app via options.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getPrivacyFAQs } from './content';
|
||||
*
|
||||
* const privacyFAQs = getPrivacyFAQs(locale, {
|
||||
* dataTypeDE: 'Aufgaben',
|
||||
* dataTypeEN: 'tasks',
|
||||
* extraBulletsDE: ['<strong>Offline</strong>: Funktioniert auch ohne Internet'],
|
||||
* extraBulletsEN: ['<strong>Offline</strong>: Works without internet'],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function getPrivacyFAQs(locale: string, options: PrivacyFAQOptions): FAQItem[] {
|
||||
const isDE = locale === 'de';
|
||||
|
||||
const extraDE = (options.extraBulletsDE || []).map((b) => `<li>${b}</li>`).join('');
|
||||
const extraEN = (options.extraBulletsEN || []).map((b) => `<li>${b}</li>`).join('');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'faq-privacy',
|
||||
question: isDE
|
||||
? `Wie werden meine ${options.dataTypeDE} geschützt?`
|
||||
: `How are my ${options.dataTypeEN} protected?`,
|
||||
answer: isDE
|
||||
? `<p>Deine Daten sind sicher bei ManaCore:</p><ul><li><strong>Verschlüsselung</strong>: Alle Daten werden bei der Übertragung (TLS) verschlüsselt</li><li><strong>DSGVO-konform</strong>: Wir halten uns an die EU-Datenschutzverordnung</li><li><strong>Kein Datenverkauf</strong>: Deine ${options.dataTypeDE} werden nie an Dritte verkauft oder für Werbung genutzt</li><li><strong>Self-Hosted</strong>: ManaCore läuft auf eigenen Servern — keine Abhängigkeit von Cloud-Anbietern wie AWS oder Google</li><li><strong>Open Source</strong>: Der Quellcode ist einsehbar — Transparenz statt Vertrauen</li>${extraDE}</ul>`
|
||||
: `<p>Your data is secure with ManaCore:</p><ul><li><strong>Encryption</strong>: All data is encrypted in transit (TLS)</li><li><strong>GDPR compliant</strong>: We follow EU data protection regulations</li><li><strong>No data selling</strong>: Your ${options.dataTypeEN} are never sold to third parties or used for advertising</li><li><strong>Self-hosted</strong>: ManaCore runs on its own servers — no dependency on cloud providers like AWS or Google</li><li><strong>Open source</strong>: The source code is viewable — transparency over trust</li>${extraEN}</ul>`,
|
||||
category: 'privacy',
|
||||
order: 95,
|
||||
language: isDE ? 'de' : 'en',
|
||||
featured: true,
|
||||
tags: isDE
|
||||
? ['datenschutz', 'dsgvo', 'sicherheit', 'verschlüsselung']
|
||||
: ['privacy', 'gdpr', 'security', 'encryption'],
|
||||
},
|
||||
{
|
||||
id: 'faq-tech-independence',
|
||||
question: isDE
|
||||
? 'Wie unabhängig ist ManaCore von großen Tech-Konzernen?'
|
||||
: 'How independent is ManaCore from big tech companies?',
|
||||
answer: isDE
|
||||
? '<p>ManaCore ist bewusst <strong>technologisch unabhängig</strong> aufgebaut:</p><ul><li><strong>Eigene Server</strong>: Alle Dienste laufen auf einem eigenen Mac Mini Server — kein AWS, kein Google Cloud, kein Azure</li><li><strong>Eigene KI</strong>: Lokale KI-Modelle (Gemma, Qwen, LLaVA) laufen auf unserem eigenen GPU-Server mit NVIDIA RTX 3090 — deine Daten verlassen nie unsere Infrastruktur</li><li><strong>Keine Google/Apple-Anmeldung</strong>: Eigenes Auth-System (Mana Core Auth) — kein OAuth über Drittanbieter, keine Tracking-Cookies von Google oder Facebook</li><li><strong>Eigene Suche</strong>: SearXNG Meta-Suchmaschine statt Google Search API</li><li><strong>Eigener Speicher</strong>: MinIO (S3-kompatibel) statt AWS S3 oder Google Cloud Storage</li><li><strong>Eigene Datenbank</strong>: PostgreSQL auf eigenem Server statt Cloud-Datenbanken</li><li><strong>Keine Tracking-SDKs</strong>: Kein Google Analytics, kein Facebook Pixel, kein Amplitude — eigene Analytics mit Umami</li></ul><p>Das Ziel: Ein digitales Zuhause, das dir gehört — nicht Big Tech.</p>'
|
||||
: '<p>ManaCore is deliberately built to be <strong>technologically independent</strong>:</p><ul><li><strong>Own servers</strong>: All services run on a dedicated Mac Mini server — no AWS, no Google Cloud, no Azure</li><li><strong>Own AI</strong>: Local AI models (Gemma, Qwen, LLaVA) run on our own GPU server with NVIDIA RTX 3090 — your data never leaves our infrastructure</li><li><strong>No Google/Apple login</strong>: Own auth system (Mana Core Auth) — no OAuth via third parties, no tracking cookies from Google or Facebook</li><li><strong>Own search</strong>: SearXNG meta-search engine instead of Google Search API</li><li><strong>Own storage</strong>: MinIO (S3-compatible) instead of AWS S3 or Google Cloud Storage</li><li><strong>Own database</strong>: PostgreSQL on own server instead of cloud databases</li><li><strong>No tracking SDKs</strong>: No Google Analytics, no Facebook Pixel, no Amplitude — own analytics with Umami</li></ul><p>The goal: A digital home that belongs to you — not big tech.</p>',
|
||||
category: 'privacy',
|
||||
order: 96,
|
||||
language: isDE ? 'de' : 'en',
|
||||
featured: true,
|
||||
tags: isDE
|
||||
? ['unabhängig', 'self-hosted', 'eigene-server', 'kein-google', 'kein-aws']
|
||||
: ['independent', 'self-hosted', 'own-servers', 'no-google', 'no-aws'],
|
||||
},
|
||||
{
|
||||
id: 'faq-data-export',
|
||||
question: isDE ? 'Kann ich meine Daten exportieren?' : 'Can I export my data?',
|
||||
answer: isDE
|
||||
? '<p>Ja! Du hast jederzeit volle Kontrolle über deine Daten:</p><ul><li><strong>Datenexport</strong>: Exportiere alle deine Daten in gängigen, offenen Formaten (JSON, CSV, vCard, iCal — je nach App)</li><li><strong>Datenportabilität</strong>: Gemäß DSGVO Art. 20 hast du das Recht auf Datenübertragbarkeit</li><li><strong>Keine Vendor-Lock-in</strong>: Deine Daten gehören dir — nicht uns. Wir nutzen offene Standards, damit du jederzeit zu einem anderen Dienst wechseln kannst</li><li><strong>Standard-Formate</strong>: Keine proprietären Formate — alles ist mit gängigen Tools les- und importierbar</li></ul>'
|
||||
: '<p>Yes! You have full control over your data at all times:</p><ul><li><strong>Data export</strong>: Export all your data in common, open formats (JSON, CSV, vCard, iCal — depending on the app)</li><li><strong>Data portability</strong>: Under GDPR Art. 20 you have the right to data portability</li><li><strong>No vendor lock-in</strong>: Your data belongs to you — not us. We use open standards so you can switch to another service at any time</li><li><strong>Standard formats</strong>: No proprietary formats — everything is readable and importable with common tools</li></ul>',
|
||||
category: 'privacy',
|
||||
order: 97,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['export', 'daten', 'portabilität', 'dsgvo', 'offene-formate']
|
||||
: ['export', 'data', 'portability', 'gdpr', 'open-formats'],
|
||||
},
|
||||
{
|
||||
id: 'faq-account-deletion',
|
||||
question: isDE ? 'Kann ich mein Konto löschen?' : 'Can I delete my account?',
|
||||
answer: isDE
|
||||
? '<p>Ja, jederzeit:</p><ul><li>Gehe zu <strong>Profil > Konto löschen</strong></li><li>Alle deine Daten werden <strong>dauerhaft und unwiderruflich</strong> gelöscht</li><li>Dies umfasst alle Inhalte, Einstellungen und persönlichen Daten</li><li>Geteilte Inhalte (z.B. Share-Links) werden ebenfalls deaktiviert</li></ul><p>Wir empfehlen, vorher einen Datenexport durchzuführen.</p>'
|
||||
: '<p>Yes, at any time:</p><ul><li>Go to <strong>Profile > Delete Account</strong></li><li>All your data will be <strong>permanently and irreversibly</strong> deleted</li><li>This includes all content, settings, and personal data</li><li>Shared content (e.g., share links) will also be deactivated</li></ul><p>We recommend performing a data export beforehand.</p>',
|
||||
category: 'privacy',
|
||||
order: 98,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['konto', 'löschen', 'daten', 'entfernen']
|
||||
: ['account', 'delete', 'data', 'remove'],
|
||||
},
|
||||
];
|
||||
}
|
||||
53
packages/help/src/sanitize.ts
Normal file
53
packages/help/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'],
|
||||
});
|
||||
}
|
||||
130
packages/help/src/schemas.ts
Normal file
130
packages/help/src/schemas.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Zod Schemas for Help Content Validation
|
||||
* Used to validate Markdown frontmatter
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Base Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const supportedLanguageSchema = z.enum(['en', 'de', 'fr', 'it', 'es']);
|
||||
|
||||
export const faqCategorySchema = z.enum([
|
||||
'general',
|
||||
'account',
|
||||
'billing',
|
||||
'features',
|
||||
'technical',
|
||||
'privacy',
|
||||
]);
|
||||
|
||||
export const featureCategorySchema = z.enum(['getting-started', 'core', 'advanced', 'integration']);
|
||||
|
||||
export const guideDifficultySchema = z.enum(['beginner', 'intermediate', 'advanced']);
|
||||
|
||||
export const changelogTypeSchema = z.enum(['major', 'minor', 'patch', 'beta']);
|
||||
|
||||
export const shortcutCategorySchema = z.enum(['navigation', 'editing', 'general', 'app-specific']);
|
||||
|
||||
// ============================================================================
|
||||
// Content Item Schemas (for Frontmatter)
|
||||
// ============================================================================
|
||||
|
||||
const baseContentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
language: supportedLanguageSchema,
|
||||
order: z.number().optional().default(0),
|
||||
appSpecific: z.boolean().optional().default(false),
|
||||
apps: z.array(z.string()).optional().default([]),
|
||||
lastUpdated: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const faqFrontmatterSchema = baseContentSchema.extend({
|
||||
question: z.string().min(1),
|
||||
category: faqCategorySchema,
|
||||
featured: z.boolean().optional().default(false),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
relatedFaqs: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
export const featureFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
category: featureCategorySchema,
|
||||
available: z.boolean().optional().default(true),
|
||||
comingSoon: z.boolean().optional().default(false),
|
||||
highlights: z.array(z.string()).optional().default([]),
|
||||
learnMoreUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const shortcutSchema = z.object({
|
||||
shortcut: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const shortcutsFrontmatterSchema = baseContentSchema.extend({
|
||||
category: shortcutCategorySchema,
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export const guideStepSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
duration: z.string().optional(),
|
||||
});
|
||||
|
||||
export const gettingStartedFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
difficulty: guideDifficultySchema,
|
||||
estimatedTime: z.string().optional(),
|
||||
prerequisites: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
export const changelogChangeSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export const changelogFrontmatterSchema = baseContentSchema.extend({
|
||||
version: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
releaseDate: z.coerce.date(),
|
||||
type: changelogTypeSchema,
|
||||
summary: z.string().optional(),
|
||||
highlighted: z.boolean().optional().default(false),
|
||||
changes: z
|
||||
.object({
|
||||
features: z.array(changelogChangeSchema).optional(),
|
||||
improvements: z.array(changelogChangeSchema).optional(),
|
||||
bugfixes: z.array(changelogChangeSchema).optional(),
|
||||
})
|
||||
.optional(),
|
||||
platforms: z.array(z.string()).optional().default(['all']),
|
||||
});
|
||||
|
||||
export const contactFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
supportEmail: z.string().email().optional(),
|
||||
supportUrl: z.string().url().optional(),
|
||||
discordUrl: z.string().url().optional(),
|
||||
twitterUrl: z.string().url().optional(),
|
||||
documentationUrl: z.string().url().optional(),
|
||||
responseTime: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports from Schemas
|
||||
// ============================================================================
|
||||
|
||||
export type FAQFrontmatter = z.infer<typeof faqFrontmatterSchema>;
|
||||
export type FeatureFrontmatter = z.infer<typeof featureFrontmatterSchema>;
|
||||
export type ShortcutsFrontmatter = z.infer<typeof shortcutsFrontmatterSchema>;
|
||||
export type GettingStartedFrontmatter = z.infer<typeof gettingStartedFrontmatterSchema>;
|
||||
export type ChangelogFrontmatter = z.infer<typeof changelogFrontmatterSchema>;
|
||||
export type ContactFrontmatter = z.infer<typeof contactFrontmatterSchema>;
|
||||
212
packages/help/src/search-engine.ts
Normal file
212
packages/help/src/search-engine.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Search Functionality using Fuse.js
|
||||
* Provides full-text search across help content
|
||||
*/
|
||||
|
||||
import Fuse, { type IFuseOptions } from 'fuse.js';
|
||||
import type {
|
||||
HelpContent,
|
||||
FAQItem,
|
||||
FeatureItem,
|
||||
GettingStartedItem,
|
||||
ChangelogItem,
|
||||
} from './content';
|
||||
import type {
|
||||
SearchableItem,
|
||||
SearchResult,
|
||||
SearchOptions,
|
||||
SearchIndexConfig,
|
||||
} from './content';
|
||||
import { generateExcerpt, stripHtml } from './parser.js';
|
||||
import { sanitizeHtml } from './sanitize.js';
|
||||
|
||||
const DEFAULT_CONFIG: SearchIndexConfig = {
|
||||
titleWeight: 2,
|
||||
contentWeight: 1,
|
||||
tagsWeight: 1.5,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert HelpContent to searchable items
|
||||
*/
|
||||
export function flattenContentForSearch(content: HelpContent): SearchableItem[] {
|
||||
const items: SearchableItem[] = [];
|
||||
|
||||
// FAQs
|
||||
for (const faq of content.faq) {
|
||||
items.push({
|
||||
id: faq.id,
|
||||
type: 'faq',
|
||||
title: faq.question,
|
||||
question: faq.question,
|
||||
content: stripHtml(faq.answer),
|
||||
tags: faq.tags,
|
||||
});
|
||||
}
|
||||
|
||||
// Features
|
||||
for (const feature of content.features) {
|
||||
items.push({
|
||||
id: feature.id,
|
||||
type: 'feature',
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
content: stripHtml(feature.content),
|
||||
tags: feature.highlights,
|
||||
});
|
||||
}
|
||||
|
||||
// Getting Started Guides
|
||||
for (const guide of content.gettingStarted) {
|
||||
items.push({
|
||||
id: guide.id,
|
||||
type: 'guide',
|
||||
title: guide.title,
|
||||
description: guide.description,
|
||||
content: stripHtml(guide.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Changelog
|
||||
for (const log of content.changelog) {
|
||||
items.push({
|
||||
id: log.id,
|
||||
type: 'changelog',
|
||||
title: `${log.version} - ${log.title}`,
|
||||
content: stripHtml(log.content),
|
||||
description: log.summary,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fuse.js search index from help content
|
||||
*/
|
||||
export function buildSearchIndex(
|
||||
content: HelpContent,
|
||||
config: SearchIndexConfig = DEFAULT_CONFIG
|
||||
): Fuse<SearchableItem> {
|
||||
const items = flattenContentForSearch(content);
|
||||
|
||||
const fuseOptions: IFuseOptions<SearchableItem> = {
|
||||
keys: [
|
||||
{ name: 'title', weight: config.titleWeight ?? 2 },
|
||||
{ name: 'question', weight: config.titleWeight ?? 2 },
|
||||
{ name: 'content', weight: config.contentWeight ?? 1 },
|
||||
{ name: 'description', weight: config.contentWeight ?? 1 },
|
||||
{ name: 'tags', weight: config.tagsWeight ?? 1.5 },
|
||||
],
|
||||
threshold: config.threshold ?? 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: config.minMatchCharLength ?? 2,
|
||||
ignoreLocation: true,
|
||||
};
|
||||
|
||||
return new Fuse(items, fuseOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the original item from content
|
||||
*/
|
||||
function findOriginalItem(
|
||||
id: string,
|
||||
type: string,
|
||||
content: HelpContent
|
||||
): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return content.faq.find((item) => item.id === id) ?? null;
|
||||
case 'feature':
|
||||
return content.features.find((item) => item.id === id) ?? null;
|
||||
case 'guide':
|
||||
return content.gettingStarted.find((item) => item.id === id) ?? null;
|
||||
case 'changelog':
|
||||
return content.changelog.find((item) => item.id === id) ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching text in content
|
||||
*/
|
||||
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 safeText.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search help content
|
||||
*/
|
||||
export function search(
|
||||
index: Fuse<SearchableItem>,
|
||||
query: string,
|
||||
content: HelpContent,
|
||||
options: SearchOptions = {}
|
||||
): SearchResult[] {
|
||||
const { limit = 10, threshold, types, appId } = options;
|
||||
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let results = index.search(query, { limit: limit * 2 });
|
||||
|
||||
// Filter by type if specified
|
||||
if (types && types.length > 0) {
|
||||
results = results.filter((r) => types.includes(r.item.type));
|
||||
}
|
||||
|
||||
// Filter by app if specified
|
||||
if (appId) {
|
||||
results = results.filter((r) => {
|
||||
const originalItem = findOriginalItem(r.item.id, r.item.type, content);
|
||||
if (!originalItem) return true;
|
||||
if (!originalItem.appSpecific) return true;
|
||||
return originalItem.apps?.includes(appId);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply threshold filter if specified
|
||||
if (threshold !== undefined) {
|
||||
results = results.filter((r) => (r.score ?? 1) <= threshold);
|
||||
}
|
||||
|
||||
// Limit results
|
||||
results = results.slice(0, limit);
|
||||
|
||||
const mappedResults: SearchResult[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
const originalItem = findOriginalItem(result.item.id, result.item.type, content);
|
||||
if (!originalItem) continue;
|
||||
|
||||
mappedResults.push({
|
||||
id: result.item.id,
|
||||
type: result.item.type,
|
||||
title: result.item.title,
|
||||
excerpt: generateExcerpt(result.item.content, 150),
|
||||
score: result.score ?? 1,
|
||||
highlight: highlightMatch(result.item.title, query),
|
||||
item: originalItem,
|
||||
});
|
||||
}
|
||||
|
||||
return mappedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a search function with pre-built index
|
||||
*/
|
||||
export function createSearcher(content: HelpContent, config?: SearchIndexConfig) {
|
||||
const index = buildSearchIndex(content, config);
|
||||
|
||||
return (query: string, options?: SearchOptions) => search(index, query, content, options);
|
||||
}
|
||||
71
packages/help/src/search-types.ts
Normal file
71
packages/help/src/search-types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Search-related Type Definitions
|
||||
*/
|
||||
|
||||
import type { FAQItem, FeatureItem, GettingStartedItem, ChangelogItem } from './content.js';
|
||||
|
||||
// ============================================================================
|
||||
// Searchable Item Types
|
||||
// ============================================================================
|
||||
|
||||
export type SearchableContentType = 'faq' | 'feature' | 'guide' | 'changelog';
|
||||
|
||||
export interface SearchableItem {
|
||||
id: string;
|
||||
type: SearchableContentType;
|
||||
title: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
question?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Result Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
type: SearchableContentType;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
score: number;
|
||||
highlight?: string;
|
||||
/** Original item reference */
|
||||
item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
/** Maximum number of results to return */
|
||||
limit?: number;
|
||||
/** Minimum score threshold (0-1, lower is more strict) */
|
||||
threshold?: number;
|
||||
/** Filter by content type */
|
||||
types?: SearchableContentType[];
|
||||
/** Filter by app ID (for app-specific content) */
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export interface SearchIndexConfig {
|
||||
/** Weight for title/question field */
|
||||
titleWeight?: number;
|
||||
/** Weight for content field */
|
||||
contentWeight?: number;
|
||||
/** Weight for tags field */
|
||||
tagsWeight?: number;
|
||||
/** Fuzzy match threshold (0-1, lower is more strict) */
|
||||
threshold?: number;
|
||||
/** Minimum characters to start searching */
|
||||
minMatchCharLength?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search State Types (for UI)
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
isSearching: boolean;
|
||||
hasSearched: boolean;
|
||||
}
|
||||
175
packages/help/src/translations.ts
Normal file
175
packages/help/src/translations.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Default translations for the HelpPage component.
|
||||
* Apps can use these directly or override individual fields.
|
||||
*/
|
||||
|
||||
import type { HelpPageTranslations } from './types.js';
|
||||
|
||||
export const defaultTranslationsDE: HelpPageTranslations = {
|
||||
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',
|
||||
},
|
||||
search: {
|
||||
noResults: 'Keine Ergebnisse für "{query}"',
|
||||
resultsCount: '{count} Ergebnisse',
|
||||
searching: 'Suche...',
|
||||
},
|
||||
faq: {
|
||||
noItems: 'Keine häufigen Fragen verfügbar.',
|
||||
allCategories: 'Alle',
|
||||
categories: {
|
||||
general: 'Allgemein',
|
||||
account: 'Konto',
|
||||
billing: 'Abrechnung',
|
||||
features: 'Funktionen',
|
||||
technical: 'Technisch',
|
||||
privacy: 'Datenschutz',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
noItems: 'Keine Features verfügbar.',
|
||||
comingSoon: 'Demnächst',
|
||||
learnMore: 'Mehr erfahren',
|
||||
},
|
||||
shortcuts: {
|
||||
noItems: 'Keine Tastenkürzel verfügbar.',
|
||||
columns: {
|
||||
shortcut: 'Kürzel',
|
||||
action: 'Aktion',
|
||||
description: 'Beschreibung',
|
||||
},
|
||||
},
|
||||
gettingStarted: {
|
||||
noItems: 'Keine Anleitungen verfügbar.',
|
||||
estimatedTime: 'Geschätzte Zeit',
|
||||
difficulty: {
|
||||
beginner: 'Einsteiger',
|
||||
intermediate: 'Fortgeschritten',
|
||||
advanced: 'Experte',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
noInfo: 'Keine Kontaktinformationen verfügbar.',
|
||||
email: 'E-Mail senden',
|
||||
responseTime: 'Antwortzeit',
|
||||
},
|
||||
common: {
|
||||
back: 'Zurück',
|
||||
showMore: 'Mehr anzeigen',
|
||||
showLess: 'Weniger anzeigen',
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultTranslationsEN: 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 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',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default translations for a locale, with optional overrides.
|
||||
* Use this to customize only the fields that differ per app (e.g. subtitle).
|
||||
*/
|
||||
export function getHelpTranslations(
|
||||
locale: string,
|
||||
overrides?: Partial<HelpPageTranslations>
|
||||
): HelpPageTranslations {
|
||||
const base = locale === 'de' ? defaultTranslationsDE : defaultTranslationsEN;
|
||||
if (!overrides) return base;
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
164
packages/help/src/ui-types.ts
Normal file
164
packages/help/src/ui-types.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Component Props and Translation Types
|
||||
*/
|
||||
|
||||
import type { HelpContent, SearchResult } from './content';
|
||||
|
||||
// ============================================================================
|
||||
// Translation Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpPageTranslations {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
searchPlaceholder: string;
|
||||
sections: {
|
||||
faq: string;
|
||||
features: string;
|
||||
shortcuts: string;
|
||||
gettingStarted: string;
|
||||
changelog: string;
|
||||
contact: string;
|
||||
};
|
||||
search: {
|
||||
noResults: string;
|
||||
resultsCount: string;
|
||||
searching: string;
|
||||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
allCategories: string;
|
||||
categories: {
|
||||
general: string;
|
||||
account: string;
|
||||
billing: string;
|
||||
features: string;
|
||||
technical: string;
|
||||
privacy: string;
|
||||
};
|
||||
};
|
||||
features: {
|
||||
noItems: string;
|
||||
comingSoon: string;
|
||||
learnMore: string;
|
||||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
columns: {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
estimatedTime: string;
|
||||
difficulty: {
|
||||
beginner: string;
|
||||
intermediate: string;
|
||||
advanced: string;
|
||||
};
|
||||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
showAll: string;
|
||||
types: {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
beta: string;
|
||||
};
|
||||
labels: {
|
||||
features: string;
|
||||
improvements: string;
|
||||
bugFixes: string;
|
||||
};
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
email: string;
|
||||
responseTime: string;
|
||||
};
|
||||
common: {
|
||||
back: string;
|
||||
showMore: string;
|
||||
showLess: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export type HelpSection =
|
||||
| 'faq'
|
||||
| 'features'
|
||||
| 'shortcuts'
|
||||
| 'getting-started'
|
||||
| 'changelog'
|
||||
| 'contact';
|
||||
|
||||
export interface HelpPageProps {
|
||||
content: HelpContent;
|
||||
appName: string;
|
||||
appId: string;
|
||||
translations: HelpPageTranslations;
|
||||
searchEnabled?: boolean;
|
||||
showFAQ?: boolean;
|
||||
showFeatures?: boolean;
|
||||
showShortcuts?: boolean;
|
||||
showGettingStarted?: boolean;
|
||||
showChangelog?: boolean;
|
||||
showContact?: boolean;
|
||||
defaultSection?: HelpSection;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
onSectionChange?: (section: HelpSection) => void;
|
||||
onSearch?: (query: string, results: SearchResult[]) => void;
|
||||
}
|
||||
|
||||
export interface FAQSectionProps {
|
||||
items: HelpContent['faq'];
|
||||
translations: Pick<HelpPageTranslations, 'faq' | 'common'>;
|
||||
showCategories?: boolean;
|
||||
maxItems?: number;
|
||||
expandFirst?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangelogEntryProps {
|
||||
item: HelpContent['changelog'][number];
|
||||
translations: Pick<HelpPageTranslations, 'changelog'>;
|
||||
}
|
||||
|
||||
export interface FeaturesOverviewProps {
|
||||
items: HelpContent['features'];
|
||||
translations: Pick<HelpPageTranslations, 'features'>;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsProps {
|
||||
items: HelpContent['shortcuts'];
|
||||
translations: Pick<HelpPageTranslations, 'shortcuts'>;
|
||||
}
|
||||
|
||||
export interface GettingStartedGuideProps {
|
||||
items: HelpContent['gettingStarted'];
|
||||
translations: Pick<HelpPageTranslations, 'gettingStarted'>;
|
||||
}
|
||||
|
||||
export interface ChangelogSectionProps {
|
||||
items: HelpContent['changelog'];
|
||||
translations: Pick<HelpPageTranslations, 'changelog'>;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export interface ContactSectionProps {
|
||||
contact: HelpContent['contact'];
|
||||
translations: Pick<HelpPageTranslations, 'contact'>;
|
||||
}
|
||||
|
||||
export interface HelpSearchProps {
|
||||
content: HelpContent;
|
||||
translations: Pick<HelpPageTranslations, 'search'>;
|
||||
placeholder?: string;
|
||||
onResultSelect: (result: SearchResult) => void;
|
||||
}
|
||||
13
packages/help/tsconfig.json
Normal file
13
packages/help/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
}
|
||||
7304
pnpm-lock.yaml
generated
7304
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue