public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
UI fixes to admin page
acd0517
apps/web/src/AppShell.tsx | 158 ++++++++---
apps/web/src/components/AdminConsole.tsx | 465 +++++++++++++++++--------------
apps/web/src/components/SpacesPage.tsx | 86 +++---
apps/web/src/docs-theme.css | 131 ++++++++-
4 files changed, 556 insertions(+), 284 deletions(-)
Diff
diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx
index fad9e26..befcddc 100644
--- a/apps/web/src/AppShell.tsx
+++ b/apps/web/src/AppShell.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type {
AiCitation,
+ BrandingSettings,
ImportJobSummary,
IntegrationSummary,
PageDetail,
@@ -28,12 +29,7 @@ import { SpacesPage } from "./components/SpacesPage";
import { api } from "./lib/api";
type BrandingResponse = {
- branding: {
- siteName: string;
- logoUrl?: string | null;
- brandColor: string;
- publicKnowledgeBaseEnabled: boolean;
- };
+ branding: BrandingSettings;
};
const LEDGER_FOOTER = "Powered by Ledger made by ANord.cc";
@@ -250,7 +246,6 @@ function DocsShell({
const navigate = useNavigate();
const [searchOpen, setSearchOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
- const [createOpen, setCreateOpen] = useState(false);
const currentPageSlug = location.pathname.startsWith("/page/")
? decodeURIComponent(location.pathname.split("/page/")[1] ?? "")
: undefined;
@@ -280,22 +275,6 @@ function DocsShell({
results={searchResults}
isLoading={searchLoading}
/>
- {createOpen ? (
- <>
- <div className="command-palette__overlay is-open" onClick={() => setCreateOpen(false)} />
- <div className="editor-modal" role="dialog" aria-modal="true" aria-label="Create document">
- <PageEditor
- spaces={spaces}
- variant="dialog"
- onCancel={() => setCreateOpen(false)}
- onCreated={(slug) => {
- setCreateOpen(false);
- navigate(`/page/${slug}`);
- }}
- />
- </div>
- </>
- ) : null}
{loadingNavigation ? (
<SidebarSkeleton />
@@ -343,7 +322,7 @@ function DocsShell({
{user ? (
<>
{canCreate ? (
- <button type="button" className="button-primary" onClick={() => setCreateOpen(true)}>
+ <button type="button" className="button-primary" onClick={() => navigate("/new")}>
New doc
</button>
) : null}
@@ -357,7 +336,18 @@ function DocsShell({
</header>
<main className="app-content">{children}</main>
- <footer className="app-footer">{LEDGER_FOOTER}</footer>
+ <footer className="app-footer">
+ <span>{LEDGER_FOOTER}</span>
+ {branding?.footerLinks?.length ? (
+ <nav className="footer-links" aria-label="Footer links">
+ {branding.footerLinks.map((link) => (
+ <a key={`${link.label}-${link.href}`} href={link.href} target="_blank" rel="noreferrer">
+ {link.label}
+ </a>
+ ))}
+ </nav>
+ ) : null}
+ </footer>
</div>
</div>
);
@@ -683,19 +673,19 @@ function PageView({
) : null}
</div>
- <div className="doc-actions">
- <button type="button" className="button-secondary" onClick={copyLink}>
- <Icon name="copy" className="icon icon-sm" />
- Copy link
- </button>
- {user && (user.role === "editor" || user.role === "admin" || user.role === "owner") ? (
- <Link to="/admin/general" className="button-secondary">
- <Icon name="external" className="icon icon-sm" />
- Open admin
- </Link>
- ) : null}
- </div>
- </header>
+ <div className="doc-actions">
+ <button type="button" className="button-secondary" onClick={copyLink}>
+ <Icon name="copy" className="icon icon-sm" />
+ Copy link
+ </button>
+ {user && (user.role === "editor" || user.role === "admin" || user.role === "owner") ? (
+ <Link to={`/page/${page.slug}/edit`} className="button-secondary">
+ <Icon name="external" className="icon icon-sm" />
+ Edit doc
+ </Link>
+ ) : null}
+ </div>
+ </header>
{page.excerpt ? <p className="doc-summary">{page.excerpt}</p> : null}
@@ -779,6 +769,93 @@ function PageView({
);
}
+function EditorPage({
+ spaces,
+ user
+}: {
+ spaces: Space[];
+ user: SessionUser | null;
+}) {
+ const { slug } = useParams();
+ const navigate = useNavigate();
+ const [page, setPage] = useState<PageDetail | null>(null);
+ const [loading, setLoading] = useState(Boolean(slug));
+ const [error, setError] = useState<string | null>(null);
+ const canEdit = Boolean(user && ["editor", "admin", "owner"].includes(user.role));
+
+ useEffect(() => {
+ if (!slug) {
+ setLoading(false);
+ setPage(null);
+ setError(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ api
+ .get<{ page: PageDetail }>(`/api/pages/slug/${slug}`)
+ .then((response) => setPage(response.page))
+ .catch((reason) => setError(reason instanceof Error ? reason.message : "Could not load this page for editing."))
+ .finally(() => setLoading(false));
+ }, [slug]);
+
+ if (!canEdit) {
+ return <AccessDeniedPage />;
+ }
+
+ if (loading) {
+ return (
+ <div className="stack-page">
+ <ContentSkeleton />
+ </div>
+ );
+ }
+
+ if (error && slug) {
+ return (
+ <div className="stack-page">
+ <section className="panel">
+ <EmptyState title="Could not open editor" description={error} action={<Link to="/spaces" className="button-secondary">Back to home</Link>} />
+ </section>
+ </div>
+ );
+ }
+
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow={slug ? "Document editor" : "New document"}
+ title={slug ? `Edit ${page?.title ?? "document"}` : "Create a new document"}
+ description={slug ? "Update Markdown, visibility, and publish state from the same drafting surface." : "Start with Markdown first, then publish when the doc is ready."}
+ />
+ <PageEditor
+ spaces={spaces}
+ mode={slug ? "edit" : "create"}
+ variant="page"
+ initialPage={
+ slug && page
+ ? {
+ id: page.id,
+ spaceId: page.spaceId,
+ title: page.title,
+ slug: page.slug,
+ bodyMarkdown: page.bodyMarkdown,
+ excerpt: page.excerpt ?? "",
+ visibility: page.visibility,
+ state: page.state,
+ tags: page.tags,
+ parentPageId: page.parentPageId
+ }
+ : null
+ }
+ onSaved={(nextSlug) => navigate(`/page/${nextSlug}`)}
+ onCancel={() => navigate(slug ? `/page/${slug}` : "/spaces")}
+ />
+ </div>
+ );
+}
+
function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
const navigate = useNavigate();
const [email, setEmail] = useState("");
@@ -854,7 +931,8 @@ function SetupPage({
siteName: form.siteName,
logoUrl: null,
brandColor: form.brandColor,
- publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
+ publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled,
+ footerLinks: []
});
navigate("/spaces");
@@ -1247,6 +1325,7 @@ export function App() {
<Routes>
<Route path="/" element={<Navigate to="/spaces" replace />} />
<Route path="/login" element={<LoginPage onLogin={setUser} />} />
+ <Route path="/new" element={<EditorPage spaces={spaces} user={user} />} />
<Route
path="/preferences"
element={
@@ -1278,6 +1357,7 @@ export function App() {
<Route path="/imports" element={<ImportsPage user={user} spaces={spaces} />} />
<Route path="/ask-ai" element={<AskAiPage />} />
<Route path="/space/:spaceKey" element={<SpacePage spaces={spaces} pagesBySpace={pagesBySpace} />} />
+ <Route path="/page/:slug/edit" element={<EditorPage spaces={spaces} user={user} />} />
<Route path="/page/:slug" element={<PageView spaces={spaces} pagesBySpace={pagesBySpace} user={user} />} />
<Route path="/dashboard" element={<Navigate to="/admin/general" replace />} />
<Route path="/admin" element={user ? (canAdmin ? <Navigate to="/admin/general" replace /> : <AccessDeniedPage />) : <LoginPage onLogin={setUser} />} />
diff --git a/apps/web/src/components/AdminConsole.tsx b/apps/web/src/components/AdminConsole.tsx
index e96247f..b754017 100644
--- a/apps/web/src/components/AdminConsole.tsx
+++ b/apps/web/src/components/AdminConsole.tsx
@@ -36,9 +36,8 @@ const adminNav = [
["permissions", "Permissions"],
["integrations", "Integrations"],
["webhooks", "Webhooks"],
- ["ai", "AI Settings"],
- ["mcp", "MCP"],
- ["import-history", "Import History"],
+ ["ai", "AI & MCP"],
+ ["import-history", "Import"],
["activity", "Activity"]
] as const;
@@ -56,9 +55,8 @@ const adminGroups = [
items: [
["integrations", "Integrations"],
["webhooks", "Webhooks"],
- ["ai", "AI Settings"],
- ["mcp", "MCP"],
- ["import-history", "Import History"],
+ ["ai", "AI & MCP"],
+ ["import-history", "Import"],
["activity", "Activity"]
]
}
@@ -74,14 +72,24 @@ const webhookEvents = [
"search.no_results"
] as const;
+const roleDetails: Record<string, string> = {
+ owner: "Full control over settings, members, content, and infrastructure features.",
+ admin: "Manage workspace configuration, members, imports, integrations, and webhook behavior.",
+ editor: "Create and update documents, drafts, and import content where enabled.",
+ viewer: "Read internal content that is available to authenticated members.",
+ public: "Read only public content without signing in."
+};
+
export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUser; spaces: Space[] }) {
const { section = "general" } = useParams();
- const currentSection = adminNav.some(([key]) => key === section) ? section : "general";
+ const normalizedSection = section === "mcp" ? "ai" : section;
+ const currentSection = adminNav.some(([key]) => key === normalizedSection) ? normalizedSection : "general";
const [brandingForm, setBrandingForm] = useState({
siteName: "Ledger",
logoUrl: "",
brandColor: "#245cff",
- publicKnowledgeBaseEnabled: true
+ publicKnowledgeBaseEnabled: true,
+ footerLinks: [] as Array<{ label: string; href: string }>
});
const [users, setUsers] = useState<AdminUser[]>([]);
const [groups, setGroups] = useState<AdminGroup[]>([]);
@@ -134,7 +142,7 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
api.get<{ groups: AdminGroup[] }>("/api/admin/groups"),
api.get<{ roles: Array<{ id: string; key: string; name: string }> }>("/api/roles"),
api.get<{
- branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean };
+ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean; footer_links: Array<{ label: string; href: string }> };
mcp: { endpoint: string; authMode: string };
}>("/api/settings/admin"),
api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
@@ -151,7 +159,8 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
siteName: settingsResponse.branding.site_name,
logoUrl: settingsResponse.branding.logo_url ?? "",
brandColor: settingsResponse.branding.brand_color,
- publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled
+ publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled,
+ footerLinks: settingsResponse.branding.footer_links ?? []
});
setMcpSettings(settingsResponse.mcp);
setIntegrations(integrationsResponse.integrations);
@@ -217,7 +226,8 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
siteName: brandingForm.siteName,
logoUrl: brandingForm.logoUrl || null,
brandColor: brandingForm.brandColor,
- publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
+ publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled,
+ footerLinks: brandingForm.footerLinks.filter((link) => link.label.trim() && link.href.trim())
});
setStatus("General settings saved.");
} catch (error) {
@@ -320,15 +330,13 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
case "permissions":
return ["Admin", "Permissions", "Review role capabilities and how restricted pages are enforced."];
case "integrations":
- return ["Admin", "Integrations", "Configure source systems and send people into the import workflow when the connector is ready."];
+ return ["Admin", "Integrations", "Configure source systems, check connector health, and jump into importing content."];
case "webhooks":
return ["Admin", "Webhooks", "Manage outbound event delivery, signing secrets, and recent delivery attempts."];
case "ai":
- return ["Admin", "AI Settings", "Configure AI providers and review how retrieval and citations behave."];
- case "mcp":
- return ["Admin", "MCP", "Inspect Ledger’s MCP surface and understand how auth and permissions apply."];
+ return ["Admin", "AI & MCP", "Configure AI providers, review answer behavior, and inspect the MCP surface exposed by Ledger."];
case "import-history":
- return ["Admin", "Import history", "Review import jobs, status, and outcomes."];
+ return ["Admin", "Import", "Launch imports and review recent import activity from one place."];
case "activity":
return ["Admin", "Activity", "Review recent audit activity across the workspace."];
default:
@@ -416,13 +424,67 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
</label>
</div>
- <div className="settings-row">
- <div className="settings-row__content">
- <strong>Footer</strong>
- <p>Fixed product footer shown on every page.</p>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Footer links</strong>
+ <p>Add support, status, or policy links beside the fixed Ledger footer line.</p>
+ </div>
+ <div className="settings-row__control settings-row__control-stack">
+ {brandingForm.footerLinks.length === 0 ? <p className="muted">No footer links configured yet.</p> : null}
+ {brandingForm.footerLinks.map((link, index) => (
+ <div key={`${link.label}-${index}`} className="settings-inline-grid">
+ <input
+ value={link.label}
+ placeholder="Label"
+ onChange={(event) =>
+ setBrandingForm((current) => ({
+ ...current,
+ footerLinks: current.footerLinks.map((item, itemIndex) =>
+ itemIndex === index ? { ...item, label: event.target.value } : item
+ )
+ }))
+ }
+ />
+ <input
+ value={link.href}
+ placeholder="https://example.com"
+ onChange={(event) =>
+ setBrandingForm((current) => ({
+ ...current,
+ footerLinks: current.footerLinks.map((item, itemIndex) =>
+ itemIndex === index ? { ...item, href: event.target.value } : item
+ )
+ }))
+ }
+ />
+ <button
+ type="button"
+ className="button-secondary"
+ onClick={() =>
+ setBrandingForm((current) => ({
+ ...current,
+ footerLinks: current.footerLinks.filter((_, itemIndex) => itemIndex !== index)
+ }))
+ }
+ >
+ Remove
+ </button>
+ </div>
+ ))}
+ <button
+ type="button"
+ className="button-secondary"
+ onClick={() =>
+ setBrandingForm((current) => ({
+ ...current,
+ footerLinks: [...current.footerLinks, { label: "", href: "" }]
+ }))
+ }
+ >
+ Add footer link
+ </button>
+ </div>
</div>
- <div className="settings-row__value">Powered by Ledger made by ANord.cc</div>
- </div>
</section>
<div className="settings-actions">
@@ -432,88 +494,76 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
) : null}
{currentSection === "members" ? (
- <div className="admin-grid">
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Users</p>
- <h3>Members</h3>
+ <>
+ <section className="settings-section">
+ <h2 className="settings-section__title">Members</h2>
+ <div className="settings-row settings-row--summary">
+ <div className="settings-row__content">
+ <strong>Workspace access</strong>
+ <p>Members are listed here with their current effective role so admins can quickly verify who can browse, edit, and manage the knowledge base.</p>
</div>
+ <div className="settings-row__value">{users.length} members</div>
</div>
- <div className="list-grid">
- {users.map((member) => (
- <div key={member.id} className="list-item">
+ {users.map((member) => (
+ <div key={member.id} className="settings-row">
+ <div className="settings-row__content">
<strong>{member.display_name}</strong>
- <span>{member.email}</span>
+ <p>{member.email}</p>
+ </div>
+ <div className="settings-row__value">
<span className="badge badge-internal">{member.role_key}</span>
</div>
- ))}
- </div>
- </section>
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Groups</p>
- <h3>Permission groups</h3>
</div>
- </div>
+ ))}
+ </section>
+ <section className="settings-section settings-section-subtle">
+ <h2 className="settings-section__title">Groups</h2>
{groups.length === 0 ? (
<EmptyState title="No groups yet" description="Create groups in the backend or seed data to organize restricted pages by team." />
) : (
- <div className="list-grid">
- {groups.map((group) => (
- <div key={group.id} className="list-item">
+ groups.map((group) => (
+ <div key={group.id} className="settings-row">
+ <div className="settings-row__content">
<strong>{group.name}</strong>
- <span>{group.description ?? "No description"}</span>
+ <p>{group.description ?? "No description"}</p>
</div>
- ))}
- </div>
+ </div>
+ ))
)}
</section>
- </div>
+ </>
) : null}
{currentSection === "permissions" ? (
- <div className="admin-grid">
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Roles</p>
- <h3>Access levels</h3>
- </div>
- </div>
- <div className="list-grid">
- {roles.map((role) => (
- <div key={role.id} className="list-item">
+ <>
+ <section className="settings-section">
+ <h2 className="settings-section__title">Roles</h2>
+ {roles.map((role) => (
+ <div key={role.id} className="settings-row">
+ <div className="settings-row__content">
<strong>{role.name}</strong>
- <span>{role.key}</span>
+ <p>{roleDetails[role.key] ?? role.key}</p>
</div>
- ))}
- </div>
- </section>
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Behavior</p>
- <h3>Permission model</h3>
- </div>
- </div>
- <div className="list-grid">
- <div className="list-item">
- <strong>Public pages</strong>
- <span>Readable without authentication when public KB is enabled.</span>
+ <div className="settings-row__value">{role.key}</div>
</div>
- <div className="list-item">
- <strong>Internal pages</strong>
- <span>Readable only by authenticated users with at least viewer access.</span>
- </div>
- <div className="list-item">
- <strong>Restricted pages</strong>
- <span>Require explicit role or group permissions. Search, AI, and MCP follow the same backend checks.</span>
+ ))}
+ </section>
+ <section className="settings-section settings-section-subtle">
+ <h2 className="settings-section__title">Permission behavior</h2>
+ {[
+ ["Public pages", "Readable without authentication when the public knowledge base is enabled."],
+ ["Internal pages", "Readable only by authenticated users with at least viewer access."],
+ ["Restricted pages", "Require explicit role or group permissions. Search, AI, and MCP follow the same backend checks."]
+ ].map(([label, detail]) => (
+ <div key={label} className="settings-row">
+ <div className="settings-row__content">
+ <strong>{label}</strong>
+ <p>{detail}</p>
+ </div>
</div>
- </div>
+ ))}
</section>
- </div>
+ </>
) : null}
{currentSection === "integrations" ? (
@@ -523,50 +573,77 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
const lastJob = importJobs.find((job) => job.provider === provider);
const form = integrationForms[provider];
const canRunImport = provider === "markdown_import" || integration?.status === "configured";
+ const integrationTitle = provider === "google_docs" ? "Google Docs" : provider === "github" ? "GitHub" : "Markdown Import";
+ const integrationDescription =
+ provider === "google_docs"
+ ? "Import Google Docs content into Ledger Markdown pages and preserve source metadata."
+ : provider === "github"
+ ? "Connect a GitHub repository and import Markdown from a chosen branch and path."
+ : "Upload Markdown files directly through Ledger without an external connector.";
return (
- <section key={provider} className="integration-card panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Integration</p>
- <h3>{provider === "google_docs" ? "Google Docs" : provider === "github" ? "GitHub" : "Markdown Import"}</h3>
+ <section key={provider} className="settings-section settings-section-subtle integration-section">
+ <div className="settings-row settings-row--summary">
+ <div className="settings-row__content">
+ <strong>{integrationTitle}</strong>
+ <p>{integrationDescription}</p>
+ </div>
+ <div className="settings-row__value">
+ <span className={`badge badge-${integration?.status === "configured" ? "public" : "restricted"}`}>
+ {integration?.statusMessage ?? "Not configured"}
+ </span>
+ </div>
+ </div>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Connection name</strong>
+ <p>Used in the admin UI to identify this source.</p>
+ </div>
+ <div className="settings-row__control">
+ <input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
</div>
- <span className={`badge badge-${integration?.status === "configured" ? "public" : "restricted"}`}>
- {integration?.statusMessage ?? "Not configured"}
- </span>
</div>
- <p className="muted">
- {provider === "google_docs"
- ? "Import Google Docs content into Ledger pages."
- : provider === "github"
- ? "Import Markdown files from a repository path."
- : "Upload Markdown files directly from the browser."}
- </p>
- <p className="muted">
- {lastJob ? `Last import: ${new Date(lastJob.updatedAt).toLocaleString()}` : "Last import: none yet"}
- </p>
- <label className="field">
- Name
- <input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
- </label>
{provider === "github" ? (
- <label className="field">
- GitHub token
- <input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
- </label>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>GitHub token</strong>
+ <p>Required for repository previews and imports.</p>
+ </div>
+ <div className="settings-row__control">
+ <input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
+ </div>
+ </div>
) : null}
{provider === "google_docs" ? (
- <label className="field">
- Google access token
- <input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
- </label>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Google access token</strong>
+ <p>Required for previewing and importing Google Docs.</p>
+ </div>
+ <div className="settings-row__control">
+ <input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
+ </div>
+ </div>
) : null}
- <label className="checkbox-row checkbox-card">
- <input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
- <span>Enabled</span>
- </label>
- <div className="panel__footer">
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Enabled</strong>
+ <p>Allow this integration to preview and import content.</p>
+ </div>
+ <label className="toggle-switch">
+ <input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
+ <span className="toggle-switch__track" />
+ </label>
+ </div>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Last import</strong>
+ <p>Latest import activity for this source.</p>
+ </div>
+ <div className="settings-row__value">{lastJob ? new Date(lastJob.updatedAt).toLocaleString() : "None yet"}</div>
+ </div>
+ <div className="panel__footer panel__footer-start">
<button onClick={() => saveIntegration(provider)}>Save configuration</button>
- {canRunImport ? <Link to="/imports" className="button-secondary">Open imports</Link> : <button type="button" className="button-secondary" disabled title="Configure this integration before importing.">Import unavailable</button>}
+ {canRunImport ? <Link to="/imports" className="button-secondary">Open import</Link> : <button type="button" className="button-secondary" disabled title="Configure this integration before importing.">Import unavailable</button>}
</div>
</section>
);
@@ -642,7 +719,7 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
<div className="feedback-list">
{webhooks.map((webhook) => (
<div key={webhook.id} className="feedback-item">
- <div className="feedback-item__header">
+ <div className="settings-row__content">
<strong>{webhook.name}</strong>
<span className={`badge badge-${webhook.isActive ? "public" : "restricted"}`}>{webhook.isActive ? "Active" : "Disabled"}</span>
</div>
@@ -674,108 +751,86 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
) : null}
{currentSection === "ai" ? (
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Provider configuration</p>
- <h3>AI settings</h3>
+ <>
+ <section className="settings-section">
+ <h2 className="settings-section__title">AI provider</h2>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Provider</strong>
+ <p>Select the provider type Ledger should use for answers.</p>
+ </div>
+ <div className="settings-row__control">
+ <select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
+ <option value="none">Disabled</option>
+ <option value="openai_compatible">OpenAI-compatible</option>
+ <option value="anthropic_compatible">Anthropic-compatible</option>
+ </select>
+ </div>
</div>
- </div>
- <div className="field-grid">
- <label className="field">
- Provider
- <select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
- <option value="none">Disabled</option>
- <option value="openai_compatible">OpenAI-compatible</option>
- <option value="anthropic_compatible">Anthropic-compatible</option>
- </select>
- </label>
- <label className="field">
- Model
- <input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
- </label>
- </div>
- <label className="field">
- API key
- <input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured. Enter a new key to rotate it." : "Paste provider key"} />
- </label>
- <label className="checkbox-row checkbox-card">
- <input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
- <span>Enable AI answers</span>
- </label>
- <div className="admin-grid">
- <div className="list-item">
- <strong>Retrieval settings</strong>
- <span>Ledger uses permission-filtered keyword retrieval over permitted pages. This is active whenever AI is enabled.</span>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Model</strong>
+ <p>Model identifier used by the configured provider.</p>
+ </div>
+ <div className="settings-row__control">
+ <input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
+ </div>
</div>
- <div className="list-item">
- <strong>Answer behavior</strong>
- <span>Answers should refuse when the KB lacks enough information instead of fabricating a response.</span>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>API key</strong>
+ <p>{aiSettings.hasApiKey ? "A provider key is already stored. Enter a new key to rotate it." : "Paste a provider key to enable server-side requests."}</p>
+ </div>
+ <div className="settings-row__control">
+ <input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured" : "Paste provider key"} />
+ </div>
</div>
- <div className="list-item">
- <strong>Citation behavior</strong>
- <span>Every answer links back to source pages the user can already access.</span>
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Enable AI answers</strong>
+ <p>Allow Ask AI to answer from permission-filtered Ledger content.</p>
+ </div>
+ <label className="toggle-switch">
+ <input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
+ <span className="toggle-switch__track" />
+ </label>
</div>
- </div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Retrieval settings</strong><p>Ledger searches only pages the current user can access before generating an answer.</p></div></div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Answer behavior</strong><p>If the knowledge base does not contain enough information, Ledger refuses instead of inventing an answer.</p></div></div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Citation behavior</strong><p>Answers cite the exact source pages the user is already allowed to open.</p></div></div>
+ </section>
{aiSettings.provider === "none" || !aiSettings.isEnabled ? (
<EmptyState title="AI is disabled" description="Ask AI will remain available as a page, but the primary action stays disabled until a provider is configured." />
) : null}
- <div className="panel__footer">
+ <section className="settings-section settings-section-subtle">
+ <h2 className="settings-section__title">MCP server</h2>
+ <div className="settings-row"><div className="settings-row__content"><strong>Hosted endpoint</strong><p>{displayedMcpEndpoint}</p></div><div className="settings-row__value">Live path</div></div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Tools</strong><p><code>search_knowledge_base</code>, <code>read_page</code>, <code>list_spaces</code>, <code>get_page_metadata</code>, <code>create_draft_page</code></p></div></div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Auth</strong><p>MCP currently uses {mcpSettings.authMode === "session_cookie" ? "the active Ledger session cookie" : mcpSettings.authMode}. Dedicated API tokens are not implemented yet.</p></div></div>
+ <div className="settings-row"><div className="settings-row__content"><strong>Permission behavior</strong><p>MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.</p></div></div>
+ </section>
+ <div className="settings-actions">
<button onClick={saveAi}>Save AI settings</button>
</div>
- </section>
- ) : null}
-
- {currentSection === "mcp" ? (
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Server status</p>
- <h3>MCP availability</h3>
- </div>
- </div>
- <div className="list-grid">
- <div className="list-item">
- <strong>Hosted endpoint</strong>
- <span>{displayedMcpEndpoint}</span>
- </div>
- <div className="list-item">
- <strong>Transport</strong>
- <span>Ledger exposes MCP on the same host it is served from, so deployed workspaces use their own domain for MCP access.</span>
- </div>
- <div className="list-item">
- <strong>Tools</strong>
- <span>`search_knowledge_base`, `read_page`, `list_spaces`, `get_page_metadata`, `create_draft_page`</span>
- </div>
- <div className="list-item">
- <strong>Auth</strong>
- <span>MCP currently uses {mcpSettings.authMode === "session_cookie" ? "the active Ledger session cookie" : mcpSettings.authMode}. Dedicated API tokens are not implemented, so the UI does not claim otherwise.</span>
- </div>
- <div className="list-item">
- <strong>Permission behavior</strong>
- <span>MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.</span>
- </div>
- <div className="list-item">
- <strong>Backend compatibility</strong>
- <span>The backend also accepts the same MCP router on its direct API service, so local development keeps working without a reverse proxy.</span>
- </div>
- </div>
- </section>
+ </>
) : null}
{currentSection === "import-history" ? (
- <section className="panel">
+ <section className="settings-section">
+ <div className="settings-actions">
+ <Link to="/imports" className="button-secondary">Open import flow</Link>
+ </div>
{importJobs.length === 0 ? (
<EmptyState title="No imports yet" description="Run an import from the Imports area to populate this history." />
) : (
- <div className="feedback-list">
+ <div className="settings-section settings-section-subtle">
{importJobs.map((job) => (
- <div key={job.id} className="feedback-item">
- <div className="feedback-item__header">
+ <div key={job.id} className="settings-row">
+ <div className="settings-row__content">
<strong>{job.sourceLabel}</strong>
- <span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
+ <p>{job.provider} • {job.importedCount} pages • {new Date(job.updatedAt).toLocaleString()}</p>
</div>
- <p>{job.provider} • {job.importedCount} pages</p>
+ <p>{job.provider} • {job.importedCount} pages</p>
{job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
</div>
))}
@@ -793,7 +848,7 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
{activity.map((item) => (
<div key={item.id} className="list-item">
<strong>{item.action}</strong>
- <span>{item.actor_name} • {new Date(item.created_at).toLocaleString()}</span>
+ <span>{item.actor_name} • {new Date(item.created_at).toLocaleString()}</span>
<span>{item.resource_type}:{item.resource_id}</span>
</div>
))}
diff --git a/apps/web/src/components/SpacesPage.tsx b/apps/web/src/components/SpacesPage.tsx
index 4721184..37fefa7 100644
--- a/apps/web/src/components/SpacesPage.tsx
+++ b/apps/web/src/components/SpacesPage.tsx
@@ -20,6 +20,16 @@ export function SpacesPage({
pagesBySpace: Record<string, PageSummary[]>;
}) {
const [tab, setTab] = useState<"updated" | "public" | "internal">("updated");
+ const collections = useMemo(
+ () =>
+ spaces
+ .map((space) => ({
+ ...space,
+ count: (pagesBySpace[space.key] ?? []).length
+ }))
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
+ [pagesBySpace, spaces]
+ );
const allPages = useMemo(
() =>
Object.values(pagesBySpace)
@@ -45,7 +55,7 @@ export function SpacesPage({
<PageHeader
eyebrow="Knowledge base"
title="Home"
- description="Browse the latest documentation across your workspace, then jump into collections from the left sidebar."
+ description="Open the latest docs, move between collections quickly, and keep the knowledge base feeling lightweight instead of buried in admin screens."
/>
{allPages.length === 0 ? (
@@ -69,44 +79,46 @@ export function SpacesPage({
</button>
</div>
- <section className="document-feed">
- {visiblePages.map((page) => {
- const space = spaces.find((entry) => entry.id === page.spaceId);
- return (
- <Link key={page.id} to={`/page/${page.slug}`} className="document-feed__item">
- <div className="document-feed__icon">
- <Icon name="document" className="icon icon-sm" />
- </div>
- <div className="document-feed__body">
- <strong className="document-feed__title">{page.title}</strong>
- <p className="document-feed__meta">
- Updated {new Date(page.updatedAt).toLocaleDateString()} in {space?.name ?? "Collection"} - {page.visibility}
- </p>
- </div>
- </Link>
- );
- })}
- </section>
+ <div className="home-content-grid">
+ <section className="document-feed">
+ {visiblePages.map((page) => {
+ const space = spaces.find((entry) => entry.id === page.spaceId);
+ return (
+ <Link key={page.id} to={`/page/${page.slug}`} className="document-feed__item">
+ <div className="document-feed__icon">
+ <Icon name="document" className="icon icon-sm" />
+ </div>
+ <div className="document-feed__body">
+ <strong className="document-feed__title">{page.title}</strong>
+ <p className="document-feed__meta">
+ Updated {new Date(page.updatedAt).toLocaleDateString()} in {space?.name ?? "Collection"} - {page.visibility}
+ </p>
+ </div>
+ </Link>
+ );
+ })}
+ </section>
- <section className="content-section home-collections">
- <div className="section-head">
- <div>
- <p className="eyebrow">Quick access</p>
- <h3>Collections</h3>
+ <aside className="home-side-column">
+ <div className="section-head section-head--compact">
+ <div>
+ <p className="eyebrow">Quick access</p>
+ <h3>Collections</h3>
+ </div>
</div>
- </div>
- <div className="collection-list">
- {spaces.map((space) => (
- <Link key={space.id} to={`/space/${space.key}`} className="collection-list__item">
- <div className="collection-list__body">
- <strong>{space.name}</strong>
- <p>{(pagesBySpace[space.key] ?? []).length} documents</p>
- </div>
- <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
- </Link>
- ))}
- </div>
- </section>
+ <div className="collection-list">
+ {collections.map((space) => (
+ <Link key={space.id} to={`/space/${space.key}`} className="collection-list__item">
+ <div className="collection-list__body">
+ <strong>{space.name}</strong>
+ <p>{space.count} documents</p>
+ </div>
+ <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
+ </Link>
+ ))}
+ </div>
+ </aside>
+ </div>
</>
)}
</div>
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index 7c8f5b4..5c00731 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -238,7 +238,7 @@ a:focus {
border-color: var(--line-strong);
}
-button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher):not(.home-tab) {
+button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher):not(.home-tab):not(.collection-group__header):not(.integration-card) {
border-radius: var(--radius-sm);
padding: 0.78rem 1rem;
background: var(--brand);
@@ -414,6 +414,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
min-height: 2.3rem;
padding: 0.38rem 0.55rem;
background: transparent;
+ color: var(--text-muted);
border-top: 1px solid var(--line);
border-radius: var(--radius-sm);
}
@@ -426,6 +427,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.collection-group__header.is-current {
color: var(--text);
+ background: transparent;
+ border-color: var(--line-strong);
}
.collection-group__body {
@@ -449,7 +452,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
}
.tree-item.is-active .tree-link {
- background: var(--bg-active);
+ background: color-mix(in srgb, var(--bg-active) 55%, transparent);
color: var(--text);
box-shadow: inset 2px 0 0 0 var(--brand);
}
@@ -659,6 +662,11 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
font-size: 0.8rem;
border-top: 1px solid var(--line);
letter-spacing: 0.02em;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
}
.overview-layout,
@@ -750,6 +758,17 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
padding-top: var(--space-5);
}
+.home-content-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
+ gap: 2.4rem;
+ align-items: start;
+}
+
+.home-side-column {
+ align-self: start;
+}
+
.collection-list {
display: grid;
}
@@ -767,6 +786,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.collection-list__item:hover {
color: var(--text);
border-bottom-color: var(--line-strong);
+ background: var(--bg-hover);
}
.collection-list__body strong {
@@ -1276,6 +1296,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
margin-bottom: var(--space-2);
}
+.section-head--compact {
+ margin-bottom: 0;
+}
+
.empty-state {
padding: var(--space-6) 0;
max-width: 34rem;
@@ -1368,6 +1392,11 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
+.admin-content .integration-list {
+ grid-template-columns: 1fr;
+ max-width: 860px;
+}
+
.field-note p {
margin: 0;
}
@@ -1476,6 +1505,21 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
text-transform: capitalize;
}
+.footer-links {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.9rem;
+ flex-wrap: wrap;
+}
+
+.footer-links a {
+ color: var(--text-muted);
+}
+
+.footer-links a:hover {
+ color: var(--text);
+}
+
.admin-backlink {
display: inline-flex;
align-items: center;
@@ -1574,6 +1618,38 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
margin-left: auto;
}
+.settings-row__control-stack {
+ display: grid;
+ gap: 0.6rem;
+ width: 100%;
+}
+
+.settings-inline-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 140px) minmax(0, 1fr) auto;
+ gap: 0.6rem;
+ width: 100%;
+}
+
+.settings-row__value {
+ color: var(--text-muted);
+ font-size: var(--text-sm);
+}
+
+.settings-section-subtle {
+ background: transparent;
+ border: 1px solid var(--line);
+ padding: 1rem 1.1rem;
+}
+
+.integration-section {
+ padding: 1rem 1.1rem;
+}
+
+.panel__footer-start {
+ justify-content: flex-start;
+}
+
.settings-row__value {
color: var(--text-muted);
text-align: left;
@@ -1704,6 +1780,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.integration-card--selectable {
text-align: left;
cursor: pointer;
+ border-radius: var(--radius-sm);
}
.integration-card--selectable.is-selected {
@@ -1711,6 +1788,51 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.24);
}
+.settings-row--summary {
+ padding-top: 0;
+}
+
+.composer-shell {
+ display: grid;
+ gap: 1.2rem;
+}
+
+.composer-header {
+ align-items: center;
+}
+
+.composer-header__actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+.composer-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.9fr);
+ gap: 1.4rem;
+ align-items: start;
+}
+
+.composer-main,
+.composer-sidebar {
+ display: grid;
+ gap: 1rem;
+}
+
+.composer-sidebar {
+ padding: 1rem 1.1rem;
+}
+
+.composer-toolbar {
+ display: grid;
+ gap: 0.7rem;
+}
+
+.editor-textarea-page {
+ min-height: 62vh;
+}
+
.mobile-only {
display: none;
}
@@ -1783,7 +1905,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.overview-hero,
.field-grid,
.collection-cards,
- .analytics-grid {
+ .analytics-grid,
+ .home-content-grid,
+ .composer-layout,
+ .settings-inline-grid {
grid-template-columns: 1fr;
}