feat(admin): add manage and edit pages for runtime configs

This commit is contained in:
Ashwin Kumar 2026-03-16 23:30:37 +01:00
parent e5ce1ef1b3
commit 562ad4f8de
9 changed files with 499 additions and 15 deletions

View file

@ -186,3 +186,46 @@ body {
color: #0f766e;
font-weight: 600;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.list-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.list-item {
border: 1px solid #dbe1ec;
border-radius: 12px;
padding: 12px;
background: #fff;
}
.list-item h3 {
margin: 0 0 8px;
font-size: 16px;
}
.list-item p {
margin: 4px 0;
font-size: 13px;
color: #475569;
}
@media (max-width: 1000px) {
.list-grid {
grid-template-columns: 1fr;
}
.list-header {
flex-direction: column;
align-items: flex-start;
}
}

View file

@ -2,8 +2,11 @@ import { A, useLocation } from '@solidjs/router';
const links = [
{ href: '/admin/runtime-roles/new', label: 'Create Role' },
{ href: '/admin/runtime-roles', label: 'Manage Roles' },
{ href: '/admin/role-ui-configs/new', label: 'Create Dashboard' },
{ href: '/admin/role-ui-configs', label: 'Manage Dashboards' },
{ href: '/admin/onboarding-schemas/new', label: 'Create Onboarding Flow' },
{ href: '/admin/onboarding-schemas', label: 'Manage Onboarding Flows' },
];
export default function AdminSidebar() {
@ -14,11 +17,20 @@ export default function AdminSidebar() {
<div class="brand">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" />
</div>
{links.map((item) => (
<A href={item.href} class={`nav-item ${location.pathname === item.href ? 'active' : ''}`}>
{item.label}
</A>
))}
{links.map((item) => {
const isActive =
location.pathname === item.href ||
(item.href !== '/admin/runtime-roles' &&
item.href !== '/admin/role-ui-configs' &&
item.href !== '/admin/onboarding-schemas' &&
location.pathname.startsWith(item.href.replace('/new', '')));
return (
<A href={item.href} class={`nav-item ${isActive ? 'active' : ''}`}>
{item.label}
</A>
);
})}
<p class="notice">Same UI flow, simpler builder experience.</p>
</aside>
);

View file

@ -4,17 +4,30 @@ export type RuntimeRecordStatus = 'draft' | 'published';
const KEY = 'nxtgauge_admin_runtime_builder_v1';
type RuntimeStore = {
role: Record<string, unknown>;
dashboard: Record<string, unknown>;
onboarding: Record<string, unknown>;
role: Record<string, RuntimeStoredRecord<unknown>>;
dashboard: Record<string, RuntimeStoredRecord<unknown>>;
onboarding: Record<string, RuntimeStoredRecord<unknown>>;
};
export type RuntimeStoredRecord<T> = {
status: RuntimeRecordStatus;
updatedAt: string;
payload: T;
};
export type RuntimeListItem<T> = RuntimeStoredRecord<T> & {
key: string;
};
function emptyStore(): RuntimeStore {
return { role: {}, dashboard: {}, onboarding: {} };
}
function readStore(): RuntimeStore {
if (typeof window === 'undefined') {
return { role: {}, dashboard: {}, onboarding: {} };
}
if (typeof window === 'undefined') return emptyStore();
const raw = window.localStorage.getItem(KEY);
if (!raw) return { role: {}, dashboard: {}, onboarding: {} };
if (!raw) return emptyStore();
try {
const parsed = JSON.parse(raw) as Partial<RuntimeStore>;
return {
@ -23,7 +36,7 @@ function readStore(): RuntimeStore {
onboarding: parsed.onboarding || {},
};
} catch {
return { role: {}, dashboard: {}, onboarding: {} };
return emptyStore();
}
}
@ -33,9 +46,11 @@ function writeStore(store: RuntimeStore) {
}
export function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) {
const normalizedKey = key.trim();
if (!normalizedKey) return;
const store = readStore();
const bucket = store[type];
bucket[key] = {
store[type][normalizedKey] = {
status,
updatedAt: new Date().toISOString(),
payload,
@ -43,3 +58,22 @@ export function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload:
writeStore(store);
}
export function listRuntimeConfigs<T>(type: RuntimeRecordType): Array<RuntimeListItem<T>> {
const store = readStore();
return Object.entries(store[type])
.map(([key, value]) => ({ key, ...(value as RuntimeStoredRecord<T>) }))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function getRuntimeConfig<T>(type: RuntimeRecordType, key: string): RuntimeStoredRecord<T> | null {
const record = readStore()[type][key];
return (record as RuntimeStoredRecord<T>) || null;
}
export function deleteRuntimeConfig(type: RuntimeRecordType, key: string): boolean {
const store = readStore();
if (!store[type][key]) return false;
delete store[type][key];
writeStore(store);
return true;
}

View file

@ -0,0 +1,81 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
export default function EditOnboardingPage() {
const params = useParams();
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
onMount(() => {
const existing = getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
setConfig(existing?.payload || null);
});
const persist = (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
};
return (
<AdminShell>
<h1 class="page-title">Edit Onboarding Flow</h1>
<p class="page-subtitle">Update this runtime onboarding schema without changing source code.</p>
{!config() ? (
<section class="card"><p class="notice">Onboarding schema not found in local runtime storage.</p></section>
) : (
<div class="grid">
<section class="card">
<h2>Onboarding Builder</h2>
<div class="field">
<label>Schema ID</label>
<input value={config()!.schemaId} onInput={(e) => setConfig({ ...config()!, schemaId: e.currentTarget.value })} />
</div>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
</div>
<div class="field">
<label>Version</label>
<input type="number" value={config()!.version} onInput={(e) => setConfig({ ...config()!, version: Number(e.currentTarget.value || 1) })} />
</div>
<div class="field">
<label>Steps (one title per line, quick mode)</label>
<textarea
rows={8}
value={config()!.steps.map((s) => s.title).join('\n')}
onInput={(e) =>
setConfig({
...config()!,
steps: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title, index) => ({
id: `step_${index + 1}`,
title,
fields: [{ id: `field_${index + 1}`, label: 'Sample Field', type: 'text', required: true }],
})),
})
}
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,48 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
export default function ManageOnboardingPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeOnboardingConfig>>>([]);
const load = () => setItems(listRuntimeConfigs<RuntimeOnboardingConfig>('onboarding'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('onboarding', key);
load();
};
return (
<AdminShell>
<h1 class="page-title">Manage Onboarding Flows</h1>
<p class="page-subtitle">Edit or remove runtime onboarding schemas saved from the builder.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Onboarding Schemas</h2>
<A class="btn primary" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
</div>
{items().length === 0 ? (
<p class="notice">No runtime onboarding schemas found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/onboarding-schemas/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,89 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function EditDashboardPage() {
const params = useParams();
const [config, setConfig] = createSignal<RuntimeDashboardConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
onMount(() => {
const existing = getRuntimeConfig<RuntimeDashboardConfig>('dashboard', params.roleKey);
setConfig(existing?.payload || null);
});
const persist = (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Dashboard config published in runtime storage.');
};
return (
<AdminShell>
<h1 class="page-title">Edit Dashboard</h1>
<p class="page-subtitle">Update dashboard runtime config without changing source code.</p>
{!config() ? (
<section class="card"><p class="notice">Dashboard config not found in local runtime storage.</p></section>
) : (
<div class="grid">
<section class="card">
<h2>Dashboard Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
</div>
<div class="field">
<label>Sidebar Items (label|route per line)</label>
<textarea
rows={8}
value={config()!.sidebar.map((x) => `${x.label}|${x.route}`).join('\n')}
onInput={(e) =>
setConfig({
...config()!,
sidebar: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [label, route] = line.split('|').map((part) => part.trim());
return { key: label.toLowerCase().replace(/\s+/g, '-'), label, route };
}),
})
}
/>
</div>
<div class="field">
<label>Widgets (title per line)</label>
<textarea
rows={6}
value={config()!.widgets.map((x) => x.title).join('\n')}
onInput={(e) =>
setConfig({
...config()!,
widgets: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title) => ({ key: title.toLowerCase().replace(/\s+/g, '-'), title, enabled: true })),
})
}
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,48 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function ManageDashboardsPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeDashboardConfig>>>([]);
const load = () => setItems(listRuntimeConfigs<RuntimeDashboardConfig>('dashboard'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('dashboard', key);
load();
};
return (
<AdminShell>
<h1 class="page-title">Manage Dashboards</h1>
<p class="page-subtitle">Edit or remove runtime dashboard configs saved from the builder.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Dashboards</h2>
<A class="btn primary" href="/admin/role-ui-configs/new">Create Dashboard</A>
</div>
{items().length === 0 ? (
<p class="notice">No runtime dashboard configs found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/role-ui-configs/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,81 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function EditRolePage() {
const params = useParams();
const [config, setConfig] = createSignal<RuntimeRoleConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
onMount(() => {
const existing = getRuntimeConfig<RuntimeRoleConfig>('role', params.roleKey);
setConfig(existing?.payload || null);
});
const persist = (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Role config published in runtime storage.');
};
return (
<AdminShell>
<h1 class="page-title">Edit Role</h1>
<p class="page-subtitle">Update this runtime role without changing source code.</p>
{!config() ? (
<section class="card"><p class="notice">Role not found in local runtime storage.</p></section>
) : (
<div class="grid">
<section class="card">
<h2>Role Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
</div>
<div class="field">
<label>Display Name</label>
<input value={config()!.displayName} onInput={(e) => setConfig({ ...config()!, displayName: e.currentTarget.value })} />
</div>
<div class="field">
<label>Vertical</label>
<select value={config()!.vertical} onInput={(e) => setConfig({ ...config()!, vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="marketplace">Marketplace</option>
<option value="jobs">Jobs</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema ID</label>
<input value={config()!.onboardingSchemaId} onInput={(e) => setConfig({ ...config()!, onboardingSchemaId: e.currentTarget.value })} />
</div>
<div class="field">
<label>Enabled Modules (comma separated)</label>
<input value={config()!.enabledModules.join(', ')} onInput={(e) => setConfig({ ...config()!, enabledModules: e.currentTarget.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
</div>
<div class="field">
<label>
<input
type="checkbox"
checked={config()!.requiresOnboardingApproval}
onInput={(e) => setConfig({ ...config()!, requiresOnboardingApproval: e.currentTarget.checked })}
/>
{' '}Requires onboarding approval
</label>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,48 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function ManageRolesPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeRoleConfig>>>([]);
const load = () => setItems(listRuntimeConfigs<RuntimeRoleConfig>('role'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('role', key);
load();
};
return (
<AdminShell>
<h1 class="page-title">Manage Roles</h1>
<p class="page-subtitle">Edit or remove runtime role configs saved from the builder.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Roles</h2>
<A class="btn primary" href="/admin/runtime-roles/new">Create Role</A>
</div>
{items().length === 0 ? (
<p class="notice">No runtime role configs found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
</section>
</AdminShell>
);
}