mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 11:41:23 +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
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue