NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Continues UI Fixes part 2

8c672a8
Alex Nord <[email protected]> 3 months ago
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) {