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 [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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -136,6 +192,17 @@ export default function KnowledgeBasePage() {
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue