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,210 @@
|
|||||||
|
/**
|
||||||
|
* Knowledge API client — Wiki articles/categories/versions, knowledge graph,
|
||||||
|
* and Ask Knowledge.
|
||||||
|
*
|
||||||
|
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from './client';
|
||||||
|
|
||||||
|
// ─── Wiki types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type WikiArticleStatus = 'draft' | 'published' | 'archived';
|
||||||
|
|
||||||
|
export interface WikiArticle {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
content_html: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
category_id: string | null;
|
||||||
|
tags: string[];
|
||||||
|
status: WikiArticleStatus;
|
||||||
|
entity_links: Record<string, unknown>[];
|
||||||
|
version: number;
|
||||||
|
published_at: string | null;
|
||||||
|
owner_id: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiArticleListResult {
|
||||||
|
items: WikiArticle[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiArticleCreate {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
content?: string;
|
||||||
|
summary?: string | null;
|
||||||
|
category_id?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
status?: WikiArticleStatus;
|
||||||
|
entity_links?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiArticleUpdate {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
summary?: string | null;
|
||||||
|
category_id?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
status?: WikiArticleStatus;
|
||||||
|
entity_links?: Record<string, unknown>[];
|
||||||
|
edit_comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiCategoryCreate {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiVersion {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
edited_by: string | null;
|
||||||
|
edit_comment: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Graph types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GraphRelationship {
|
||||||
|
id: string;
|
||||||
|
source_type: string;
|
||||||
|
source_id: string;
|
||||||
|
target_type: string;
|
||||||
|
target_id: string;
|
||||||
|
relationship_type: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
owner_id: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphRelationshipsResult {
|
||||||
|
items: GraphRelationship[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ask Knowledge types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KnowledgeAskRequest {
|
||||||
|
query: string;
|
||||||
|
source_types?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeEvidence {
|
||||||
|
id: string;
|
||||||
|
source_type: string;
|
||||||
|
source_id: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score?: number;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeAskResponse {
|
||||||
|
answer: string;
|
||||||
|
evidence: KnowledgeEvidence[];
|
||||||
|
sources_used?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wiki API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchWikiArticles(params?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
category_id?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
search?: string | null;
|
||||||
|
}): Promise<WikiArticleListResult> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.page) query.set('page', String(params.page));
|
||||||
|
if (params?.page_size) query.set('page_size', String(params.page_size));
|
||||||
|
if (params?.category_id) query.set('category_id', params.category_id);
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
const qs = query.toString();
|
||||||
|
return apiGet<WikiArticleListResult>(`/wiki/articles${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiArticle(articleId: string): Promise<WikiArticle> {
|
||||||
|
return apiGet<WikiArticle>(`/wiki/articles/${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWikiArticle(data: WikiArticleCreate): Promise<WikiArticle> {
|
||||||
|
return apiPost<WikiArticle>('/wiki/articles', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWikiArticle(articleId: string, data: WikiArticleUpdate): Promise<WikiArticle> {
|
||||||
|
return apiPatch<WikiArticle>(`/wiki/articles/${articleId}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWikiArticle(articleId: string): Promise<void> {
|
||||||
|
await apiDelete<void>(`/wiki/articles/${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiVersions(articleId: string): Promise<WikiVersion[]> {
|
||||||
|
const result = await apiGet<{ items: WikiVersion[] }>(`/wiki/articles/${articleId}/versions`);
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreWikiVersion(articleId: string, version: number): Promise<WikiArticle> {
|
||||||
|
return apiPost<WikiArticle>(`/wiki/articles/${articleId}/versions/${version}/restore`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiCategories(): Promise<WikiCategory[]> {
|
||||||
|
const result = await apiGet<{ items: WikiCategory[] }>('/wiki/categories');
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWikiCategory(data: WikiCategoryCreate): Promise<WikiCategory> {
|
||||||
|
return apiPost<WikiCategory>('/wiki/categories', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Graph API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchGraphRelationships(params?: {
|
||||||
|
source_type?: string | null;
|
||||||
|
source_id?: string | null;
|
||||||
|
target_type?: string | null;
|
||||||
|
target_id?: string | null;
|
||||||
|
relationship_type?: string | null;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<GraphRelationshipsResult> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.source_type) query.set('source_type', params.source_type);
|
||||||
|
if (params?.source_id) query.set('source_id', params.source_id);
|
||||||
|
if (params?.target_type) query.set('target_type', params.target_type);
|
||||||
|
if (params?.target_id) query.set('target_id', params.target_id);
|
||||||
|
if (params?.relationship_type) query.set('relationship_type', params.relationship_type);
|
||||||
|
if (params?.page) query.set('page', String(params.page));
|
||||||
|
if (params?.page_size) query.set('page_size', String(params.page_size));
|
||||||
|
const qs = query.toString();
|
||||||
|
return apiGet<GraphRelationshipsResult>(`/graph/relationships${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ask Knowledge API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function askKnowledge(body: KnowledgeAskRequest): Promise<KnowledgeAskResponse> {
|
||||||
|
return apiPost<KnowledgeAskResponse>('/knowledge/ask', body);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1154,5 +1154,108 @@
|
|||||||
"install": "Installieren",
|
"install": "Installieren",
|
||||||
"dismiss": "Später",
|
"dismiss": "Später",
|
||||||
"installed": "LeoCRM ist installiert."
|
"installed": "LeoCRM ist installiert."
|
||||||
|
},
|
||||||
|
"wiki": {
|
||||||
|
"title": "Wiki",
|
||||||
|
"subtitle": "Wissensartikel verwalten",
|
||||||
|
"newArticle": "Neuer Artikel",
|
||||||
|
"menu": {
|
||||||
|
"wiki": "Wiki"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"published": "Veröffentlicht",
|
||||||
|
"archived": "Archiviert"
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"searchPlaceholder": "Artikel suchen...",
|
||||||
|
"allArticles": "Alle Artikel",
|
||||||
|
"articles": "Artikel",
|
||||||
|
"noArticles": "Keine Artikel gefunden",
|
||||||
|
"noArticlesDescription": "Erstellen Sie einen neuen Artikel oder passen Sie die Suche an.",
|
||||||
|
"collapse": "Einklappen",
|
||||||
|
"expand": "Ausklappen",
|
||||||
|
"loadError": "Wiki konnte nicht geladen werden."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"createTitle": "Neuer Artikel",
|
||||||
|
"editTitle": "Artikel bearbeiten",
|
||||||
|
"title": "Titel",
|
||||||
|
"slug": "Slug",
|
||||||
|
"slugPlaceholder": "optional",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"noCategory": "Keine Kategorie",
|
||||||
|
"status": "Status",
|
||||||
|
"tags": "Tags",
|
||||||
|
"tagsPlaceholder": "durch Komma getrennt",
|
||||||
|
"summary": "Zusammenfassung",
|
||||||
|
"content": "Inhalt",
|
||||||
|
"contentPlaceholder": "Markdown-Inhalt schreiben...",
|
||||||
|
"titleRequired": "Titel ist erforderlich.",
|
||||||
|
"emptyPreview": "Kein Inhalt zum Vorschauen."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"noSelection": "Kein Artikel ausgewählt",
|
||||||
|
"noSelectionDescription": "Wählen Sie einen Artikel aus der Liste oder erstellen Sie einen neuen.",
|
||||||
|
"emptyContent": "Dieser Artikel hat noch keinen Inhalt.",
|
||||||
|
"loadError": "Artikel konnte nicht geladen werden."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Artikel löschen",
|
||||||
|
"message": "Möchten Sie den Artikel \"{{title}}\" wirklich löschen?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"graph": {
|
||||||
|
"title": "Wissensgraph",
|
||||||
|
"description": "Beziehungen zwischen Entitäten",
|
||||||
|
"zoomIn": "Vergrößern",
|
||||||
|
"zoomOut": "Verkleinern",
|
||||||
|
"reset": "Ansicht zurücksetzen",
|
||||||
|
"emptyTitle": "Keine Beziehungen",
|
||||||
|
"emptyDescription": "Noch keine Beziehungen im Wissensgraph vorhanden.",
|
||||||
|
"relationships": "Beziehungen",
|
||||||
|
"noRelationships": "Keine Beziehungen für diesen Knoten.",
|
||||||
|
"selectHint": "Klicken Sie auf einen Knoten, um Details zu sehen.",
|
||||||
|
"loadError": "Wissensgraph konnte nicht geladen werden.",
|
||||||
|
"ariaLabel": "Wissensgraph-Visualisierung"
|
||||||
|
},
|
||||||
|
"ask": {
|
||||||
|
"title": "Wissen fragen",
|
||||||
|
"description": "Stellen Sie eine Frage an Ihr gesammeltes Wissen",
|
||||||
|
"placeholder": "Ihre Frage...",
|
||||||
|
"submit": "Fragen",
|
||||||
|
"sourceTypes": "Quellen:",
|
||||||
|
"answer": "Antwort",
|
||||||
|
"evidence": "Belege",
|
||||||
|
"noEvidence": "Keine Belege gefunden",
|
||||||
|
"noEvidenceDescription": "Für diese Frage wurden keine Quellen gefunden.",
|
||||||
|
"score": "Relevanz",
|
||||||
|
"error": "Wissen konnte nicht abgefragt werden."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"created": "Artikel erstellt.",
|
||||||
|
"updated": "Artikel aktualisiert.",
|
||||||
|
"deleted": "Artikel gelöscht.",
|
||||||
|
"saveError": "Artikel konnte nicht gespeichert werden.",
|
||||||
|
"deleteError": "Artikel konnte nicht gelöscht werden."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"title": "Versionen",
|
||||||
|
"empty": "Keine Versionen vorhanden.",
|
||||||
|
"restore": "Wiederherstellen",
|
||||||
|
"restored": "Version wiederhergestellt.",
|
||||||
|
"loadError": "Versionen konnten nicht geladen werden.",
|
||||||
|
"restoreError": "Version konnte nicht wiederhergestellt werden."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"version": "Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"write": "Schreiben",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"split": "Geteilt",
|
||||||
|
"writeLabel": "Markdown-Inhalt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1154,5 +1154,108 @@
|
|||||||
"install": "Install",
|
"install": "Install",
|
||||||
"dismiss": "Later",
|
"dismiss": "Later",
|
||||||
"installed": "LeoCRM is installed."
|
"installed": "LeoCRM is installed."
|
||||||
|
},
|
||||||
|
"wiki": {
|
||||||
|
"title": "Wiki",
|
||||||
|
"subtitle": "Manage knowledge articles",
|
||||||
|
"newArticle": "New Article",
|
||||||
|
"menu": {
|
||||||
|
"wiki": "Wiki"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"published": "Published",
|
||||||
|
"archived": "Archived"
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"searchPlaceholder": "Search articles...",
|
||||||
|
"allArticles": "All Articles",
|
||||||
|
"articles": "Articles",
|
||||||
|
"noArticles": "No articles found",
|
||||||
|
"noArticlesDescription": "Create a new article or adjust your search.",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
|
"loadError": "Failed to load wiki."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"createTitle": "New Article",
|
||||||
|
"editTitle": "Edit Article",
|
||||||
|
"title": "Title",
|
||||||
|
"slug": "Slug",
|
||||||
|
"slugPlaceholder": "optional",
|
||||||
|
"category": "Category",
|
||||||
|
"noCategory": "No category",
|
||||||
|
"status": "Status",
|
||||||
|
"tags": "Tags",
|
||||||
|
"tagsPlaceholder": "comma separated",
|
||||||
|
"summary": "Summary",
|
||||||
|
"content": "Content",
|
||||||
|
"contentPlaceholder": "Write Markdown...",
|
||||||
|
"titleRequired": "Title is required.",
|
||||||
|
"emptyPreview": "No content to preview."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"noSelection": "No article selected",
|
||||||
|
"noSelectionDescription": "Select an article from the list or create a new one.",
|
||||||
|
"emptyContent": "This article has no content yet.",
|
||||||
|
"loadError": "Failed to load article."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete Article",
|
||||||
|
"message": "Are you sure you want to delete the article \"{{title}}\"?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"graph": {
|
||||||
|
"title": "Knowledge Graph",
|
||||||
|
"description": "Relationships between entities",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"reset": "Reset view",
|
||||||
|
"emptyTitle": "No relationships",
|
||||||
|
"emptyDescription": "No relationships in the knowledge graph yet.",
|
||||||
|
"relationships": "Relationships",
|
||||||
|
"noRelationships": "No relationships for this node.",
|
||||||
|
"selectHint": "Click a node to see details.",
|
||||||
|
"loadError": "Failed to load knowledge graph.",
|
||||||
|
"ariaLabel": "Knowledge graph visualization"
|
||||||
|
},
|
||||||
|
"ask": {
|
||||||
|
"title": "Ask Knowledge",
|
||||||
|
"description": "Ask a question against your collected knowledge",
|
||||||
|
"placeholder": "Your question...",
|
||||||
|
"submit": "Ask",
|
||||||
|
"sourceTypes": "Sources:",
|
||||||
|
"answer": "Answer",
|
||||||
|
"evidence": "Evidence",
|
||||||
|
"noEvidence": "No evidence found",
|
||||||
|
"noEvidenceDescription": "No sources found for this question.",
|
||||||
|
"score": "Relevance",
|
||||||
|
"error": "Failed to query knowledge."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"created": "Article created.",
|
||||||
|
"updated": "Article updated.",
|
||||||
|
"deleted": "Article deleted.",
|
||||||
|
"saveError": "Failed to save article.",
|
||||||
|
"deleteError": "Failed to delete article."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"title": "Versions",
|
||||||
|
"empty": "No versions available.",
|
||||||
|
"restore": "Restore",
|
||||||
|
"restored": "Version restored.",
|
||||||
|
"loadError": "Failed to load versions.",
|
||||||
|
"restoreError": "Failed to restore version."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"version": "Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"write": "Write",
|
||||||
|
"preview": "Preview",
|
||||||
|
"split": "Split",
|
||||||
|
"writeLabel": "Markdown content"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* Wiki page — knowledge articles with categories, Markdown editor, versioning.
|
||||||
|
*
|
||||||
|
* Combines WikiBrowser (category tree + article list + search), article detail
|
||||||
|
* with Markdown preview, create/edit modal, delete, and version history with
|
||||||
|
* restore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { CalendarDays, History, Loader2, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
createWikiArticle,
|
||||||
|
deleteWikiArticle,
|
||||||
|
fetchWikiArticle,
|
||||||
|
fetchWikiCategories,
|
||||||
|
fetchWikiVersions,
|
||||||
|
restoreWikiVersion,
|
||||||
|
updateWikiArticle,
|
||||||
|
type WikiArticle,
|
||||||
|
type WikiArticleStatus,
|
||||||
|
type WikiCategory,
|
||||||
|
type WikiVersion,
|
||||||
|
} from '@/api/knowledge';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { WikiBrowser } from '@/components/wiki/WikiBrowser';
|
||||||
|
import { WikiEditor } from '@/components/wiki/WikiEditor';
|
||||||
|
|
||||||
|
interface ArticleForm {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
summary: string;
|
||||||
|
category_id: string;
|
||||||
|
tags: string;
|
||||||
|
status: WikiArticleStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ArticleForm = {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
content: '',
|
||||||
|
summary: '',
|
||||||
|
category_id: '',
|
||||||
|
tags: '',
|
||||||
|
status: 'draft',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WikiPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);
|
||||||
|
const [article, setArticle] = useState<WikiArticle | null>(null);
|
||||||
|
const [loadingArticle, setLoadingArticle] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [versions, setVersions] = useState<WikiVersion[]>([]);
|
||||||
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<ArticleForm>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<WikiArticle | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cats = await fetchWikiCategories();
|
||||||
|
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — categories are optional in the editor
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCategories();
|
||||||
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
const loadArticle = useCallback(async (id: string) => {
|
||||||
|
setLoadingArticle(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchWikiArticle(id);
|
||||||
|
setArticle(data);
|
||||||
|
setSelectedArticleId(data.id);
|
||||||
|
setShowVersions(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('wiki.detail.loadError'));
|
||||||
|
} finally {
|
||||||
|
setLoadingArticle(false);
|
||||||
|
}
|
||||||
|
}, [t, toast]);
|
||||||
|
|
||||||
|
const handleSelectArticle = useCallback(
|
||||||
|
(a: WikiArticle) => {
|
||||||
|
void loadArticle(a.id);
|
||||||
|
},
|
||||||
|
[loadArticle]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback(() => {
|
||||||
|
if (!article) return;
|
||||||
|
setEditingId(article.id);
|
||||||
|
setForm({
|
||||||
|
title: article.title,
|
||||||
|
slug: article.slug,
|
||||||
|
content: article.content,
|
||||||
|
summary: article.summary ?? '',
|
||||||
|
category_id: article.category_id ?? '',
|
||||||
|
tags: article.tags.join(', '),
|
||||||
|
status: article.status,
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, [article]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
toast.error(t('wiki.editor.titleRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
slug: form.slug.trim() || undefined,
|
||||||
|
content: form.content,
|
||||||
|
summary: form.summary.trim() || null,
|
||||||
|
category_id: form.category_id || null,
|
||||||
|
tags: form.tags.split(',').map((s) => s.trim()).filter(Boolean),
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
if (editingId) {
|
||||||
|
const updated = await updateWikiArticle(editingId, payload);
|
||||||
|
setArticle(updated);
|
||||||
|
toast.success(t('knowledge.editor.updated'));
|
||||||
|
} else {
|
||||||
|
const created = await createWikiArticle(payload);
|
||||||
|
setArticle(created);
|
||||||
|
setSelectedArticleId(created.id);
|
||||||
|
toast.success(t('knowledge.editor.created'));
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('knowledge.editor.saveError'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [form, editingId, t, toast]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteWikiArticle(deleteTarget.id);
|
||||||
|
toast.success(t('knowledge.editor.deleted'));
|
||||||
|
setArticle(null);
|
||||||
|
setSelectedArticleId(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('knowledge.editor.deleteError'));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}, [deleteTarget, t, toast]);
|
||||||
|
|
||||||
|
const loadVersions = useCallback(async () => {
|
||||||
|
if (!article) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchWikiVersions(article.id);
|
||||||
|
setVersions(data);
|
||||||
|
setShowVersions(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('knowledge.versions.loadError'));
|
||||||
|
}
|
||||||
|
}, [article, t, toast]);
|
||||||
|
|
||||||
|
const handleRestore = useCallback(
|
||||||
|
async (version: number) => {
|
||||||
|
if (!article) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const restored = await restoreWikiVersion(article.id, version);
|
||||||
|
setArticle(restored);
|
||||||
|
setShowVersions(false);
|
||||||
|
toast.success(t('knowledge.versions.restored'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('knowledge.versions.restoreError'));
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[article, t, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusBadge = useMemo(() => {
|
||||||
|
if (!article) return null;
|
||||||
|
const variant =
|
||||||
|
article.status === 'published' ? 'success' : article.status === 'archived' ? 'secondary' : 'warning';
|
||||||
|
return <Badge variant={variant}>{t(`wiki.status.${article.status}`)}</Badge>;
|
||||||
|
}, [article, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-secondary-900">{t('wiki.title')}</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('wiki.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
{t('wiki.newArticle')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 h-[calc(100vh-8rem)]">
|
||||||
|
{/* Browser sidebar */}
|
||||||
|
<div className="lg:col-span-1 border border-secondary-200 rounded-lg bg-white overflow-hidden">
|
||||||
|
<WikiBrowser selectedArticleId={selectedArticleId} onSelectArticle={handleSelectArticle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article detail / editor */}
|
||||||
|
<div className="lg:col-span-3 overflow-y-auto">
|
||||||
|
{loadingArticle ? (
|
||||||
|
<div className="flex items-center justify-center py-20" role="status" aria-label={t('common.loading')}>
|
||||||
|
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : article ? (
|
||||||
|
<Card
|
||||||
|
title={article.title}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusBadge}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void loadVersions()} icon={<History className="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
{t('knowledge.versions.title')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={openEdit} icon={<Pencil className="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(article)} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-secondary-500 mb-4">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<CalendarDays className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{formatDate(article.updated_at)}
|
||||||
|
</span>
|
||||||
|
<span>{t('knowledge.detail.version')}: {article.version}</span>
|
||||||
|
{article.tags.map((tag) => (
|
||||||
|
<span key={tag} className="text-xs text-secondary-400">#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showVersions && (
|
||||||
|
<div className="mb-4 border border-secondary-200 rounded-lg p-3 bg-secondary-50">
|
||||||
|
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.versions.title')}</h4>
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<p className="text-sm text-secondary-500">{t('knowledge.versions.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<li key={v.id} className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-secondary-800">v{v.version}</span>
|
||||||
|
<span className="text-secondary-400 ml-2">{formatDate(v.created_at)}</span>
|
||||||
|
{v.edit_comment && <span className="text-secondary-500 ml-2">— {v.edit_comment}</span>}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleRestore(v.version)}
|
||||||
|
isLoading={restoring}
|
||||||
|
icon={<Undo2 className="h-4 w-4" aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
{t('knowledge.versions.restore')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={t('wiki.detail.noSelection')}
|
||||||
|
description={t('wiki.detail.noSelectionDescription')}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
{t('wiki.newArticle')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit modal */}
|
||||||
|
<Modal
|
||||||
|
open={editorOpen}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
title={editingId ? t('wiki.editor.editTitle') : t('wiki.editor.createTitle')}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={t('wiki.editor.title')}
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('wiki.editor.slug')}
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||||
|
placeholder={t('wiki.editor.slugPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Select
|
||||||
|
label={t('wiki.editor.category')}
|
||||||
|
value={form.category_id}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, category_id: e.target.value }))}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('wiki.editor.noCategory') },
|
||||||
|
...categories.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('wiki.editor.status')}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value as WikiArticleStatus }))}
|
||||||
|
options={[
|
||||||
|
{ value: 'draft', label: t('wiki.status.draft') },
|
||||||
|
{ value: 'published', label: t('wiki.status.published') },
|
||||||
|
{ value: 'archived', label: t('wiki.status.archived') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('wiki.editor.tags')}
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
||||||
|
placeholder={t('wiki.editor.tagsPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('wiki.editor.summary')}
|
||||||
|
value={form.summary}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('wiki.editor.content')}</label>
|
||||||
|
<WikiEditor
|
||||||
|
value={form.content}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, content: value }))}
|
||||||
|
placeholder={t('wiki.editor.contentPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={() => setEditorOpen(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleSave()} isLoading={saving}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title={t('wiki.delete.title')}
|
||||||
|
message={t('wiki.delete.message', { title: deleteTarget?.title ?? '' })}
|
||||||
|
confirmLabel={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={() => void handleDelete()}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user