From 9174d30d920755967a18c7993ea16149c159d44b Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 10 Sep 2026 13:51:41 +0200 Subject: [PATCH] Use the platform navigation instead of a hand-built sidebar The shell rolled its own sidebar, which puts two navigations side by side the moment the app is opened inside a dashboard that draws one of its own. from @clawnify/app is one definition that serves both: it paints the app's sidebar when the app stands alone, and hands the same list to the host when it is embedded. Queue and Drafts carry live counts, so stats now load at boot along with the rest. The `posts` list each view loads is filtered to that view, so it cannot be counted for this. The rail's own CSS reads token names this app does not define, so styles.css puts it back on ours. Everything about its geometry sits behind min-width: 768px: `aside.cn-nav` outranks the package's own `.cn-nav` and specificity resolves before source order, so an unguarded width would land on the horizontal strip drawn on a phone as well. --- README.md | 1 - package.json | 3 +- pnpm-lock.yaml | 64 +++++++++++++++++++---- src/client/app.tsx | 51 +++++++++++++++++-- src/client/components/sidebar.tsx | 85 ------------------------------- src/client/hooks/use-app.ts | 5 +- src/client/hooks/use-router.ts | 21 ++++---- src/client/styles.css | 75 ++++++++++++++++++++++++++- 8 files changed, 194 insertions(+), 111 deletions(-) delete mode 100644 src/client/components/sidebar.tsx diff --git a/README.md b/README.md index a1cc9c6..de7349f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ src/ use-app.ts -- State management + CRUD operations use-router.ts -- pushState URL router components/ - sidebar.tsx -- Navigation sidebar dashboard.tsx -- Stats cards + upcoming posts post-composer.tsx -- Multi-channel post editor with char limits calendar-view.tsx -- Month grid calendar diff --git a/package.json b/package.json index f70d199..16bf248 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "build": "vite build" }, "dependencies": { - "@clawnify/app": "^0.1.0", + "@clawnify/app": "^0.2.1", "@clawnify/db": "^0.4.1", "@hono/zod-openapi": "^0.18.0", + "@phosphor-icons/react": "^2.1.10", "@tailwindcss/vite": "^4.2.2", "hono": "^4.6.0", "lucide-preact": "^0.575.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71fa66d..4300fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,17 @@ importers: .: dependencies: '@clawnify/app': - specifier: ^0.1.0 - version: 0.1.0(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14) + specifier: ^0.2.1 + version: 0.2.1(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(hono@4.12.14)(react@19.2.8) '@clawnify/db': specifier: ^0.4.1 version: 0.4.1 '@hono/zod-openapi': specifier: ^0.18.0 version: 0.18.4(hono@4.12.14)(zod@3.25.76) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) @@ -152,17 +155,24 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@clawnify/app@0.1.0': - resolution: {integrity: sha512-U0m4EzjXlesnjDfx7rVF7sgb4JwnENlelJ2mQPwGoCxKYTTzNwhQcpjMdfq71RD6eNJ1/EGe6qEYIRWv/1xxLQ==} + '@clawnify/app@0.2.1': + resolution: {integrity: sha512-t6fx4rnbEAewU3fH/WGKfWhfhZgVbLp1JgnpCR0YSHaE6rYwxEmgHAYlN3q5gSjYutsWmXANFqro3wuyxWZlTA==} peerDependencies: '@hono/zod-openapi': '>=0.18 <1' + '@phosphor-icons/react': '>=2' hono: '*' + react: '>=18' + peerDependenciesMeta: + '@phosphor-icons/react': + optional: true + react: + optional: true '@clawnify/db@0.4.1': resolution: {integrity: sha512-Ck9RGqEElt6P/3S9quxvr2MIg8mZk1n9wFftH3PpGPFnpA6hbk0td67oaioeMRuDCn7vePcJCeyYEkbvNVYy/g==} - '@clawnify/routes@0.2.1': - resolution: {integrity: sha512-syrH1Sk4uxXVI9Yo76yLrccAEpcwAUDxvxL3/K+QnLHFetPU/RaterpwXNbzonA2gtY7xBmYfJ66mgEw280HaQ==} + '@clawnify/routes@0.2.2': + resolution: {integrity: sha512-+s7gjRyH8bmIUHQDHmmwGyUeEcV5m+IoWEYr2tLiWdVW7WKlFlgeKIuJ61vW9mc9k4TizL+B+Tg8qJ72zAbvOA==} peerDependencies: '@hono/zod-openapi': '>=0.18 <1' hono: '*' @@ -714,6 +724,13 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1418,6 +1435,15 @@ packages: preact@10.29.1: resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1430,6 +1456,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1762,12 +1791,15 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@clawnify/app@0.1.0(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14)': + '@clawnify/app@0.2.1(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(hono@4.12.14)(react@19.2.8)': dependencies: '@clawnify/db': 0.4.1 - '@clawnify/routes': 0.2.1(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14) + '@clawnify/routes': 0.2.2(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14) '@hono/zod-openapi': 0.18.4(hono@4.12.14)(zod@3.25.76) hono: 4.12.14 + optionalDependencies: + '@phosphor-icons/react': 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -1833,7 +1865,7 @@ snapshots: - sql.js - sqlite3 - '@clawnify/routes@0.2.1(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14)': + '@clawnify/routes@0.2.2(@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76))(hono@4.12.14)': dependencies: '@hono/zod-openapi': 0.18.4(hono@4.12.14)(zod@3.25.76) hono: 4.12.14 @@ -2158,6 +2190,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -2681,6 +2718,13 @@ snapshots: preact@10.29.1: {} + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + require-directory@2.1.1: {} rollup@4.60.1: @@ -2718,6 +2762,8 @@ snapshots: dependencies: tslib: 2.8.1 + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.7.4: {} diff --git a/src/client/app.tsx b/src/client/app.tsx index 141f0a6..021ded9 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,7 +1,8 @@ +import { useEffect } from "preact/hooks"; +import { AppNav, reportLocation, type AppNavItem } from "@clawnify/app/client"; import { AppContext } from "./context"; import { useAppState } from "./hooks/use-app"; import { useRouter } from "./hooks/use-router"; -import { Sidebar } from "./components/sidebar"; import { ErrorBanner } from "./components/error-banner"; import { Dashboard } from "./components/dashboard"; import { PostComposer } from "./components/post-composer"; @@ -11,9 +12,43 @@ import { DraftsView } from "./components/drafts-view"; import { ChannelList } from "./components/channel-list"; import { AnalyticsView } from "./components/analytics-view"; +// One definition of the navigation. paints it as this app's own +// sidebar when the app is opened directly, and hands the same list to the +// Clawnify dashboard's sidebar when it is embedded there — so the user always +// sees one nav, never two. Item ids match the router's view names, which is +// what keeps `active` a straight lookup. +const PLANNING: AppNavItem[] = [ + // The dashboard is home: not a row of its own, the app's name opens it. + { id: "dashboard", label: "Dashboard", href: "/", home: true }, + { id: "compose", label: "Compose", href: "/compose", icon: "sparkles", color: "violet" }, + { id: "calendar", label: "Calendar", href: "/calendar", icon: "calendar", color: "blue" }, + { id: "queue", label: "Queue", href: "/queue", icon: "clock", color: "amber" }, + { id: "drafts", label: "Drafts", href: "/drafts", icon: "file-text", color: "sky" }, + { id: "analytics", label: "Analytics", href: "/analytics", icon: "bar-chart-3", color: "pink" }, +]; +const SETTINGS: AppNavItem[] = [ + { id: "channels", label: "Channels", href: "/channels", icon: "hash", color: "green" }, +]; + export function App() { const appState = useAppState(); - const { view, editId, navigate } = useRouter(); + const { view, editId, path, navigate } = useRouter(); + + // Lets the dashboard restore this exact screen on reload. + useEffect(() => { + reportLocation(path); + }, [path]); + + // Counts are plain state: the badges move the moment the numbers do, with + // no endpoint and no polling behind them. Zero reads as no badge at all. + const counts: Record = { + queue: appState.stats?.scheduled ?? 0, + drafts: appState.stats?.drafts ?? 0, + }; + const groups = [ + { items: PLANNING.map((item) => (counts[item.id] ? { ...item, count: counts[item.id] } : item)) }, + { label: "Settings", items: SETTINGS }, + ]; const renderMain = () => { switch (view) { @@ -29,8 +64,16 @@ export function App() { return ( -
- + {/* The sidebar is a 275px column at ≥768px and a horizontal strip below + it, so it has to be the first child of a flex-col/md:flex-row shell. */} +
+ navigate(item.href ?? "/")} + />
{appState.loading ? (
diff --git a/src/client/components/sidebar.tsx b/src/client/components/sidebar.tsx deleted file mode 100644 index bf44d53..0000000 --- a/src/client/components/sidebar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useState } from "preact/hooks"; -import { LayoutDashboard, PenSquare, Calendar, ListOrdered, FileText, Radio, BarChart3, PanelLeft } from "lucide-preact"; -import type { View } from "../types"; - -const NAV: Array<{ view: View; path: string; label: string; icon: any }> = [ - { view: "dashboard", path: "/", label: "Dashboard", icon: LayoutDashboard }, - { view: "compose", path: "/compose", label: "Compose", icon: PenSquare }, - { view: "calendar", path: "/calendar", label: "Calendar", icon: Calendar }, - { view: "queue", path: "/queue", label: "Queue", icon: ListOrdered }, - { view: "drafts", path: "/drafts", label: "Drafts", icon: FileText }, - { view: "channels", path: "/channels", label: "Channels", icon: Radio }, - { view: "analytics", path: "/analytics", label: "Analytics", icon: BarChart3 }, -]; - -interface Props { - currentView: View; - navigate: (path: string) => void; -} - -// The shell's sidebar: a brand row the same height as the page toolbar (so -// their bottom rules meet as one line), 28px nav rows with a neutral active -// fill, and a collapse toggle that folds it to icons only. -export function Sidebar({ currentView, navigate }: Props) { - const [collapsed, setCollapsed] = useState(false); - - return ( - - ); -} diff --git a/src/client/hooks/use-app.ts b/src/client/hooks/use-app.ts index 61a2f46..617c7f9 100644 --- a/src/client/hooks/use-app.ts +++ b/src/client/hooks/use-app.ts @@ -48,8 +48,11 @@ export function useAppState() { } catch (e: any) { setError(e.message); } }, []); + // Stats ride along at boot because the sidebar's queue/drafts badges read + // them, and those have to be right on whichever screen the app opens at — + // `posts` is filtered per view, so it cannot be counted for this. useEffect(() => { - Promise.all([loadChannels(), loadLabels(), loadPosts()]).then(() => setLoading(false)); + Promise.all([loadChannels(), loadLabels(), loadPosts(), loadStats()]).then(() => setLoading(false)); }, []); // ── Channel CRUD ── diff --git a/src/client/hooks/use-router.ts b/src/client/hooks/use-router.ts index 23be24e..1efbe87 100644 --- a/src/client/hooks/use-router.ts +++ b/src/client/hooks/use-router.ts @@ -4,21 +4,24 @@ import type { View } from "../types"; interface RouterState { view: View; editId: number | null; + // The location itself, so the host bridge can report it verbatim. + path: string; } function parseLocation(): RouterState { const path = window.location.pathname; - if (path === "/calendar") return { view: "calendar", editId: null }; - if (path === "/queue") return { view: "queue", editId: null }; - if (path === "/drafts") return { view: "drafts", editId: null }; - if (path === "/channels") return { view: "channels", editId: null }; - if (path === "/analytics") return { view: "analytics", editId: null }; - if (path === "/compose") return { view: "compose", editId: null }; + const here = path + window.location.search; + if (path === "/calendar") return { view: "calendar", editId: null, path: here }; + if (path === "/queue") return { view: "queue", editId: null, path: here }; + if (path === "/drafts") return { view: "drafts", editId: null, path: here }; + if (path === "/channels") return { view: "channels", editId: null, path: here }; + if (path === "/analytics") return { view: "analytics", editId: null, path: here }; + if (path === "/compose") return { view: "compose", editId: null, path: here }; if (path.startsWith("/compose/")) { const id = Number(path.split("/")[2]); - return { view: "compose", editId: isNaN(id) ? null : id }; + return { view: "compose", editId: isNaN(id) ? null : id, path: here }; } - return { view: "dashboard", editId: null }; + return { view: "dashboard", editId: null, path: here }; } export function useRouter() { @@ -35,5 +38,5 @@ export function useRouter() { setState(parseLocation()); }, []); - return { view: state.view, editId: state.editId, navigate }; + return { view: state.view, editId: state.editId, path: state.path, navigate }; } diff --git a/src/client/styles.css b/src/client/styles.css index 5c93b09..54ca9df 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -163,7 +163,80 @@ body { .brand-mark { background: oklch(from var(--brand) 0.62 0.13 h); } } -.app-icon { @apply inline-flex items-center justify-center rounded-md bg-accent text-accent-foreground shrink-0; } +/* ── App sidebar (@clawnify/app) ──────────────────────────────────── */ +/* injects its own