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