feat: add admin notification polling, KB category CRUD helpers, fix duplicate code

- AdminShell: added notification bell polling every 30s
- KB page: added createCategory, updateCategory, deleteCategory API helpers
- KB page: added category management state and handlers
- KB page: removed duplicate second implementation (partial)
- KB page: need to insert category UI in next step
This commit is contained in:
Ashwin Kumar 2026-04-06 18:38:28 +02:00
parent 0c757cf5bd
commit 44d4ce5b2a
2 changed files with 182 additions and 341 deletions

View file

@ -316,7 +316,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
const [isSuperAdmin, setIsSuperAdmin] = createSignal(false); const [isSuperAdmin, setIsSuperAdmin] = createSignal(false);
const [sidebarOpen, setSidebarOpen] = createSignal(false); const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false); const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [notifCount] = createSignal(0); const [unreadCount, setUnreadCount] = createSignal(0);
const [theme, setTheme] = createSignal<'light' | 'dark'>('light'); const [theme, setTheme] = createSignal<'light' | 'dark'>('light');
const [routeTransitioning, setRouteTransitioning] = createSignal(false); const [routeTransitioning, setRouteTransitioning] = createSignal(false);
@ -407,6 +407,35 @@ export default function AdminShell(props: { children: JSX.Element }) {
window.addEventListener('resize', refreshTabIndicator); window.addEventListener('resize', refreshTabIndicator);
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator)); onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
// Fetch unread notification count and poll every 30 seconds
const fetchUnreadCount = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
if (!accessToken) return;
const res = await fetch('/api/gateway/api/me/notifications/unread-count', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unread_count || 0);
}
} catch (e) {
console.error('Failed to fetch unread count:', e);
}
};
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30000);
onCleanup(() => clearInterval(interval));
const isPreview = searchParams._preview === '1' || const isPreview = searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1'); (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
@ -563,12 +592,12 @@ export default function AdminShell(props: { children: JSX.Element }) {
<Sun size={18} /> <Sun size={18} />
</Show> </Show>
</button> </button>
<button type="button" style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Notifications"> <button type="button" style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Notifications">
<Bell size={18} /> <Bell size={18} />
<Show when={notifCount() > 0}> <Show when={unreadCount() > 0}>
<span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} /> <span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} />
</Show> </Show>
</button> </button>
<button type="button" style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Settings"> <button type="button" style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Settings">
<Settings size={18} /> <Settings size={18} />
</button> </button>

View file

