feat: major update with network graphs, themes, todo extensions, and more

## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -0,0 +1,65 @@
{
"name": "@manacore/shared-help-ui",
"version": "1.0.0",
"private": true,
"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"
},
"./HelpPage.svelte": {
"svelte": "./src/pages/HelpPage.svelte",
"default": "./src/pages/HelpPage.svelte"
},
"./FAQSection.svelte": {
"svelte": "./src/components/FAQSection.svelte",
"default": "./src/components/FAQSection.svelte"
},
"./FeaturesOverview.svelte": {
"svelte": "./src/components/FeaturesOverview.svelte",
"default": "./src/components/FeaturesOverview.svelte"
},
"./KeyboardShortcuts.svelte": {
"svelte": "./src/components/KeyboardShortcuts.svelte",
"default": "./src/components/KeyboardShortcuts.svelte"
},
"./GettingStartedGuide.svelte": {
"svelte": "./src/components/GettingStartedGuide.svelte",
"default": "./src/components/GettingStartedGuide.svelte"
},
"./ChangelogSection.svelte": {
"svelte": "./src/components/ChangelogSection.svelte",
"default": "./src/components/ChangelogSection.svelte"
},
"./ContactSection.svelte": {
"svelte": "./src/components/ContactSection.svelte",
"default": "./src/components/ContactSection.svelte"
},
"./HelpSearch.svelte": {
"svelte": "./src/components/HelpSearch.svelte",
"default": "./src/components/HelpSearch.svelte"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint ."
},
"dependencies": {
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-content": "workspace:*",
"@manacore/shared-icons": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -0,0 +1,152 @@
<script lang="ts">
import type { ChangelogItem } from '@manacore/shared-help-types';
interface Props {
item: ChangelogItem;
typeLabels: Record<string, string>;
}
let { item, typeLabels }: Props = $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',
});
}
</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">
New 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">
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">Bug Fixes</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>

View 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} typeLabels={translations.changelog.types} />
{/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)}
>
Show all releases ({items.length - maxItems} more)
</button>
</div>
{/if}
</div>
{/if}

View 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}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { FAQItem } from '@manacore/shared-help-types';
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>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { FAQItem, FAQCategory } from '@manacore/shared-help-types';
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);
const categories: FAQCategory[] = [
'general',
'account',
'billing',
'features',
'technical',
'privacy',
];
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')}
>
All
</button>
{#each categories as category}
{@const hasItems = items.some((item) => item.category === category)}
{#if hasItems}
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === category}
class:text-primary-700={selectedCategory === category}
class:dark:bg-primary-900={selectedCategory === category}
class:dark:text-primary-300={selectedCategory === category}
class:bg-gray-100={selectedCategory !== category}
class:text-gray-600={selectedCategory !== category}
class:dark:bg-gray-800={selectedCategory !== category}
class:dark:text-gray-400={selectedCategory !== category}
onclick={() => (selectedCategory = category)}
>
{getCategoryLabel(category)}
</button>
{/if}
{/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>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import type { FeatureItem } from '@manacore/shared-help-types';
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>

View 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}

View file

@ -0,0 +1,111 @@
<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 selectedGuide = $derived(() => {
return 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 selectedGuide()}
{@const guide = selectedGuide()}
<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}

View file

@ -0,0 +1,198 @@
<script lang="ts">
import type { HelpSearchProps } from '../types.js';
import type { SearchResult } from '@manacore/shared-help-types';
import { createSearcher } from '@manacore/shared-help-content';
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() {
// Delay to allow click on result
setTimeout(() => {
showResults = false;
}, 200);
}
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">
<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"
/>
<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">
<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">
<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"
>
{#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>
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
index
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
onclick={() => selectResult(result)}
>
<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"
>
{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>

View file

@ -0,0 +1,54 @@
<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">Shortcut</th>
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
</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}

View file

@ -0,0 +1,33 @@
/**
* @manacore/shared-help-ui
* Svelte 5 components for the Help page system
*/
// Main page component
export { default as HelpPage } from './pages/HelpPage.svelte';
// Section components
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';
// Types
export type {
HelpPageProps,
HelpPageTranslations,
HelpSection,
FAQSectionProps,
FeaturesOverviewProps,
KeyboardShortcutsProps,
GettingStartedGuideProps,
ChangelogSectionProps,
ContactSectionProps,
HelpSearchProps,
} from './types.js';

View file

@ -0,0 +1,169 @@
<script lang="ts">
import type { HelpPageProps, HelpSection } from '../types.js';
import type { SearchResult } from '@manacore/shared-help-types';
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: _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));
function setActiveSection(section: HelpSection) {
activeSection = section;
onSectionChange?.(section);
}
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]">
{#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>

View file

@ -0,0 +1,147 @@
/**
* Component Props and Translation Types
*/
import type { HelpContent, SearchResult } from '@manacore/shared-help-types';
// ============================================================================
// 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;
categories: {
general: string;
account: string;
billing: string;
features: string;
technical: string;
privacy: string;
};
};
features: {
noItems: string;
comingSoon: string;
learnMore: string;
};
shortcuts: {
noItems: string;
};
gettingStarted: {
noItems: string;
estimatedTime: string;
difficulty: {
beginner: string;
intermediate: string;
advanced: string;
};
};
changelog: {
noItems: string;
types: {
major: string;
minor: string;
patch: string;
beta: 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 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;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}