nxtgauge-admin-solid/src/routes/admin/kb.tsx
Ashwin Kumar 3b2c09cd4b style: unify all primary buttons to use shared .btn-primary class
Replaced 37 files worth of inconsistent inline Tailwind button classes
(mixed font-medium/semibold, px-4/px-6, with/without shadow-sm, inline-flex
variants) with the shared .btn-primary CSS class. Added :disabled state to
.btn-primary in app.css so disabled buttons visually dim consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:10:29 +01:00

552 lines
25 KiB
TypeScript

import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type KbCategory = {
id: string;
name: string;
slug: string;
description?: string;
article_count?: number;
updated_at?: string;
};
type KbArticle = {
id: string;
title: string;
slug?: string;
category_id?: string;
category?: string;
status: string;
updated_at?: string;
};
async function loadCategories(): Promise<KbCategory[]> {
try {
const res = await fetch(`${API}/api/admin/kb/categories`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.categories || []);
} catch {
return [];
}
}
async function loadArticles(): Promise<KbArticle[]> {
try {
const res = await fetch(`${API}/api/admin/kb/articles`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.articles || []);
} catch {
return [];
}
}
type KbPageProps = {
initialTab?: 'categories' | 'articles' | 'create-article';
};
export default function KbPage(props: KbPageProps = {}) {
const [tab, setTab] = createSignal<'categories' | 'articles' | 'create-article'>(props.initialTab || 'categories');
// Categories resource
const [categories, { refetch: refetchCategories }] = createResource(loadCategories);
// Articles resource
const [articles, { refetch: refetchArticles }] = createResource(loadArticles);
// --- Categories tab state ---
const [showCatForm, setShowCatForm] = createSignal(false);
const [catName, setCatName] = createSignal('');
const [catSlug, setCatSlug] = createSignal('');
const [catDesc, setCatDesc] = createSignal('');
const [catSaving, setCatSaving] = createSignal(false);
const [catError, setCatError] = createSignal('');
const [editingCatId, setEditingCatId] = createSignal('');
const [editCatName, setEditCatName] = createSignal('');
const [editCatSlug, setEditCatSlug] = createSignal('');
const [editCatSaving, setEditCatSaving] = createSignal(false);
const [editCatError, setEditCatError] = createSignal('');
const [deletingCatId, setDeletingCatId] = createSignal('');
const [actionError, setActionError] = createSignal('');
// --- Articles tab state ---
const [articleSearch, setArticleSearch] = createSignal('');
const [deletingArticleId, setDeletingArticleId] = createSignal('');
const [articleActionError, setArticleActionError] = createSignal('');
// --- Create Article tab state ---
const [artTitle, setArtTitle] = createSignal('');
const [artSlug, setArtSlug] = createSignal('');
const [artCategoryId, setArtCategoryId] = createSignal('');
const [artContent, setArtContent] = createSignal('');
const [artStatus, setArtStatus] = createSignal('DRAFT');
const [artSaving, setArtSaving] = createSignal(false);
const [artError, setArtError] = createSignal('');
// Filtered articles
const filteredArticles = createMemo(() => {
const all = articles() ?? [];
const q = articleSearch().toLowerCase();
if (!q) return all;
return all.filter((a) => a.title?.toLowerCase().includes(q));
});
// Categories actions
const handleAddCategory = async (e: Event) => {
e.preventDefault();
try {
setCatSaving(true);
setCatError('');
const res = await fetch(`${API}/api/admin/kb/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: catName(), slug: catSlug(), description: catDesc() }),
});
if (!res.ok) throw new Error('Failed to create category');
setCatName('');
setCatSlug('');
setCatDesc('');
setShowCatForm(false);
refetchCategories();
} catch (err: any) {
setCatError(err.message || 'Failed to create');
} finally {
setCatSaving(false);
}
};
const startEditCat = (cat: KbCategory) => {
setEditingCatId(cat.id);
setEditCatName(cat.name);
setEditCatSlug(cat.slug);
setEditCatError('');
};
const cancelEditCat = () => {
setEditingCatId('');
setEditCatError('');
};
const saveEditCat = async (id: string) => {
try {
setEditCatSaving(true);
setEditCatError('');
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editCatName(), slug: editCatSlug() }),
});
if (!res.ok) throw new Error('Failed to save');
setEditingCatId('');
refetchCategories();
} catch (err: any) {
setEditCatError(err.message || 'Failed to save');
} finally {
setEditCatSaving(false);
}
};
const deleteCategory = async (id: string, name: string) => {
if (!confirm(`Delete category "${name}"?`)) return;
try {
setDeletingCatId(id);
setActionError('');
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetchCategories();
} catch (err: any) {
setActionError(err.message || 'Failed to delete');
} finally {
setDeletingCatId('');
}
};
// Articles actions
const deleteArticle = async (id: string, title: string) => {
if (!confirm(`Delete article "${title}"?`)) return;
try {
setDeletingArticleId(id);
setArticleActionError('');
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetchArticles();
} catch (err: any) {
setArticleActionError(err.message || 'Failed to delete');
} finally {
setDeletingArticleId('');
}
};
// Create article
const handleCreateArticle = async (e: Event) => {
e.preventDefault();
try {
setArtSaving(true);
setArtError('');
const body: Record<string, any> = {
title: artTitle(),
slug: artSlug(),
content: artContent(),
status: artStatus(),
};
if (artCategoryId()) body.category_id = artCategoryId();
const res = await fetch(`${API}/api/admin/kb/articles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to create article');
setArtTitle('');
setArtSlug('');
setArtCategoryId('');
setArtContent('');
setArtStatus('DRAFT');
setTab('articles');
refetchArticles();
} catch (err: any) {
setArtError(err.message || 'Failed to create');
} finally {
setArtSaving(false);
}
};
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Knowledge Base</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage help articles and categories</p>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex gap-6 overflow-x-auto">
<button
type="button"
class={tab() === 'categories' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setTab('categories')}
>
Categories
</button>
<button
type="button"
class={tab() === 'articles' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setTab('articles')}
>
Articles
</button>
<button
type="button"
class={tab() === 'create-article' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setTab('create-article')}
>
Create Article
</button>
</div>
<div class="flex-1 p-6">
{/* Categories Tab */}
<Show when={tab() === 'categories'}>
<div class="mb-6 flex items-start justify-between gap-4" style="margin-bottom:16px">
<div />
<button class="btn-primary" onClick={() => setShowCatForm(!showCatForm())}>
{showCatForm() ? 'Cancel' : 'Add Category'}
</button>
</div>
<Show when={actionError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{actionError()}</div>
</Show>
<Show when={showCatForm()}>
<div class="table-card" style="margin-bottom:16px;max-width:480px">
<h2 style="margin:0 0 16px;font-size:15px;font-weight:700">New Category</h2>
<Show when={catError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:10px">{catError()}</div>
</Show>
<form onSubmit={handleAddCategory} style="display:flex;flex-direction:column;gap:12px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={catName()}
onInput={(e) => setCatName(e.currentTarget.value)}
required
placeholder="e.g. Getting Started"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={catSlug()}
onInput={(e) => setCatSlug(e.currentTarget.value)}
required
placeholder="e.g. getting-started"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
<textarea
value={catDesc()}
onInput={(e) => setCatDesc(e.currentTarget.value)}
rows="3"
placeholder="Brief description..."
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
/>
</div>
<div>
<button class="btn-primary" type="submit" disabled={catSaving()}>
{catSaving() ? 'Saving...' : 'Create Category'}
</button>
</div>
</form>
</div>
</Show>
<div class="table-card">
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Article Count</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={categories.loading}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!categories.loading && categories.error}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) === 0}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">No categories found.</td></tr>
</Show>
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) > 0}>
<For each={categories()}>
{(cat) => (
<>
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{cat.name}</td>
<td class="text-slate-500" style="font-family:monospace;font-size:13px">{cat.slug}</td>
<td class="text-slate-500">{cat.article_count ?? '—'}</td>
<td>
<div class="flex items-center justify-end gap-1">
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEditCat(cat)}>Edit</button>
<button
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
disabled={deletingCatId() === cat.id}
onClick={() => deleteCategory(cat.id, cat.name)}
>
{deletingCatId() === cat.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
<Show when={editingCatId() === cat.id}>
<tr>
<td colspan="4" style="background:#f8fafc;padding:14px">
<Show when={editCatError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:8px">{editCatError()}</div>
</Show>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={editCatName()}
onInput={(e) => setEditCatName(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={editCatSlug()}
onInput={(e) => setEditCatSlug(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" disabled={editCatSaving()} onClick={() => saveEditCat(cat.id)}>
{editCatSaving() ? 'Saving...' : 'Save'}
</button>
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={cancelEditCat}>Cancel</button>
</div>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</Show>
{/* Articles Tab */}
<Show when={tab() === 'articles'}>
<Show when={articleActionError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{articleActionError()}</div>
</Show>
<div style="margin-bottom:16px">
<input
type="text"
placeholder="Search articles by title..."
value={articleSearch()}
onInput={(e) => setArticleSearch(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:280px"
/>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Updated At</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={articles.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!articles.loading && articles.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!articles.loading && !articles.error && filteredArticles().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No articles found.</td></tr>
</Show>
<Show when={!articles.loading && !articles.error && filteredArticles().length > 0}>
<For each={filteredArticles()}>
{(article) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{article.title}</td>
<td class="text-slate-500">{article.category || '—'}</td>
<td>
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${article.status === 'PUBLISHED' ? 'active' : 'draft'}`}>
{article.status || '—'}
</span>
</td>
<td class="text-slate-500">
{article.updated_at ? new Date(article.updated_at).toLocaleString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/kb/articles/${article.id}/edit`}>Edit</A>
<button
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
disabled={deletingArticleId() === article.id}
onClick={() => deleteArticle(article.id, article.title)}
>
{deletingArticleId() === article.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</Show>
{/* Create Article Tab */}
<Show when={tab() === 'create-article'}>
<div class="table-card" style="max-width:640px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Article</h2>
<Show when={artError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{artError()}</div>
</Show>
<form onSubmit={handleCreateArticle} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
<input
type="text"
value={artTitle()}
onInput={(e) => setArtTitle(e.currentTarget.value)}
required
placeholder="e.g. How to reset your password"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={artSlug()}
onInput={(e) => setArtSlug(e.currentTarget.value)}
placeholder="e.g. how-to-reset-password"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Category</label>
<select
value={artCategoryId()}
onChange={(e) => setArtCategoryId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<option value=""> Select category </option>
<Show when={!categories.loading}>
<For each={categories() ?? []}>
{(cat) => <option value={cat.id}>{cat.name}</option>}
</For>
</Show>
</select>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Content</label>
<textarea
value={artContent()}
onInput={(e) => setArtContent(e.currentTarget.value)}
required
rows="12"
placeholder="Write article content here..."
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Status</label>
<select
value={artStatus()}
onChange={(e) => setArtStatus(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div>
<button class="btn-primary" type="submit" disabled={artSaving()}>
{artSaving() ? 'Creating...' : 'Create Article'}
</button>
</div>
</form>
</div>
</Show>
</div>
</div>
</AdminShell>
);
}