@ -112,15 +112,71 @@ async function deleteArticle(id: string): Promise<boolean> {
credentials: 'include', credentials: 'include',
}); });
return res.ok; return res.ok;
} catch (e) {
console.error('KB delete failed:', e);
return false;
}
}
// Category Management API
async function createCategory(category: Partial<Category>): Promise<Category | null> {
try {
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
const res = await fetch(`${API}/api/admin/kb/categories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
credentials: 'include',
body: JSON.stringify(category),
});
if (!res.ok) throw new Error('Failed to create category');
return await res.json();
} catch (e) { } catch (e) {
console.error('KB delete failed:', e); console.error('KB create category failed:', e);
return null;
}
}
async function updateCategory(id: string, category: Partial<Category>): Promise<Category | null> {
try {
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
credentials: 'include',
body: JSON.stringify(category),
});
if (!res.ok) throw new Error('Failed to update category');
return await res.json();
} catch (e) {
console.error('KB update category failed:', e);
return null;
}
}
async function deleteCategory(id: string): Promise<boolean> {
try {
const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, {
method: 'DELETE',
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
credentials: 'include',
});
return res.ok;
} catch (e) {
console.error('KB delete category failed:', e);
return false; return false;
} }
} }
export default function KnowledgeBasePage() { export default function KnowledgeBasePage() {
const [articles, { refetch }] = createResource(loadArticles); const [articles, { refetch }] = createResource(loadArticles);
const [categories] = createResource(loadCategories); const [categories, { refetch: refetchCategories }] = 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');
@ -135,7 +191,18 @@ export default function KnowledgeBasePage() {
const [summary, setSummary] = createSignal(''); const [summary, setSummary] = createSignal('');
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
// Category management state
const [catMode, setCatMode] = createSignal<'list' | 'create' | 'edit'>('list');
const [editingCategory, setEditingCategory] = createSignal<Category | null>(null);
const [catName, setCatName] = createSignal('');
const [catSlug, setCatSlug] = createSignal('');
const [catDescription, setCatDescription] = createSignal('');
const [catDisplayOrder, setCatDisplayOrder] = createSignal(0);
const [catIsActive, setCatIsActive] = createSignal(true);
const [catSaving, setCatSaving] = createSignal(false);
const [catError, setCatError] = createSignal('');
// Map category name to ID from categories list // Map category name to ID from categories list
const getCategoryIdByName = (name: string): string | undefined => { const getCategoryIdByName = (name: string): string | undefined => {
const cats = categories(); const cats = categories();
@ -240,6 +307,82 @@ export default function KnowledgeBasePage() {
} }
}; };
// Category Management Handlers
const openCreateCategory = () => {
setEditingCategory(null);
setCatName('');
setCatSlug('');
setCatDescription('');
setCatDisplayOrder(0);
setCatIsActive(true);
setCatError('');
setCatMode('create');
};
const openEditCategory = (cat: Category) => {
setEditingCategory(cat);
setCatName(cat.name);
setCatSlug(cat.slug || '');
setCatDescription(cat.description || '');
setCatDisplayOrder(cat.display_order || 0);
setCatIsActive(cat.is_active ?? true);
setCatError('');
setCatMode('edit');
};
const handleSaveCategory = async () => {
if (!catName().trim()) {
setCatError('Category name is required');
return;
}
setCatSaving(true);
setCatError('');
try {
const payload: Partial<Category> = {
name: catName(),
slug: catSlug() || undefined,
description: catDescription() || undefined,
display_order: catDisplayOrder(),
is_active: catIsActive(),
};
let result: Category | null = null;
if (catMode() === 'edit' && editingCategory()) {
result = await updateCategory(editingCategory().id, payload);
} else {
result = await createCategory(payload);
}
if (!result) throw new Error(catMode() === 'edit' ? 'Failed to update category' : 'Failed to create category');
await refetchCategories();
setCatMode('list');
setEditingCategory(null);
} catch (err: any) {
setCatError(err.message || 'Failed to save category');
} finally {
setCatSaving(false);
}
};
const handleCancelCategory = () => {
setCatMode('list');
setEditingCategory(null);
setCatError('');
};
const handleDeleteCategory = async (id: string) => {
if (!confirm('Delete this category? Articles in this category will become unassociated.')) return;
try {
const ok = await deleteCategory(id);
if (ok) {
await refetchCategories();
} else {
alert('Failed to delete category');
}
} catch (err) {
console.error('Failed to delete category:', err);
alert('Failed to delete category');
}
};
const seedArticles = async () => { const seedArticles = async () => {
if (!confirm('This will seed 40 platform articles. Continue?')) return; if (!confirm('This will seed 40 platform articles. Continue?')) return;
@ -498,335 +641,4 @@ export default function KnowledgeBasePage() {
); );
} }
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 [searchQuery, setSearchQuery] = createSignal('');
const [selectedCategory, setSelectedCategory] = createSignal('All');
// Editor state
const [editingArticle, setEditingArticle] = createSignal<any>(null);
const [saving, setSaving] = createSignal(false);
const filteredArticles = () => {
const list = articles() || [];
return list.filter(a => {
const matchesSearch = a.title.toLowerCase().includes(searchQuery().toLowerCase()) ||
(a.summary && a.summary.toLowerCase().includes(searchQuery().toLowerCase()));
const matchesCategory = selectedCategory() === 'All' || a.category === selectedCategory();
return matchesSearch && matchesCategory;
});
};
const handleEdit = (article: RuntimeKBArticle) => {
setEditingArticle({ ...article });
setView('editor');
};
const handleCreate = () => {
setEditingArticle({
id: `kb-${Date.now()}`,
title: '',
category: 'General',
content: '',
author: 'Admin',
isPublished: false,
viewCount: 0,
lastUpdated: new Date().toISOString()
});
setView('editor');
};
const handleSave = async () => {
const article = editingArticle();
if (!article || !article.title || !article.content) return;
setSaving(true);
try {
const currentArticles = kbData()?.articles || [];
const index = currentArticles.findIndex(a => a.id === article.id);
const updatedArticle = {
...article,
lastUpdated: new Date().toISOString()
} as RuntimeKBArticle;
let newArticles = [...currentArticles];
if (index >= 0) {
newArticles[index] = updatedArticle;
} else {
newArticles.unshift(updatedArticle);
}
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles: newArticles }, 'published');
await refetch();
setView('list');
setEditingArticle(null);
} catch (err) {
console.error('Failed to save article:', err);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this article?')) return;
try {
const currentArticles = kbData()?.articles || [];
const newArticles = currentArticles.filter(a => a.id !== id);
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles: newArticles }, 'published');
await refetch();
} catch (err) {
console.error('Failed to delete article:', err);
}
};
const seedArticles = async () => {
if (!confirm('This will seed 40 platform articles. Continue?')) return;
setSaving(true);
try {
const articles: RuntimeKBArticle[] = [];
// Generation logic for 40 articles
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'] },
];
articleTemplates.forEach(t => {
t.topics.forEach((topic, i) => {
articles.push({
id: `kb-seed-${t.category.toLowerCase().replace(/\s/g, '-')}-${i}`,
title: topic,
category: t.category,
content: `This is a comprehensive guide about ${topic} on the Nxtgauge platform. We cover everything from initial setup to advanced strategies for success.
### Key Highlights:
1. Understanding the fundamentals of ${topic}.
2. Step-by-step implementation guide.
3. Troubleshooting common roadblocks.
4. Professional tips for maximizing your business results.
Nxtgauge is dedicated to providing the best tools for your professional growth. Check back frequently for updates on this guide.`,
author: 'System Architect',
isPublished: true,
viewCount: Math.floor(Math.random() * 500) + 100,
lastUpdated: new Date().toISOString()
});
});
});
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles }, 'published');
await refetch();
alert('40 articles seeded successfully!');
} catch (err) {
console.error('Seeding failed:', err);
} 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 and runtime documentation.</p>
</div>
<div class="flex gap-2">
<button
onClick={seedArticles}
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"
>
<AlertCircle size={16} /> Seed 40 Articles
</button>
<button
onClick={handleCreate}
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">{editingArticle()?.id ? 'Edit' : 'Create'} Article</h2>
<button onClick={() => setView('list')} class="text-gray-400 hover:text-gray-600"><X 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={editingArticle()?.title || ''}
onInput={(e) => setEditingArticle(p => ({ ...p!, title: 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">Category</label>
<select
value={editingArticle()?.category || 'General'}
onChange={(e) => setEditingArticle(p => ({ ...p!, category: e.currentTarget.value }))}
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] outline-none"
>
<For each={CATEGORIES}>{cat => <option value={cat}>{cat}</option>}</For>
</select>
</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={editingArticle()?.isPublished}
onChange={(e) => setEditingArticle(p => ({ ...p!, isPublished: e.currentTarget.checked }))}
/>
<span class="text-sm font-medium text-gray-700">Published</span>
</label>
</div>
</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={editingArticle()?.content || ''}
onInput={(e) => setEditingArticle(p => ({ ...p!, content: 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>
<div class="p-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-3">
<button
onClick={() => setView('list')}
class="px-4 py-2 text-sm font-bold text-gray-600 hover:text-gray-800 transition-all"
>
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"
>
<Save size={16} />
{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)}
class={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all ${selectedCategory() === cat ? 'bg-[#FF5E13] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{cat}
</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={kbData.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={!kbData.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">
{article.category}
</span>
<Show when={article.isPublished} 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.content}
</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.lastUpdated).toLocaleDateString()}</span>
<span class="flex items-center gap-1"><User size={12} /> {article.author}</span>
</div>
<div class="flex gap-2">
<button onClick={() => handleEdit(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>
);
}