mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 18:29:39 +02:00
fix(shared-help): harden help system with XSS protection, i18n, type safety, and reference implementation
- Add HTML sanitization via isomorphic-dompurify in parser layer to prevent XSS - Replace all hardcoded English strings with translations (FAQSection, KeyboardShortcuts, ChangelogEntry/Section) - Remove unsafe `as` type casting in loader.ts, use Zod-inferred generics instead - Add error logging in content loader (replaces silent catch blocks) - Fix HelpSearch blur handling (mousedown+preventDefault instead of setTimeout hack) - Add ARIA attributes to HelpSearch for accessibility - Derive FAQ categories from items instead of hardcoding all 6 - Fix null-safety in GettingStartedGuide.svelte - Fix unused appId variable in HelpPage.svelte, add scroll-reset on tab switch - Rebuild Contacts help page as reference implementation using shared HelpPage component - Add README with quick-start guide, props docs, and translations template Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8356ac634a
commit
42dd7d2a7a
18 changed files with 845 additions and 801 deletions
|
|
@ -1,12 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogItem } from '@manacore/shared-help-types';
|
||||
import type { ChangelogEntryProps } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
item: ChangelogItem;
|
||||
typeLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
let { item, typeLabels }: Props = $props();
|
||||
let { item, translations }: ChangelogEntryProps = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
|
|
@ -32,6 +27,9 @@
|
|||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
const typeLabels = translations.changelog.types;
|
||||
const changeLabels = translations.changelog.labels;
|
||||
</script>
|
||||
|
||||
<div class="border-b border-gray-200 py-4 dark:border-gray-700">
|
||||
|
|
@ -83,7 +81,7 @@
|
|||
{#if item.changes.features && item.changes.features.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
New Features
|
||||
{changeLabels.features}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.features as change}
|
||||
|
|
@ -104,7 +102,7 @@
|
|||
{#if item.changes.improvements && item.changes.improvements.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
Improvements
|
||||
{changeLabels.improvements}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.improvements as change}
|
||||
|
|
@ -124,7 +122,9 @@
|
|||
|
||||
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">Bug Fixes</h5>
|
||||
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">
|
||||
{changeLabels.bugFixes}
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.bugfixes as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
{:else}
|
||||
<div>
|
||||
{#each displayedItems() as item (item.id)}
|
||||
<ChangelogEntry {item} typeLabels={translations.changelog.types} />
|
||||
<ChangelogEntry {item} {translations} />
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
Show all releases ({items.length - maxItems} more)
|
||||
{translations.changelog.showAll} ({items.length - maxItems})
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,8 @@
|
|||
let selectedCategory = $state<FAQCategory | 'all'>('all');
|
||||
let showAll = $state(false);
|
||||
|
||||
const categories: FAQCategory[] = [
|
||||
'general',
|
||||
'account',
|
||||
'billing',
|
||||
'features',
|
||||
'technical',
|
||||
'privacy',
|
||||
];
|
||||
// Derive categories from actual items instead of hardcoding
|
||||
const categories = $derived([...new Set(items.map((item) => item.category))] as FAQCategory[]);
|
||||
|
||||
const filteredItems = $derived(() => {
|
||||
let result = items;
|
||||
|
|
@ -62,27 +56,24 @@
|
|||
class:dark:text-gray-400={selectedCategory !== 'all'}
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
All
|
||||
{translations.faq.allCategories}
|
||||
</button>
|
||||
{#each categories as category}
|
||||
{@const hasItems = items.some((item) => item.category === category)}
|
||||
{#if hasItems}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === category}
|
||||
class:text-primary-700={selectedCategory === category}
|
||||
class:dark:bg-primary-900={selectedCategory === category}
|
||||
class:dark:text-primary-300={selectedCategory === category}
|
||||
class:bg-gray-100={selectedCategory !== category}
|
||||
class:text-gray-600={selectedCategory !== category}
|
||||
class:dark:bg-gray-800={selectedCategory !== category}
|
||||
class:dark:text-gray-400={selectedCategory !== category}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === category}
|
||||
class:text-primary-700={selectedCategory === category}
|
||||
class:dark:bg-primary-900={selectedCategory === category}
|
||||
class:dark:text-primary-300={selectedCategory === category}
|
||||
class:bg-gray-100={selectedCategory !== category}
|
||||
class:text-gray-600={selectedCategory !== category}
|
||||
class:dark:bg-gray-800={selectedCategory !== category}
|
||||
class:dark:text-gray-400={selectedCategory !== category}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
|
||||
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
|
||||
|
||||
const selectedGuide = $derived(() => {
|
||||
return items.find((item) => item.id === selectedGuideId) ?? null;
|
||||
});
|
||||
const guide = $derived(items.find((item) => item.id === selectedGuideId) ?? null);
|
||||
|
||||
function getDifficultyLabel(difficulty: string): string {
|
||||
return (
|
||||
|
|
@ -67,8 +65,7 @@
|
|||
|
||||
<!-- Guide Content -->
|
||||
<div class="flex-1">
|
||||
{#if selectedGuide()}
|
||||
{@const guide = selectedGuide()}
|
||||
{#if guide}
|
||||
<div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{guide.title}
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@
|
|||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay to allow click on result
|
||||
setTimeout(() => {
|
||||
showResults = false;
|
||||
}, 200);
|
||||
function handleBlur(event: FocusEvent) {
|
||||
// Only close if focus moves outside the search container
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
if (relatedTarget?.closest('[data-help-search]')) return;
|
||||
showResults = false;
|
||||
}
|
||||
|
||||
function getTypeIcon(type: string): string {
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative" data-help-search>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -118,10 +118,19 @@
|
|||
onblur={handleBlur}
|
||||
placeholder={placeholder ?? translations.search.noResults}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
aria-label={placeholder ?? translations.search.noResults}
|
||||
role="combobox"
|
||||
aria-expanded={showResults}
|
||||
aria-haspopup="listbox"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{#if isSearching}
|
||||
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
|
|
@ -131,7 +140,13 @@
|
|||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
@ -146,6 +161,7 @@
|
|||
{#if showResults}
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
role="listbox"
|
||||
>
|
||||
{#if results.length === 0}
|
||||
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -154,17 +170,21 @@
|
|||
{:else}
|
||||
<ul class="max-h-96 overflow-auto py-2">
|
||||
{#each results as result, index (result.id)}
|
||||
<li>
|
||||
<li role="option" aria-selected={selectedIndex === index}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
|
||||
index
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: ''}"
|
||||
onclick={() => selectResult(result)}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
selectResult(result);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
|
||||
aria-label={getTypeLabel(result.type)}
|
||||
>
|
||||
{getTypeIcon(result.type)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -25,9 +25,15 @@
|
|||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.shortcut}</th
|
||||
>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.action}</th
|
||||
>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100"
|
||||
>{translations.shortcuts.columns.description}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type {
|
|||
FeaturesOverviewProps,
|
||||
KeyboardShortcutsProps,
|
||||
GettingStartedGuideProps,
|
||||
ChangelogEntryProps,
|
||||
ChangelogSectionProps,
|
||||
ContactSectionProps,
|
||||
HelpSearchProps,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
let {
|
||||
content,
|
||||
appName,
|
||||
appId: _appId,
|
||||
appId,
|
||||
translations,
|
||||
searchEnabled = true,
|
||||
showFAQ = true,
|
||||
|
|
@ -57,9 +57,13 @@
|
|||
|
||||
const visibleSections = $derived(sections.filter((s) => s.show));
|
||||
|
||||
let contentRef: HTMLDivElement;
|
||||
|
||||
function setActiveSection(section: HelpSection) {
|
||||
activeSection = section;
|
||||
onSectionChange?.(section);
|
||||
// Scroll content area into view on tab change
|
||||
contentRef?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleSearchResultSelect(result: SearchResult) {
|
||||
|
|
@ -151,7 +155,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]">
|
||||
<div class="min-h-[400px]" bind:this={contentRef}>
|
||||
{#if activeSection === 'faq' && showFAQ}
|
||||
<FAQSection items={content.faq} {translations} expandFirst />
|
||||
{:else if activeSection === 'features' && showFeatures}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
allCategories: string;
|
||||
categories: {
|
||||
general: string;
|
||||
account: string;
|
||||
|
|
@ -43,6 +44,11 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
columns: {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
|
|
@ -55,12 +61,18 @@ export interface HelpPageTranslations {
|
|||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
showAll: string;
|
||||
types: {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
beta: string;
|
||||
};
|
||||
labels: {
|
||||
features: string;
|
||||
improvements: string;
|
||||
bugFixes: string;
|
||||
};
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
|
|
@ -113,6 +125,11 @@ export interface FAQSectionProps {
|
|||
expandFirst?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangelogEntryProps {
|
||||
item: HelpContent['changelog'][number];
|
||||
translations: Pick<HelpPageTranslations, 'changelog'>;
|
||||
}
|
||||
|
||||
export interface FeaturesOverviewProps {
|
||||
items: HelpContent['features'];
|
||||
translations: Pick<HelpPageTranslations, 'features'>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue