feat(H): H-GRAPH/H-EDITOR/H-BROWSE/H-ASK — frontend knowledge components (Wiki page, editor, browser, knowledge graph, ask-knowledge), i18n updates, tsc clean
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* WikiBrowser — category tree + article list + search.
|
||||
*
|
||||
* Renders categories as a tree (with parent/child nesting) and the matching
|
||||
* articles below. Search filters articles by title via the backend `search`
|
||||
* parameter.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Loader2, Search } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { fetchWikiArticles, fetchWikiCategories, type WikiArticle, type WikiCategory } from '@/api/knowledge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
interface WikiBrowserProps {
|
||||
selectedArticleId: string | null;
|
||||
onSelectArticle: (article: WikiArticle) => void;
|
||||
}
|
||||
|
||||
interface CategoryNode {
|
||||
category: WikiCategory;
|
||||
children: CategoryNode[];
|
||||
}
|
||||
|
||||
function buildTree(categories: WikiCategory[]): CategoryNode[] {
|
||||
const map = new Map<string, CategoryNode>();
|
||||
for (const c of categories) {
|
||||
map.set(c.id, { category: c, children: [] });
|
||||
}
|
||||
const roots: CategoryNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
const parentId = node.category.parent_id;
|
||||
if (parentId && map.has(parentId)) {
|
||||
map.get(parentId)?.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function CategoryNodeItem({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
onSelectCategory,
|
||||
}: {
|
||||
node: CategoryNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onSelectCategory: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isOpen = expanded.has(node.category.id);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className="flex items-center gap-1 py-1.5 px-2 rounded-md hover:bg-secondary-100 cursor-pointer min-h-touch"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => onSelectCategory(node.category.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectCategory(node.category.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-secondary-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(node.category.id);
|
||||
}}
|
||||
aria-label={isOpen ? t('wiki.browser.collapse') : t('wiki.browser.expand')}
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" aria-hidden="true" /> : <ChevronRight className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5" aria-hidden="true" />
|
||||
)}
|
||||
{isOpen ? <FolderOpen className="h-4 w-4 text-primary-500" aria-hidden="true" /> : <Folder className="h-4 w-4 text-primary-500" aria-hidden="true" />}
|
||||
<span className="text-sm text-secondary-800 truncate">{node.category.name}</span>
|
||||
</div>
|
||||
{hasChildren && isOpen && (
|
||||
<ul>
|
||||
{node.children.map((child) => (
|
||||
<CategoryNodeItem
|
||||
key={child.category.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onSelectCategory={onSelectCategory}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function WikiBrowser({ selectedArticleId, onSelectArticle }: WikiBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<WikiCategory[]>([]);
|
||||
const [articles, setArticles] = useState<WikiArticle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catResult, articleResult] = await Promise.all([
|
||||
fetchWikiCategories(),
|
||||
fetchWikiArticles({ page_size: 100, category_id: selectedCategoryId, search: search || null }),
|
||||
]);
|
||||
setCategories(catResult);
|
||||
setArticles(articleResult.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('wiki.browser.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategoryId, search, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const tree = useMemo(() => buildTree(categories), [categories]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectCategory = useCallback((id: string | null) => {
|
||||
setSelectedCategoryId(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-secondary-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400" aria-hidden="true" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('wiki.browser.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
aria-label={t('wiki.browser.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-10" role="status" aria-label={t('common.loading')}>
|
||||
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && <p className="text-sm text-danger-600 p-3">{error}</p>}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectCategory(null)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm font-medium min-h-touch hover:bg-secondary-100',
|
||||
selectedCategoryId === null ? 'bg-primary-50 text-primary-700' : 'text-secondary-700'
|
||||
)}
|
||||
>
|
||||
{t('wiki.browser.allArticles')}
|
||||
</button>
|
||||
<ul className="mt-1">
|
||||
{tree.map((node) => (
|
||||
<CategoryNodeItem
|
||||
key={node.category.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
onSelectCategory={selectCategory}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 border-t border-secondary-200 pt-3">
|
||||
<h3 className="px-3 text-xs font-semibold uppercase tracking-wide text-secondary-500 mb-2">
|
||||
{t('wiki.browser.articles')}
|
||||
</h3>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('wiki.browser.noArticles')}
|
||||
description={t('wiki.browser.noArticlesDescription')}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{articles.map((article) => (
|
||||
<li key={article.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectArticle(article)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 rounded-md hover:bg-secondary-100 min-h-touch',
|
||||
selectedArticleId === article.id ? 'bg-primary-50' : ''
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm font-medium text-secondary-800 truncate">{article.title}</span>
|
||||
<span className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={article.status === 'published' ? 'success' : article.status === 'archived' ? 'secondary' : 'warning'}>
|
||||
{t(`wiki.status.${article.status}`)}
|
||||
</Badge>
|
||||
{article.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="text-xs text-secondary-400">#{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* WikiEditor — Markdown editor with live preview.
|
||||
*
|
||||
* Uses a textarea for Markdown input and react-markdown + remark-gfm for
|
||||
* the live preview pane. Content is stored as Markdown in the backend.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import clsx from 'clsx';
|
||||
import { Eye, PencilLine, Columns2 } from 'lucide-react';
|
||||
|
||||
export interface WikiEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
type EditorMode = 'write' | 'preview' | 'split';
|
||||
|
||||
export function WikiEditor({ value, onChange, placeholder, minHeight = 'min-h-72' }: WikiEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<EditorMode>('split');
|
||||
|
||||
const preview = useMemo(
|
||||
() => (
|
||||
<div className="prose prose-sm max-w-none px-4 py-3 text-secondary-800">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value || t('wiki.editor.emptyPreview')}</ReactMarkdown>
|
||||
</div>
|
||||
),
|
||||
[value, t]
|
||||
);
|
||||
|
||||
const modeButton = (m: EditorMode, label: string, Icon: React.ComponentType<{ className?: string }>) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium min-h-touch transition-colors',
|
||||
mode === m ? 'bg-primary-600 text-white' : 'text-secondary-600 hover:bg-secondary-100'
|
||||
)}
|
||||
aria-pressed={mode === m}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-300 rounded-md overflow-hidden">
|
||||
<div className="flex items-center gap-1 border-b border-secondary-200 bg-secondary-50 px-2 py-1.5">
|
||||
{modeButton('write', t('editor.write'), PencilLine)}
|
||||
{modeButton('preview', t('editor.preview'), Eye)}
|
||||
{modeButton('split', t('editor.split'), Columns2)}
|
||||
</div>
|
||||
<div className={clsx('grid', mode === 'split' ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1')}>
|
||||
{(mode === 'write' || mode === 'split') && (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={t('editor.writeLabel')}
|
||||
className={clsx(
|
||||
'w-full resize-y border-0 focus:outline-none focus:ring-0 p-4 text-sm font-mono text-secondary-900 bg-white',
|
||||
mode === 'split' && 'border-b md:border-b-0 md:border-r border-secondary-200',
|
||||
minHeight
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(mode === 'preview' || mode === 'split') && (
|
||||
<div className={clsx('bg-secondary-50 border-0 overflow-y-auto', minHeight)}>{preview}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user