mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
65
packages/shared-help-ui/package.json
Normal file
65
packages/shared-help-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal file
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
123
packages/shared-help-ui/src/components/ContactSection.svelte
Normal file
123
packages/shared-help-ui/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/shared-help-ui/src/components/FAQItem.svelte
Normal file
46
packages/shared-help-ui/src/components/FAQItem.svelte
Normal 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>
|
||||
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal file
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal 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>
|
||||
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal file
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal file
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
33
packages/shared-help-ui/src/index.ts
Normal file
33
packages/shared-help-ui/src/index.ts
Normal 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';
|
||||
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal file
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal 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>
|
||||
147
packages/shared-help-ui/src/types.ts
Normal file
147
packages/shared-help-ui/src/types.ts
Normal 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;
|
||||
}
|
||||
16
packages/shared-help-ui/tsconfig.json
Normal file
16
packages/shared-help-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue