94c7c8fff5
Check Cross-Plugin Imports / check (push) Has been cancelled
1.1 Contacts refresh: useUnifiedContacts mutations already invalidate (verified)
1.2 Contacts drag-drop: ContactList items already draggable + ContactFolderTree onDrop (verified)
1.3 Contacts move dialog: Added move-to-folder dropdown in ContactDetail
1.4 Wiki save refresh: Added refreshKey prop to WikiBrowser, triggers reload after save/delete
1.5 Calendar dialog close: Window.tsx now injects windowId into componentProps
1.6 Communication AI chat: Added metadata support to ConversationCreate schema + service,
frontend passes conversation_type metadata for AI/system chats,
categorization checks metadata first
1.7 Wiki duplicate menu: Removed hardcoded /wiki from Sidebar.tsx (plugin provides it dynamically)
Backend: kommunikation schema/routes/services updated for metadata support
Frontend: tsc clean, build successful
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
|
* 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 [browserRefreshKey, setBrowserRefreshKey] = useState(0);
|
|
|
|
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);
|
|
setBrowserRefreshKey((k) => k + 1);
|
|
} 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);
|
|
setBrowserRefreshKey((k) => k + 1);
|
|
} 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} refreshKey={browserRefreshKey} />
|
|
</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>
|
|
);
|
|
}
|