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:
Agent Zero
2026-08-18 23:27:04 +02:00
parent 240a49321d
commit e002272278
8 changed files with 1665 additions and 2 deletions
@@ -0,0 +1,139 @@
/**
* AskKnowledge — query input + answer + evidence cards.
*
* Calls POST /api/v1/knowledge/ask with `{ query, source_types }` and renders
* the returned answer plus evidence cards.
*/
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, Sparkles } from 'lucide-react';
import { askKnowledge, type KnowledgeAskResponse } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
const SOURCE_TYPES = ['contact', 'company', 'wiki', 'dms_file', 'email', 'task'];
export function AskKnowledge() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<KnowledgeAskResponse | null>(null);
const toggleSource = useCallback((type: string) => {
setSourceTypes((prev) =>
prev.includes(type) ? prev.filter((s) => s !== type) : [...prev, type]
);
}, []);
const handleAsk = useCallback(async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const response = await askKnowledge({
query: query.trim(),
source_types: sourceTypes.length > 0 ? sourceTypes : undefined,
});
setResult(response);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.ask.error'));
} finally {
setLoading(false);
}
}, [query, sourceTypes, t]);
return (
<Card title={t('knowledge.ask.title')} description={t('knowledge.ask.description')}>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400" aria-hidden="true" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAsk();
}}
placeholder={t('knowledge.ask.placeholder')}
className="pl-9"
aria-label={t('knowledge.ask.placeholder')}
/>
</div>
<Button onClick={() => void handleAsk()} isLoading={loading} icon={<Sparkles className="h-4 w-4" aria-hidden="true" />}>
{t('knowledge.ask.submit')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-secondary-500">{t('knowledge.ask.sourceTypes')}</span>
{SOURCE_TYPES.map((type) => {
const active = sourceTypes.includes(type);
return (
<button
key={type}
type="button"
onClick={() => toggleSource(type)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium min-h-touch transition-colors ${
active ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
}`}
aria-pressed={active}
>
{type}
</button>
);
})}
</div>
{error && <p className="text-sm text-danger-600" role="alert">{error}</p>}
{loading && (
<div className="flex items-center justify-center py-10" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && result && (
<div className="space-y-4">
<div className="border border-secondary-200 rounded-lg p-4 bg-secondary-50">
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.answer')}</h4>
<p className="text-sm text-secondary-800 whitespace-pre-wrap">{result.answer}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.evidence')}</h4>
{result.evidence.length === 0 ? (
<EmptyState
title={t('knowledge.ask.noEvidence')}
description={t('knowledge.ask.noEvidenceDescription')}
/>
) : (
<ul className="space-y-2">
{result.evidence.map((evidence) => (
<li key={evidence.id} className="border border-secondary-200 rounded-lg p-3 bg-white">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-secondary-900">{evidence.title}</span>
<Badge variant="info">{evidence.source_type}</Badge>
</div>
<p className="text-sm text-secondary-600 line-clamp-3">{evidence.snippet}</p>
{typeof evidence.score === 'number' && (
<p className="text-xs text-secondary-400 mt-1">
{t('knowledge.ask.score')}: {Math.round(evidence.score * 100)}%
</p>
)}
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</Card>
);
}
@@ -0,0 +1,369 @@
/**
* KnowledgeGraph — SVG-based visualization of entity relationships.
*
* Fetches relationships from GET /api/v1/graph/relationships and renders
* Contacts/Companies as nodes (circles) connected by labeled edges.
* Supports pan/zoom and click-to-inspect details.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { fetchGraphRelationships, type GraphRelationship } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Button } from '@/components/ui/Button';
interface GraphNode {
id: string;
entity_type: string;
label: string;
x: number;
y: number;
}
interface GraphEdge {
id: string;
source: string;
target: string;
label: string;
}
interface SelectedNode {
id: string;
entity_type: string;
label: string;
relationships: GraphRelationship[];
}
const NODE_RADIUS = 24;
const WIDTH = 900;
const HEIGHT = 560;
const TYPE_COLORS: Record<string, string> = {
contact: '#3b82f6',
company: '#10b981',
email: '#8b5cf6',
task: '#f59e0b',
dms_file: '#ef4444',
default: '#64748b',
};
function typeColor(type: string): string {
return TYPE_COLORS[type] ?? TYPE_COLORS.default;
}
function typeLabel(type: string): string {
return type.replace(/_/g, ' ');
}
/**
* Simple force-directed layout: nodes repel, edges attract.
* Deterministic initial placement avoids layout jumps.
*/
function layoutNodes(relationships: GraphRelationship[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodeMap = new Map<string, { entity_type: string; label: string }>();
const edges: GraphEdge[] = [];
for (const rel of relationships) {
const sourceKey = `${rel.source_type}:${rel.source_id}`;
const targetKey = `${rel.target_type}:${rel.target_id}`;
if (!nodeMap.has(sourceKey)) {
nodeMap.set(sourceKey, { entity_type: rel.source_type, label: rel.source_type });
}
if (!nodeMap.has(targetKey)) {
nodeMap.set(targetKey, { entity_type: rel.target_type, label: rel.target_type });
}
edges.push({
id: rel.id,
source: sourceKey,
target: targetKey,
label: rel.relationship_type,
});
}
const keys = Array.from(nodeMap.keys());
const nodes: GraphNode[] = keys.map((key, i) => {
const angle = (i / Math.max(keys.length, 1)) * Math.PI * 2;
const radius = Math.min(220, 80 + (i % 5) * 40);
return {
id: key,
entity_type: nodeMap.get(key)?.entity_type ?? 'unknown',
label: nodeMap.get(key)?.label ?? key,
x: WIDTH / 2 + Math.cos(angle) * radius,
y: HEIGHT / 2 + Math.sin(angle) * radius,
};
});
// Simple repulsion/attraction relaxation
for (let iter = 0; iter < 80; iter++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = 40 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].x -= fx;
nodes[i].y -= fy;
nodes[j].x += fx;
nodes[j].y += fy;
}
}
for (const edge of edges) {
const s = nodes.find((n) => n.id === edge.source);
const t = nodes.find((n) => n.id === edge.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = (dist - 160) * 0.02;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
s.x += fx;
s.y += fy;
t.x -= fx;
t.y -= fy;
}
}
// Clamp to viewport with padding
for (const n of nodes) {
n.x = Math.min(Math.max(n.x, 60), WIDTH - 60);
n.y = Math.min(Math.max(n.y, 60), HEIGHT - 60);
}
return { nodes, edges };
}
export function KnowledgeGraph() {
const { t } = useTranslation();
const [relationships, setRelationships] = useState<GraphRelationship[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<GraphNode | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchGraphRelationships({ page_size: 200 });
setRelationships(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.graph.loadError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const { nodes, edges } = useMemo(() => layoutNodes(relationships), [relationships]);
const selectedRelationships = useMemo(() => {
if (!selected) return [];
return relationships.filter(
(r) =>
`${r.source_type}:${r.source_id}` === selected.id ||
`${r.target_type}:${r.target_id}` === selected.id
);
}, [selected, relationships]);
const handleNodeClick = useCallback((node: GraphNode) => {
setSelected(node);
}, []);
const handleWheel = useCallback((e: React.WheelEvent<SVGSVGElement>) => {
const factor = e.deltaY > 0 ? 0.9 : 1.1;
setZoom((z) => Math.min(Math.max(z * factor, 0.4), 3));
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
},
[dragging]
);
const handleMouseUp = useCallback(() => {
setDragging(false);
dragStart.current = null;
}, []);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
return (
<Card
title={t('knowledge.graph.title')}
description={t('knowledge.graph.description')}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.min(z * 1.2, 3))} aria-label={t('knowledge.graph.zoomIn')}>
<ZoomIn className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.max(z * 0.8, 0.4))} aria-label={t('knowledge.graph.zoomOut')}>
<ZoomOut className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={resetView} aria-label={t('knowledge.graph.reset')}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
}
>
{loading && (
<div className="flex items-center justify-center py-16" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && error && (
<div className="py-8 text-center">
<p className="text-danger-600 text-sm mb-4">{error}</p>
<Button variant="secondary" onClick={() => void load()}>
{t('common.retry')}
</Button>
</div>
)}
{!loading && !error && relationships.length === 0 && (
<EmptyState
title={t('knowledge.graph.emptyTitle')}
description={t('knowledge.graph.emptyDescription')}
/>
)}
{!loading && !error && relationships.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 border border-secondary-200 rounded-lg overflow-hidden bg-secondary-50">
<svg
width="100%"
height={HEIGHT}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="cursor-grab active:cursor-grabbing touch-none select-none"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
role="img"
aria-label={t('knowledge.graph.ariaLabel')}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Edges */}
{edges.map((edge) => {
const source = nodes.find((n) => n.id === edge.source);
const target = nodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
const mx = (source.x + target.x) / 2;
const my = (source.y + target.y) / 2;
return (
<g key={edge.id}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#94a3b8"
strokeWidth={1.5}
/>
<text
x={mx}
y={my - 6}
textAnchor="middle"
fontSize={11}
fill="#64748b"
className="pointer-events-none"
>
{edge.label}
</text>
</g>
);
})}
{/* Nodes */}
{nodes.map((node) => {
const isSelected = selected?.id === node.id;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onClick={(e) => {
e.stopPropagation();
handleNodeClick(node);
}}
className="cursor-pointer"
role="button"
aria-label={`${node.label} ${node.entity_type}`}
>
<circle
r={NODE_RADIUS}
fill={typeColor(node.entity_type)}
fillOpacity={isSelected ? 1 : 0.85}
stroke={isSelected ? '#0f172a' : '#ffffff'}
strokeWidth={isSelected ? 3 : 2}
/>
<text
textAnchor="middle"
dy="0.35em"
fontSize={11}
fill="#ffffff"
fontWeight={600}
className="pointer-events-none"
>
{node.label.length > 12 ? `${node.label.slice(0, 11)}` : node.label}
</text>
</g>
);
})}
</g>
</svg>
</div>
<div>
{selected ? (
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<h4 className="font-semibold text-secondary-900 mb-1">{selected.label}</h4>
<p className="text-sm text-secondary-500 mb-3">{typeLabel(selected.entity_type)}</p>
<p className="text-sm font-medium text-secondary-700 mb-2">{t('knowledge.graph.relationships')}</p>
{selectedRelationships.length === 0 && (
<p className="text-sm text-secondary-400">{t('knowledge.graph.noRelationships')}</p>
)}
<ul className="space-y-2">
{selectedRelationships.map((rel) => {
const isSource = `${rel.source_type}:${rel.source_id}` === selected.id;
const otherType = isSource ? rel.target_type : rel.source_type;
return (
<li key={rel.id} className="text-sm text-secondary-700">
<span className="font-medium">{rel.relationship_type}</span>
<span className="text-secondary-400"> </span>
<span>{typeLabel(otherType)}</span>
</li>
);
})}
</ul>
</div>
) : (
<div className="border border-secondary-200 rounded-lg p-4 bg-white text-sm text-secondary-500">
{t('knowledge.graph.selectHint')}
</div>
)}
</div>
</div>
)}
</Card>
);
}
@@ -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>
);
}