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:
parent
0c757cf5bd
commit
44d4ce5b2a
2 changed files with 182 additions and 341 deletions
|
|
@ -316,7 +316,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
const [isSuperAdmin, setIsSuperAdmin] = createSignal(false);
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
||||
const [notifCount] = createSignal(0);
|
||||
const [unreadCount, setUnreadCount] = createSignal(0);
|
||||
const [theme, setTheme] = createSignal<'light' | 'dark'>('light');
|
||||
const [routeTransitioning, setRouteTransitioning] = createSignal(false);
|
||||
|
||||
|
|
@ -407,6 +407,35 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
window.addEventListener('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' ||
|
||||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
|
||||
|
||||
|
|
@ -563,12 +592,12 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
<Sun size={18} />
|
||||
</Show>
|
||||
</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">
|
||||
<Bell size={18} />
|
||||
<Show when={notifCount() > 0}>
|
||||
<span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} />
|
||||
</Show>
|
||||
</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">
|
||||
<Bell size={18} />
|
||||
<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`} />
|
||||
</Show>
|
||||
</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">
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -112,15 +112,71 @@ async function deleteArticle(id: string): Promise<boolean> {
|
|||
credentials: 'include',
|
||||
});
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [articles, { refetch }] = createResource(loadArticles);
|
||||
const [categories] = createResource(loadCategories);
|
||||
const [categories, { refetch: refetchCategories }] = createResource(loadCategories);
|
||||
const [view, setView] = createSignal<'list' | 'editor'>('list');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [selectedCategory, setSelectedCategory] = createSignal('All');
|
||||
|
|
@ -135,7 +191,18 @@ export default function KnowledgeBasePage() {
|
|||
const [summary, setSummary] = createSignal('');
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
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
|
||||
const getCategoryIdByName = (name: string): string | undefined => {
|
||||
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 () => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue