feat(kb): migrate admin KB page to persistent API storage
- Replace runtime storage CRUD with real /api/admin/kb endpoints - Add proper error handling and state management - Support seed functionality via API - Align data mapping with backend Article and Category structs - Improves data persistence and admin UX Closes #KB-API-Migration
This commit is contained in:
parent
82cfd3c1d7
commit
005572cdd2
1 changed files with 514 additions and 16 deletions
|
|
@ -1,37 +1,535 @@
|
||||||
import { createResource, createSignal, Show, For, createEffect } from 'solid-js';
|
import { createResource, createSignal, Show, For, createEffect } from 'solid-js';
|
||||||
import {
|
import {
|
||||||
BookOpen, Plus, Search, Edit2, Trash2, CheckCircle,
|
BookOpen, Plus, Search, Edit2, Trash2, CheckCircle,
|
||||||
Clock, User, ChevronRight, FileText, LayoutGrid, List,
|
Clock, Globe, AlertCircle
|
||||||
Save, X, Globe, Lock, AlertCircle
|
|
||||||
} from 'lucide-solid';
|
} from 'lucide-solid';
|
||||||
import { saveRuntimeConfig, getRuntimeConfig } from '~/lib/runtime/storage';
|
|
||||||
import { RuntimeKBConfig, RuntimeKBArticle } from '~/lib/runtime/types';
|
|
||||||
|
|
||||||
const CATEGORIES = [
|
const API = '/api/gateway';
|
||||||
'General', 'Account & Profiles', 'Lead System', 'Credits & Payments',
|
|
||||||
'Verifications', 'Security', 'Policies', 'Troubleshooting'
|
|
||||||
];
|
|
||||||
|
|
||||||
async function loadKBArticles(): Promise<RuntimeKBConfig> {
|
interface Category {
|
||||||
const config = await getRuntimeConfig<RuntimeKBConfig>('knowledge_base', 'GLOBAL_KB');
|
id: string;
|
||||||
return config?.payload || { articles: [] };
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
article_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
summary?: string;
|
||||||
|
body: string;
|
||||||
|
category_id: string;
|
||||||
|
category?: string;
|
||||||
|
status: 'PUBLISHED' | 'DRAFT';
|
||||||
|
target_roles?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
views: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArticles(): Promise<Article[]> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/articles`, {
|
||||||
|
headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load articles');
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : (data.articles || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KB load failed:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories(): Promise<Category[]> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/categories`, {
|
||||||
|
headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load categories');
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : (data.categories || []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createArticle(article: Partial<Article>): Promise<Article | null> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/articles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(article),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create article');
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KB create failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateArticle(id: string, article: Partial<Article>): Promise<Article | null> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(article),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update article');
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KB update failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteArticle(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KB delete failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KnowledgeBasePage() {
|
export default function KnowledgeBasePage() {
|
||||||
const [kbData, { refetch }] = createResource(loadKBArticles);
|
const [articles, { refetch }] = createResource(loadArticles);
|
||||||
|
const [categories] = createResource(loadCategories);
|
||||||
|
const [view, setView] = createSignal<'list' | 'editor'>('list');
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = createSignal('All');
|
||||||
|
|
||||||
|
// Editor state (API format)
|
||||||
|
const [articleId, setArticleId] = createSignal<string | null>(null);
|
||||||
|
const [title, setTitle] = createSignal('');
|
||||||
|
const [slug, setSlug] = createSignal('');
|
||||||
|
const [content, setContent] = createSignal('');
|
||||||
|
const [categoryId, setCategoryId] = createSignal<string>('');
|
||||||
|
const [status, setStatus] = createSignal<'PUBLISHED' | 'DRAFT'>('DRAFT');
|
||||||
|
const [summary, setSummary] = createSignal('');
|
||||||
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal('');
|
||||||
|
|
||||||
|
// Map category name to ID from categories list
|
||||||
|
const getCategoryIdByName = (name: string): string | undefined => {
|
||||||
|
const cats = categories();
|
||||||
|
const cat = cats.find(c => c.name === name);
|
||||||
|
return cat?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map category ID to name
|
||||||
|
const getCategoryNameById = (id: string): string => {
|
||||||
|
const cats = categories();
|
||||||
|
const cat = cats.find(c => c.id === id);
|
||||||
|
return cat?.name || 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = () => {
|
||||||
|
const list = articles() || [];
|
||||||
|
return list.filter(a => {
|
||||||
|
const matchesSearch = a.title.toLowerCase().includes(searchQuery().toLowerCase()) ||
|
||||||
|
(a.body && a.body.toLowerCase().includes(searchQuery().toLowerCase()));
|
||||||
|
const matchesCategory = selectedCategory() === 'All' || getCategoryNameById(a.category_id) === selectedCategory();
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setArticleId(null);
|
||||||
|
setTitle('');
|
||||||
|
setSlug('');
|
||||||
|
setContent('');
|
||||||
|
setCategoryId('');
|
||||||
|
setStatus('DRAFT');
|
||||||
|
setSummary('');
|
||||||
|
setError('');
|
||||||
|
setView('editor');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (article: Article) => {
|
||||||
|
setArticleId(article.id);
|
||||||
|
setTitle(article.title);
|
||||||
|
setSlug(article.slug);
|
||||||
|
setContent(article.body);
|
||||||
|
setCategoryId(article.category_id);
|
||||||
|
setStatus(article.status);
|
||||||
|
setSummary(article.summary || '');
|
||||||
|
setError('');
|
||||||
|
setView('editor');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title() || !content()) {
|
||||||
|
setError('Title and content are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: Partial<Article> = {
|
||||||
|
title: title(),
|
||||||
|
slug: slug() || undefined,
|
||||||
|
content: content(),
|
||||||
|
category_id: categoryId() || undefined,
|
||||||
|
status: status(),
|
||||||
|
summary: summary() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Article | null = null;
|
||||||
|
if (articleId()) {
|
||||||
|
// Update existing
|
||||||
|
result = await updateArticle(articleId(), payload);
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
result = await createArticle(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(articleId() ? 'Failed to update article' : 'Failed to create article');
|
||||||
|
}
|
||||||
|
|
||||||
|
await refetch();
|
||||||
|
setView('list');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to save article');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this article?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await deleteArticle(id);
|
||||||
|
if (ok) {
|
||||||
|
await refetch();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete article');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete article:', err);
|
||||||
|
alert('Failed to delete article');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedArticles = async () => {
|
||||||
|
if (!confirm('This will seed 40 platform articles. Continue?')) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const cats = categories();
|
||||||
|
if (cats.length === 0) {
|
||||||
|
alert('Please create categories first before seeding.');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleTemplates = [
|
||||||
|
{ category: 'Verifications', topics: ['Business Approval', 'Identity Verification', 'Media Verification', 'Verification Timeline', 'Common Rejection Reasons'] },
|
||||||
|
{ category: 'Lead System', topics: ['How to Find Leads', 'Response Quality', 'Budget Matching', 'Unlocking Contacts', 'Lead Expiry'] },
|
||||||
|
{ category: 'Credits & Payments', topics: ['What are Tracecoins', 'Purchasing Plans', 'Refund Policy', 'Tax Invoices', 'Payment Failures'] },
|
||||||
|
{ category: 'Account & Profiles', topics: ['Portfolio Best Practices', 'Service Switching', 'Subscription Settings', 'Privacy Control', 'Profile URL Customization'] },
|
||||||
|
{ category: 'Security', topics: ['Password Recovery', '2FA Setup', 'Scam Prevention', 'Data Privacy GDPR', 'Account Recovery'] },
|
||||||
|
{ category: 'Troubleshooting', topics: ['App Performance', 'Notification Settings', 'Browser Compatibility', 'Image Upload Issues', 'Logout Problems'] },
|
||||||
|
{ category: 'Policies', topics: ['Terms of Service', 'Privacy Policy', 'Cancellation Policy', 'Refund Terms', 'Platform Guidelines'] },
|
||||||
|
{ category: 'General', topics: ['Welcome Guide', 'Mastering the Dashboard', 'Referral Program', 'Support Channels', 'App Updates'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
for (const t of articleTemplates) {
|
||||||
|
const catId = getCategoryIdByName(t.category);
|
||||||
|
if (!catId) continue;
|
||||||
|
|
||||||
|
for (const topic of t.topics) {
|
||||||
|
const slug = topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||||
|
const payload: Partial<Article> = {
|
||||||
|
title: topic,
|
||||||
|
slug: `kb-seed-${slug}`,
|
||||||
|
content: `This is a comprehensive guide about ${topic} on the Nxtgauge platform. We cover everything from initial setup to advanced strategies for success.\n\n### Key Highlights:\n1. Understanding the fundamentals of ${topic}.\n2. Step-by-step implementation guide.\n3. Troubleshooting common roadblocks.\n4. Professional tips for maximizing your business results.\n\nNxtgauge is dedicated to providing the best tools for your professional growth. Check back frequently for updates on this guide.`,
|
||||||
|
category_id: catId,
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
};
|
||||||
|
const result = await createArticle(payload);
|
||||||
|
if (result) created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`${created} articles seeded successfully!`);
|
||||||
|
await refetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Seeding failed:', err);
|
||||||
|
alert('Seeding failed. See console for details.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full bg-[#F8FAFC]">
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-[#0F172A] flex items-center gap-2">
|
||||||
|
<BookOpen size={20} class="text-[#FF5E13]" />
|
||||||
|
Knowledge Base Management
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">Manage help articles via persistent API storage.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={seedArticles}
|
||||||
|
disabled={saving()}
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-[#64748B] hover:bg-gray-100 rounded-lg border border-gray-200 transition-all flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<AlertCircle size={16} /> Seed 40 Articles
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-[#FF5E13] hover:bg-[#E54D00] rounded-lg shadow-sm transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> New Article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={view() === 'list'} fallback={
|
||||||
|
<div class="p-6 max-w-4xl mx-auto w-full">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||||
|
<h2 class="font-bold text-gray-800">{articleId() ? 'Edit' : 'Create'} Article</h2>
|
||||||
|
<button onClick={() => setView('list')} class="text-gray-400 hover:text-gray-600"><Globe size={20}/></button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title()}
|
||||||
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||||
|
placeholder="Article Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Slug (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slug()}
|
||||||
|
onInput={(e) => setSlug(e.currentTarget.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||||
|
placeholder="url-friendly-slug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
value={categoryId()}
|
||||||
|
onChange={(e) => setCategoryId(e.currentTarget.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
<For each={categories()}>{cat => <option value={cat.id}>{cat.name}</option>}</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Status</label>
|
||||||
|
<label class="flex items-center gap-2 py-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={status() === 'PUBLISHED'}
|
||||||
|
onChange={(e) => setStatus(e.currentTarget.checked ? 'PUBLISHED' : 'DRAFT')}
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Published</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Summary (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={summary()}
|
||||||
|
onInput={(e) => setSummary(e.currentTarget.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||||
|
placeholder="Brief summary for listings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Content (Markdown Supported)</label>
|
||||||
|
<textarea
|
||||||
|
rows={15}
|
||||||
|
value={content()}
|
||||||
|
onInput={(e) => setContent(e.currentTarget.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] outline-none font-mono text-sm leading-relaxed"
|
||||||
|
placeholder="Article content..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="mx-6 mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="p-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
disabled={saving()}
|
||||||
|
class="px-4 py-2 text-sm font-bold text-gray-600 hover:text-gray-800 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving()}
|
||||||
|
class="px-6 py-2 text-sm font-bold text-white bg-[#0F172A] hover:bg-[#1E293B] rounded-lg shadow-sm transition-all flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving() ? 'Saving...' : 'Save Article'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div class="px-6 py-4 flex flex-wrap gap-4 items-center justify-between border-b border-gray-200 bg-white">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory('All')}
|
||||||
|
class={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all ${selectedCategory() === 'All' ? 'bg-[#FF5E13] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<For each={categories()}>{(cat) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory(cat.name)}
|
||||||
|
class={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all ${selectedCategory() === cat.name ? 'bg-[#FF5E13] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
)}</For>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full max-w-md">
|
||||||
|
<Search size={18} class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search articles..."
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
class="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<Show when={articles.loading}>
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<Clock size={40} class="animate-spin mb-4" />
|
||||||
|
<p>Loading Knowledge Base...</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!articles.loading && filteredArticles().length === 0}>
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<AlertCircle size={40} class="mb-4" />
|
||||||
|
<p>No articles found. Try seeding data or adjusting filters.</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<For each={filteredArticles()}>{(article) => (
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-all group overflow-hidden">
|
||||||
|
<div class="p-5 border-b border-gray-100">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<span class="px-2 py-0.5 rounded bg-gray-100 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
|
||||||
|
{getCategoryNameById(article.category_id)}
|
||||||
|
</span>
|
||||||
|
<Show when={article.status === 'PUBLISHED'} fallback={<span class="text-[10px] font-bold text-amber-600 bg-amber-50 px-2 py-0.5 rounded">DRAFT</span>}>
|
||||||
|
<span class="text-[10px] font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded flex items-center gap-1">
|
||||||
|
<CheckCircle size={10} /> PUBLISHED
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-[#0F172A] text-lg leading-tight mb-2 group-hover:text-[#FF5E13] transition-colors">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 line-clamp-2 leading-relaxed">
|
||||||
|
{article.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3 bg-gray-50 flex justify-between items-center text-xs text-gray-400">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex items-center gap-1"><Clock size={12} /> {new Date(article.updated_at).toLocaleDateString()}</span>
|
||||||
|
<span class="flex items-center gap-1"><Globe size={12} /> {article.views} views</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onClick={() => openEdit(article)} class="text-gray-400 hover:text-[#FF5E13] transition-colors"><Edit2 size={16} /></button>
|
||||||
|
<button onClick={() => handleDelete(article.id)} class="text-gray-400 hover:text-red-500 transition-colors"><Trash2 size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories(): Promise<{id: string; name: string; slug: string}[]> {
|
||||||
|
try {
|
||||||
|
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
|
||||||
|
const res = await fetch(`${API}/api/admin/kb/categories`, {
|
||||||
|
headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load categories');
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : (data.categories || []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
const [articles, { refetch }] = createResource(loadArticles);
|
||||||
|
const [categories] = createResource(loadCategories);
|
||||||
const [view, setView] = createSignal<'list' | 'editor'>('list');
|
const [view, setView] = createSignal<'list' | 'editor'>('list');
|
||||||
const [searchQuery, setSearchQuery] = createSignal('');
|
const [searchQuery, setSearchQuery] = createSignal('');
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal('All');
|
const [selectedCategory, setSelectedCategory] = createSignal('All');
|
||||||
|
|
||||||
// Editor state
|
// Editor state
|
||||||
const [editingArticle, setEditingArticle] = createSignal<Partial<RuntimeKBArticle> | null>(null);
|
const [editingArticle, setEditingArticle] = createSignal<any>(null);
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
|
||||||
const filteredArticles = () => {
|
const filteredArticles = () => {
|
||||||
const articles = kbData()?.articles || [];
|
const list = articles() || [];
|
||||||
return articles.filter(a => {
|
return list.filter(a => {
|
||||||
const matchesSearch = a.title.toLowerCase().includes(searchQuery().toLowerCase()) ||
|
const matchesSearch = a.title.toLowerCase().includes(searchQuery().toLowerCase()) ||
|
||||||
a.content.toLowerCase().includes(searchQuery().toLowerCase());
|
(a.summary && a.summary.toLowerCase().includes(searchQuery().toLowerCase()));
|
||||||
const matchesCategory = selectedCategory() === 'All' || a.category === selectedCategory();
|
const matchesCategory = selectedCategory() === 'All' || a.category === selectedCategory();
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue