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
Continues UI Fixes part 2
8c672a8
apps/web/src/components/AdminConsole.tsx | 124 +++++++++++++------
apps/web/src/components/DocsSidebar.tsx | 72 ++++++-----
apps/web/src/components/SpacesPage.tsx | 79 +++++++++---
apps/web/src/docs-theme.css | 202 +++++++++++++++++++++++++++++--
4 files changed, 381 insertions(+), 96 deletions(-)
Diff
diff --git a/apps/web/src/components/AdminConsole.tsx b/apps/web/src/components/AdminConsole.tsx
index abd545c..c34f309 100644
--- a/apps/web/src/components/AdminConsole.tsx
+++ b/apps/web/src/components/AdminConsole.tsx
@@ -9,7 +9,6 @@ import type {
} from "@ledger/shared";
import { api } from "../lib/api";
import { EmptyState } from "./EmptyState";
-import { PageEditor } from "./PageEditor";
import { PageHeader } from "./PageHeader";
type Space = {
@@ -42,6 +41,28 @@ const adminNav = [
["activity", "Activity"]
] as const;
+const adminGroups = [
+ {
+ label: "Workspace",
+ items: [
+ ["general", "General"],
+ ["members", "Members"],
+ ["permissions", "Permissions"]
+ ]
+ },
+ {
+ label: "Platform",
+ items: [
+ ["integrations", "Integrations"],
+ ["webhooks", "Webhooks"],
+ ["ai", "AI Settings"],
+ ["mcp", "MCP"],
+ ["import-history", "Import History"],
+ ["activity", "Activity"]
+ ]
+ }
+] as const;
+
const webhookEvents = [
"page.created",
"page.updated",
@@ -52,7 +73,7 @@ const webhookEvents = [
"search.no_results"
] as const;
-export function AdminConsole({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
+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 [brandingForm, setBrandingForm] = useState({
@@ -317,13 +338,18 @@ export function AdminConsole({ user, spaces }: { user: SessionUser; spaces: Spac
<h3>Workspace controls</h3>
</div>
</div>
- <nav className="admin-nav">
- {adminNav.map(([key, label]) => (
- <Link key={key} to={`/admin/${key}`} className={`admin-nav__item${currentSection === key ? " is-current" : ""}`}>
- {label}
- </Link>
- ))}
- </nav>
+ {adminGroups.map((group) => (
+ <div key={group.label} className="admin-nav-group">
+ <p className="admin-nav-group__label">{group.label}</p>
+ <nav className="admin-nav">
+ {group.items.map(([key, label]) => (
+ <Link key={key} to={`/admin/${key}`} className={`admin-nav__item${currentSection === key ? " is-current" : ""}`}>
+ {label}
+ </Link>
+ ))}
+ </nav>
+ </div>
+ ))}
</aside>
<div className="admin-content stack-page">
@@ -332,40 +358,66 @@ export function AdminConsole({ user, spaces }: { user: SessionUser; spaces: Spac
{currentSection === "general" ? (
<>
- <section className="panel">
- <div className="panel__header">
- <div>
- <p className="eyebrow">Branding</p>
- <h3>Workspace identity</h3>
+ <section className="settings-section">
+ <h2 className="settings-section__title">Workspace</h2>
+
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Site name</strong>
+ <p>Name shown across the workspace and navigation.</p>
+ </div>
+ <div className="settings-row__control">
+ <input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
</div>
</div>
- <label className="field">
- Site name
- <input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
- </label>
- <div className="field-grid">
- <label className="field">
- Brand color
- <input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
- </label>
- <label className="field">
- Logo URL
+
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Logo URL</strong>
+ <p>Optional logo used in the workspace header.</p>
+ </div>
+ <div className="settings-row__control">
<input value={brandingForm.logoUrl} onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
- </label>
+ </div>
</div>
- <div className="list-item">
- <strong>Footer</strong>
- <span>Powered by Ledger made by ANord.cc</span>
+
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Brand color</strong>
+ <p>Primary accent used for actions and highlights.</p>
+ </div>
+ <div className="settings-row__control">
+ <input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
+ </div>
</div>
- <label className="checkbox-row checkbox-card">
- <input type="checkbox" checked={brandingForm.publicKnowledgeBaseEnabled} onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))} />
- <span>Public knowledge base enabled</span>
- </label>
- <div className="panel__footer">
- <button onClick={saveBranding}>Save general settings</button>
+
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Public knowledge base</strong>
+ <p>Allow public visitors to read public pages without signing in.</p>
+ </div>
+ <label className="toggle-switch">
+ <input
+ type="checkbox"
+ checked={brandingForm.publicKnowledgeBaseEnabled}
+ onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))}
+ />
+ <span className="toggle-switch__track" />
+ </label>
+ </div>
+
+ <div className="settings-row">
+ <div className="settings-row__content">
+ <strong>Footer</strong>
+ <p>Fixed product footer shown on every page.</p>
+ </div>
+ <div className="settings-row__value">Powered by Ledger made by ANord.cc</div>
</div>
</section>
- <PageEditor spaces={spaces} />
+
+ <div className="settings-actions">
+ <button onClick={saveBranding}>Save general settings</button>
+ </div>
</>
) : null}
diff --git a/apps/web/src/components/DocsSidebar.tsx b/apps/web/src/components/DocsSidebar.tsx
index e1054de..d0f0aae 100644
--- a/apps/web/src/components/DocsSidebar.tsx
+++ b/apps/web/src/components/DocsSidebar.tsx
@@ -135,20 +135,20 @@ export function DocsSidebar({
<nav className="sidebar-nav" aria-label="Primary">
<Link
- className={`sidebar-nav__item${location.pathname === "/search" ? " is-current" : ""}`}
- to="/search"
+ className={`sidebar-nav__item${location.pathname === "/spaces" || location.pathname === "/" || location.pathname.startsWith("/space/") ? " is-current" : ""}`}
+ to="/spaces"
onClick={() => closeSidebarOnMobile(onClose)}
>
- <Icon name="search" className="icon icon-sm" />
- <span>Search</span>
+ <Icon name="home" className="icon icon-sm" />
+ <span>Home</span>
</Link>
<Link
- className={`sidebar-nav__item${location.pathname === "/spaces" || location.pathname.startsWith("/space/") ? " is-current" : ""}`}
- to="/spaces"
+ className={`sidebar-nav__item${location.pathname === "/search" ? " is-current" : ""}`}
+ to="/search"
onClick={() => closeSidebarOnMobile(onClose)}
>
- <Icon name="collection" className="icon icon-sm" />
- <span>Spaces</span>
+ <Icon name="search" className="icon icon-sm" />
+ <span>Search</span>
</Link>
<Link
className={`sidebar-nav__item${location.pathname === "/drafts" ? " is-current" : ""}`}
@@ -158,32 +158,40 @@ export function DocsSidebar({
<Icon name="document" className="icon icon-sm" />
<span>Drafts</span>
</Link>
- <Link
- className={`sidebar-nav__item${location.pathname === "/imports" ? " is-current" : ""}`}
- to="/imports"
- onClick={() => closeSidebarOnMobile(onClose)}
- >
- <Icon name="plus" className="icon icon-sm" />
- <span>Imports</span>
- </Link>
- <Link
- className={`sidebar-nav__item${location.pathname === "/ask-ai" ? " is-current" : ""}`}
- to="/ask-ai"
- onClick={() => closeSidebarOnMobile(onClose)}
- >
- <Icon name="spark" className="icon icon-sm" />
- <span>Ask AI</span>
- </Link>
- <Link
- className={`sidebar-nav__item${location.pathname.startsWith("/admin") ? " is-current" : ""}`}
- to="/admin/general"
- onClick={() => closeSidebarOnMobile(onClose)}
- >
- <Icon name="settings" className="icon icon-sm" />
- <span>Admin</span>
- </Link>
</nav>
+ <section className="sidebar-section">
+ <div className="sidebar-section__header">
+ <span>Workspace</span>
+ </div>
+ <div className="sidebar-list">
+ <Link
+ className={`sidebar-doc${location.pathname === "/imports" ? " is-current" : ""}`}
+ to="/imports"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="plus" className="icon icon-sm" />
+ <span>Imports</span>
+ </Link>
+ <Link
+ className={`sidebar-doc${location.pathname === "/ask-ai" ? " is-current" : ""}`}
+ to="/ask-ai"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="spark" className="icon icon-sm" />
+ <span>Ask AI</span>
+ </Link>
+ <Link
+ className={`sidebar-doc${location.pathname.startsWith("/admin") ? " is-current" : ""}`}
+ to="/admin/general"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="settings" className="icon icon-sm" />
+ <span>Admin</span>
+ </Link>
+ </div>
+ </section>
+
<section className="sidebar-section">
<div className="sidebar-section__header">
<span>Recent</span>
diff --git a/apps/web/src/components/SpacesPage.tsx b/apps/web/src/components/SpacesPage.tsx
index 1746f26..6a5f9ee 100644
--- a/apps/web/src/components/SpacesPage.tsx
+++ b/apps/web/src/components/SpacesPage.tsx
@@ -1,3 +1,4 @@
+import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { PageSummary } from "@ledger/shared";
import { EmptyState } from "./EmptyState";
@@ -17,34 +18,72 @@ export function SpacesPage({
spaces: Space[];
pagesBySpace: Record<string, PageSummary[]>;
}) {
+ const [tab, setTab] = useState<"updated" | "public" | "internal">("updated");
+ const allPages = useMemo(
+ () =>
+ Object.values(pagesBySpace)
+ .flat()
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
+ [pagesBySpace]
+ );
+
+ const visiblePages = allPages.filter((page) => {
+ if (tab === "public") {
+ return page.visibility === "public";
+ }
+
+ if (tab === "internal") {
+ return page.visibility !== "public";
+ }
+
+ return true;
+ });
+
return (
- <div className="stack-page">
+ <div className="home-page">
<PageHeader
- eyebrow="Spaces"
- title="Browse knowledge by space"
- description="Each space organizes a collection of public or internal documentation. Use spaces to separate teams, products, or domains."
+ eyebrow="Knowledge base"
+ title="Home"
+ description="Browse the latest documentation across your workspace, then jump into collections from the left sidebar."
/>
- {spaces.length === 0 ? (
- <section className="panel">
+ {allPages.length === 0 ? (
+ <section className="panel panel-muted">
<EmptyState
- title="No spaces available"
- description="Create a space from the admin area before publishing documents."
+ title="No documents published yet"
+ description="Create your first pages and they will appear here as the workspace home feed."
/>
</section>
) : (
- <section className="collection-cards">
- {spaces.map((space) => (
- <Link key={space.id} to={`/space/${space.key}`} className="collection-card">
- <div className="collection-card__icon" />
- <div>
- <strong>{space.name}</strong>
- <p>{space.visibility} visibility</p>
- </div>
- <span>{(pagesBySpace[space.key] ?? []).length} docs</span>
- </Link>
- ))}
- </section>
+ <>
+ <div className="home-tabs" role="tablist" aria-label="Home filters">
+ <button type="button" className={`home-tab${tab === "updated" ? " is-active" : ""}`} onClick={() => setTab("updated")}>
+ Recently updated
+ </button>
+ <button type="button" className={`home-tab${tab === "public" ? " is-active" : ""}`} onClick={() => setTab("public")}>
+ Public docs
+ </button>
+ <button type="button" className={`home-tab${tab === "internal" ? " is-active" : ""}`} onClick={() => setTab("internal")}>
+ Internal docs
+ </button>
+ </div>
+
+ <section className="home-stream">
+ {visiblePages.map((page) => {
+ const space = spaces.find((entry) => entry.id === page.spaceId);
+ return (
+ <Link key={page.id} to={`/page/${page.slug}`} className="home-stream__item">
+ <div className="home-stream__title">
+ <strong>{page.title}</strong>
+ </div>
+ <p className="home-stream__meta">
+ Updated {new Date(page.updatedAt).toLocaleDateString()} in {space?.name ?? "Collection"} - {page.visibility}
+ </p>
+ </Link>
+ );
+ })}
+ </section>
+ </>
)}
</div>
);
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index 26c6180..64e0217 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -127,7 +127,7 @@ a:focus {
background: transparent;
}
-button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher) {
+button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher):not(.home-tab) {
border-radius: var(--radius-sm);
padding: 0.78rem 1rem;
background: var(--brand);
@@ -214,6 +214,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.92rem;
+ transition: background 140ms ease, color 140ms ease;
}
.sidebar-nav__item.is-current,
@@ -239,6 +240,11 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
letter-spacing: 0.08em;
}
+.sidebar-list .muted {
+ margin: 0;
+ font-size: 0.88rem;
+}
+
.collection-group {
background: transparent;
border: 0;
@@ -386,6 +392,12 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
border: 1px solid var(--line);
background: #0f1318;
color: var(--text-muted);
+ transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
+}
+
+.search-launcher:hover {
+ border-color: var(--line-strong);
+ color: var(--text);
}
.search-launcher kbd {
@@ -414,6 +426,62 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
gap: 1.2rem;
}
+.home-page {
+ display: grid;
+ gap: 1rem;
+ max-width: 840px;
+ margin: 0 auto;
+}
+
+.home-tabs {
+ display: flex;
+ align-items: center;
+ gap: 1.35rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.home-tab {
+ padding: 0 0 0.72rem;
+ background: transparent;
+ color: var(--text-soft);
+ border-bottom: 2px solid transparent;
+ font-weight: 500;
+ border-radius: 0;
+}
+
+.home-tab.is-active {
+ color: var(--text);
+ border-bottom-color: #8aa0c4;
+}
+
+.home-stream {
+ display: grid;
+}
+
+.home-stream__item {
+ display: grid;
+ gap: 0.2rem;
+ padding: 0.95rem 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.home-stream__title {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 1rem;
+}
+
+.home-stream__meta {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.92rem;
+}
+
+.panel-muted {
+ background: transparent;
+}
+
.overview-hero,
.panel,
.doc-card,
@@ -954,6 +1022,18 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
border-bottom: 1px solid var(--line);
}
+.page-header h1 {
+ margin: 0;
+ font-size: 3rem;
+ line-height: 1.06;
+ letter-spacing: -0.03em;
+}
+
+.page-header .lede {
+ max-width: 640px;
+ margin: 0.85rem 0 0;
+}
+
.page-header__actions {
display: flex;
gap: 0.75rem;
@@ -962,29 +1042,42 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.admin-shell {
display: grid;
- grid-template-columns: 260px minmax(0, 1fr);
- gap: 1rem;
+ grid-template-columns: 252px minmax(0, 1fr);
+ gap: 2.5rem;
}
.admin-sidebar {
position: sticky;
top: 1rem;
align-self: start;
- background: var(--bg-sidebar);
+ background: transparent;
padding: 0.9rem 0.75rem;
+ border: 0;
}
.admin-backlink {
display: inline-flex;
align-items: center;
- padding: 0.4rem 0;
- color: var(--text-muted);
- margin-bottom: 0.9rem;
+ padding: 0.4rem 0 1.2rem;
+ color: var(--text);
+ margin-bottom: 0.75rem;
+ font-weight: 600;
}
.admin-nav {
display: grid;
- gap: 0.4rem;
+ gap: 0.18rem;
+}
+
+.admin-nav-group {
+ margin-bottom: 1rem;
+}
+
+.admin-nav-group__label {
+ margin: 0 0 0.45rem;
+ color: #6f89b2;
+ font-size: 0.82rem;
+ font-weight: 700;
}
.admin-nav__item {
@@ -1010,6 +1103,94 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
gap: 1rem;
}
+.settings-section {
+ display: grid;
+ gap: 0;
+ max-width: 840px;
+}
+
+.settings-section__title {
+ margin: 1.2rem 0 0.75rem;
+ font-size: 1.2rem;
+}
+
+.settings-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(220px, 260px);
+ gap: 2rem;
+ align-items: center;
+ padding: 1rem 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.settings-row__content strong,
+.settings-row__value {
+ display: block;
+ font-size: 0.98rem;
+}
+
+.settings-row__content p {
+ margin: 0.35rem 0 0;
+ color: var(--text-muted);
+ line-height: 1.55;
+}
+
+.settings-row__control {
+ width: 100%;
+}
+
+.settings-row__value {
+ color: var(--text-muted);
+ text-align: left;
+}
+
+.settings-actions {
+ max-width: 840px;
+ padding-top: 1rem;
+}
+
+.toggle-switch {
+ position: relative;
+ display: inline-flex;
+ justify-content: flex-end;
+}
+
+.toggle-switch input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.toggle-switch__track {
+ width: 34px;
+ height: 20px;
+ display: inline-block;
+ border-radius: 999px;
+ background: #6f7c90;
+ position: relative;
+ transition: background 140ms ease;
+}
+
+.toggle-switch__track::after {
+ content: "";
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ background: white;
+ transition: transform 140ms ease;
+}
+
+.toggle-switch input:checked + .toggle-switch__track {
+ background: var(--brand);
+}
+
+.toggle-switch input:checked + .toggle-switch__track::after {
+ transform: translateX(14px);
+}
+
.admin-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1056,6 +1237,11 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.right-rail {
position: static;
}
+
+ .settings-row {
+ grid-template-columns: 1fr;
+ gap: 0.8rem;
+ }
}
@media (max-width: 920px) {