@graph8/sdk 0.358.0 → 0.362.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/blocks/_foundation/README.md +108 -0
- package/blocks/_foundation/block.json +160 -12
- package/blocks/_foundation/components/api-error.tsx +62 -18
- package/blocks/_foundation/components/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +21 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +39 -2
- package/blocks/appointments/components/event-type-related.tsx +75 -0
- package/blocks/appointments/components/meeting-list.tsx +8 -4
- package/blocks/appointments/components/meetings-screen.tsx +154 -0
- package/blocks/appointments/components/scheduling-screen.tsx +121 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.types.ts +9 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +39 -4
- package/blocks/deals/components/deal-detail.tsx +4 -2
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +293 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +9 -1
- package/blocks/forms/components/forms-screen.tsx +350 -0
- package/blocks/forms/components/submissions-table.tsx +13 -5
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +23 -1
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +9 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +9 -1
- package/blocks/nurture/components/nurture-screen.tsx +279 -0
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +9 -1
- package/blocks/pages/components/content-library.tsx +37 -7
- package/blocks/pages/components/page-list.tsx +4 -2
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +26 -2
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +120 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/dist/cli.js +197 -37
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +93 -7
- package/dist/index.d.ts +93 -7
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +90 -4
- package/dist/react.d.ts +90 -4
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/create.d.ts +15 -1
- package/dist/types/blocks/doctor.d.ts +2 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/registry.d.ts +15 -0
- package/dist/types/core.d.ts +3 -2
- package/dist/types/react.d.ts +2 -0
- package/dist/types/studio.d.ts +48 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/adCampaigns.d.ts +4 -0
- package/docs/api/adCampaigns.md +4 -0
- package/docs/api/gtmCampaigns.d.ts +4 -0
- package/docs/api/gtmCampaigns.md +2 -0
- package/docs/api/gtmContext.d.ts +5 -1
- package/docs/api/gtmContext.md +15 -7
- package/docs/api/integrations.d.ts +29 -4
- package/docs/api/integrations.md +14 -0
- package/docs/sdk-clients.md +1 -1
- package/package.json +1 -1
- package/stacks/stack.schema.json +6 -1
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +16 -0
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +11 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
|
@@ -2,33 +2,248 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { usePathname } from "next/navigation";
|
|
5
|
-
import type
|
|
5
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
6
|
+
import { ChevronsLeft, ChevronsRight, Menu, Search } from "lucide-react";
|
|
7
|
+
import { Kbd, Sheet, SheetContent, SheetDescription, SheetTitle, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@graph8/sdk/ui";
|
|
6
8
|
import { nav } from "@/lib/nav";
|
|
9
|
+
import { ConnectionStatus } from "./shell/connection-status";
|
|
10
|
+
import { currentNavEntry, groupNav, isActivePath, isEditableTarget, type NavEntry } from "./shell/nav-model";
|
|
11
|
+
import { QuickJump } from "./shell/quick-jump";
|
|
12
|
+
import { ThemeToggle } from "./shell/theme-toggle";
|
|
7
13
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The app frame: a grouped sidebar (icons, active state, collapsible, remembered), a workspace header
|
|
16
|
+
* (title, current screen, quick jump, connection status, theme), a mobile drawer, and the content.
|
|
17
|
+
* Edit freely: add your logo, a user menu (`userMenu`), more header tools (`headerSlot`).
|
|
18
|
+
*
|
|
19
|
+
* Keyboard: Cmd/Ctrl+K jumps to a screen, Cmd/Ctrl+B collapses the sidebar.
|
|
20
|
+
*/
|
|
21
|
+
export interface AppShellProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
/** Right side of the header, before the theme toggle (a user menu, a help link). */
|
|
24
|
+
headerSlot?: ReactNode;
|
|
25
|
+
/** Replaces the connection status pill (your own session UI). `null` hides it. */
|
|
26
|
+
status?: ReactNode | null;
|
|
27
|
+
/** Bottom of the sidebar (workspace switcher, version). */
|
|
28
|
+
sidebarFooter?: ReactNode;
|
|
29
|
+
/** Pad the content area. Off by default: screens built with ScreenLayout bring their own padding. */
|
|
30
|
+
contentPadding?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const items: readonly NavEntry[] = nav;
|
|
34
|
+
const COLLAPSED_KEY = "g8.sidebar.collapsed";
|
|
35
|
+
|
|
36
|
+
function isMod(e: KeyboardEvent): boolean {
|
|
37
|
+
return e.metaKey || e.ctrlKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function NavLink({ item, active, collapsed, onNavigate }: { item: NavEntry; active: boolean; collapsed: boolean; onNavigate?: () => void }) {
|
|
41
|
+
const Icon = item.icon;
|
|
42
|
+
const link = (
|
|
43
|
+
<Link
|
|
44
|
+
href={item.path}
|
|
45
|
+
aria-current={active ? "page" : undefined}
|
|
46
|
+
onClick={onNavigate}
|
|
47
|
+
title={collapsed ? undefined : item.description}
|
|
48
|
+
className={cn(
|
|
49
|
+
"group flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
50
|
+
collapsed ? "w-8 justify-center" : "px-2",
|
|
51
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "text-text-color-muted hover:bg-surface-hover hover:text-foreground",
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<span className={cn("flex size-5 shrink-0 items-center justify-center", active ? "text-foreground" : "text-text-color-muted group-hover:text-foreground")}>
|
|
55
|
+
{Icon ? (
|
|
56
|
+
<Icon className="size-4" strokeWidth={1.5} aria-hidden />
|
|
57
|
+
) : (
|
|
58
|
+
<span aria-hidden className={cn("flex size-4 items-center justify-center rounded-sm text-[10px] font-medium uppercase", active ? "bg-foreground text-background" : "bg-muted")}>
|
|
59
|
+
{item.label.charAt(0)}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
{collapsed ? <span className="sr-only">{item.label}</span> : <span className="truncate">{item.label}</span>}
|
|
64
|
+
</Link>
|
|
65
|
+
);
|
|
66
|
+
if (!collapsed) return link;
|
|
67
|
+
return (
|
|
68
|
+
<Tooltip>
|
|
69
|
+
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
70
|
+
<TooltipContent side="right" className="text-xs">
|
|
71
|
+
{item.label}
|
|
72
|
+
{item.description ? <span className="ml-1.5 opacity-70">{item.description}</span> : null}
|
|
73
|
+
</TooltipContent>
|
|
74
|
+
</Tooltip>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function SidebarNav({ pathname, collapsed, onNavigate }: { pathname: string | null; collapsed: boolean; onNavigate?: () => void }) {
|
|
79
|
+
const groups = useMemo(() => groupNav(items), []);
|
|
80
|
+
return (
|
|
81
|
+
<nav aria-label="Main" className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden px-2 py-2">
|
|
82
|
+
{groups.map((group, gi) => (
|
|
83
|
+
<div key={group.label ?? "_top"} className={cn("flex flex-col gap-0.5", gi > 0 && "mt-2")}>
|
|
84
|
+
{group.label ? (
|
|
85
|
+
collapsed ? (
|
|
86
|
+
<div aria-hidden className="mx-2 my-1 border-t border-sidebar-border" />
|
|
87
|
+
) : (
|
|
88
|
+
<div className="truncate px-2 pb-1 pt-1 text-[11px] font-medium uppercase tracking-wide text-text-color-muted">{group.label}</div>
|
|
89
|
+
)
|
|
90
|
+
) : null}
|
|
91
|
+
{group.items.map((item) => (
|
|
92
|
+
<NavLink key={item.path} item={item} active={isActivePath(item.path, pathname)} collapsed={collapsed} onNavigate={onNavigate} />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</nav>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function AppShell({ children, headerSlot, status, sidebarFooter, contentPadding = false }: AppShellProps) {
|
|
10
101
|
const pathname = usePathname();
|
|
102
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
103
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
104
|
+
const [jumpOpen, setJumpOpen] = useState(false);
|
|
105
|
+
const current = currentNavEntry(items, pathname);
|
|
106
|
+
|
|
107
|
+
// Remember the sidebar width per browser.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
try {
|
|
110
|
+
setCollapsed(localStorage.getItem(COLLAPSED_KEY) === "1");
|
|
111
|
+
} catch {
|
|
112
|
+
// storage unavailable: start expanded
|
|
113
|
+
}
|
|
114
|
+
}, []);
|
|
115
|
+
const toggleCollapsed = () => {
|
|
116
|
+
setCollapsed((c) => {
|
|
117
|
+
try {
|
|
118
|
+
localStorage.setItem(COLLAPSED_KEY, c ? "0" : "1");
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
return !c;
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setMobileOpen(false);
|
|
128
|
+
}, [pathname]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const onKey = (e: KeyboardEvent) => {
|
|
132
|
+
if (!isMod(e)) return;
|
|
133
|
+
if (e.key.toLowerCase() === "k") {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setJumpOpen((o) => !o);
|
|
136
|
+
} else if (e.key.toLowerCase() === "b") {
|
|
137
|
+
// Cmd/Ctrl+B means bold in a text field: leave it to the field.
|
|
138
|
+
if (isEditableTarget(e.target)) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
toggleCollapsed();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
window.addEventListener("keydown", onKey);
|
|
144
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const brand = (
|
|
148
|
+
<div className={cn("flex h-14 shrink-0 items-center gap-2.5 border-b border-sidebar-border", collapsed ? "justify-center px-0" : "px-4")}>
|
|
149
|
+
<span aria-hidden className="flex size-7 shrink-0 items-center justify-center rounded-md bg-foreground text-[11px] font-medium uppercase text-background">
|
|
150
|
+
{String({{titleJson}}).charAt(0)}
|
|
151
|
+
</span>
|
|
152
|
+
<div className={cn("min-w-0 truncate text-sm font-medium text-foreground", collapsed && "sr-only")}>{{{titleJson}}}</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
|
|
11
156
|
return (
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
157
|
+
<TooltipProvider delayDuration={100}>
|
|
158
|
+
<div className="flex h-dvh w-full overflow-hidden bg-background text-foreground">
|
|
159
|
+
<aside
|
|
160
|
+
aria-label="Sidebar"
|
|
161
|
+
data-collapsed={collapsed ? "true" : "false"}
|
|
162
|
+
className={cn("hidden h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-150 md:flex", collapsed ? "w-14" : "w-60")}
|
|
163
|
+
>
|
|
164
|
+
{brand}
|
|
165
|
+
<SidebarNav pathname={pathname} collapsed={collapsed} />
|
|
166
|
+
<div className={cn("flex shrink-0 items-center gap-1 border-t border-sidebar-border p-2", collapsed ? "flex-col" : "")}>
|
|
167
|
+
{sidebarFooter ? <div className={cn("min-w-0 flex-1", collapsed && "hidden")}>{sidebarFooter}</div> : null}
|
|
168
|
+
<Tooltip>
|
|
169
|
+
<TooltipTrigger asChild>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={toggleCollapsed}
|
|
173
|
+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
174
|
+
aria-expanded={!collapsed}
|
|
175
|
+
className={cn("inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-color-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", !collapsed && !sidebarFooter && "ml-auto")}
|
|
176
|
+
>
|
|
177
|
+
{collapsed ? <ChevronsRight className="size-4" strokeWidth={1.5} aria-hidden /> : <ChevronsLeft className="size-4" strokeWidth={1.5} aria-hidden />}
|
|
178
|
+
</button>
|
|
179
|
+
</TooltipTrigger>
|
|
180
|
+
<TooltipContent side="right" className="text-xs">
|
|
181
|
+
{collapsed ? "Expand" : "Collapse"} <Kbd className="ml-1">⌘B</Kbd>
|
|
182
|
+
</TooltipContent>
|
|
183
|
+
</Tooltip>
|
|
184
|
+
</div>
|
|
185
|
+
</aside>
|
|
186
|
+
|
|
187
|
+
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
188
|
+
<SheetContent side="left" hideCloseButton className="w-72 gap-0 bg-sidebar p-0 sm:max-w-72">
|
|
189
|
+
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
190
|
+
<SheetDescription className="sr-only">Screens of this app</SheetDescription>
|
|
191
|
+
<div className="flex h-full flex-col">
|
|
192
|
+
{brand}
|
|
193
|
+
<SidebarNav pathname={pathname} collapsed={false} onNavigate={() => setMobileOpen(false)} />
|
|
194
|
+
{sidebarFooter ? <div className="border-t border-sidebar-border p-2">{sidebarFooter}</div> : null}
|
|
195
|
+
</div>
|
|
196
|
+
</SheetContent>
|
|
197
|
+
</Sheet>
|
|
198
|
+
|
|
199
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
200
|
+
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background px-3 sm:px-4">
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => setMobileOpen(true)}
|
|
204
|
+
aria-label="Open navigation"
|
|
205
|
+
className="inline-flex size-8 items-center justify-center rounded-md text-text-color-muted hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
|
|
206
|
+
>
|
|
207
|
+
<Menu className="size-4" strokeWidth={1.5} aria-hidden />
|
|
208
|
+
</button>
|
|
209
|
+
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
|
|
210
|
+
<span className="hidden truncate font-medium text-foreground sm:inline">{{{titleJson}}}</span>
|
|
211
|
+
{current ? (
|
|
212
|
+
<>
|
|
213
|
+
<span aria-hidden className="hidden text-text-color-muted/60 sm:inline">/</span>
|
|
214
|
+
{current.group ? (
|
|
215
|
+
<>
|
|
216
|
+
<span className="hidden truncate text-text-color-muted lg:inline">{current.group}</span>
|
|
217
|
+
<span aria-hidden className="hidden text-text-color-muted/60 lg:inline">/</span>
|
|
218
|
+
</>
|
|
219
|
+
) : null}
|
|
220
|
+
<span aria-current="page" className="truncate font-medium text-foreground sm:font-normal sm:text-text-color-muted">{current.label}</span>
|
|
221
|
+
</>
|
|
222
|
+
) : null}
|
|
223
|
+
</nav>
|
|
224
|
+
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => setJumpOpen(true)}
|
|
228
|
+
aria-label="Jump to a screen"
|
|
229
|
+
aria-keyshortcuts="Meta+K Control+K"
|
|
230
|
+
className="inline-flex h-8 items-center gap-2 rounded-md border border-border bg-background px-2.5 text-sm text-text-color-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
24
231
|
>
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
232
|
+
<Search className="size-4" strokeWidth={1.5} aria-hidden />
|
|
233
|
+
<span className="hidden md:inline">Jump to</span>
|
|
234
|
+
<Kbd className="hidden md:inline-flex">⌘K</Kbd>
|
|
235
|
+
</button>
|
|
236
|
+
{status === undefined ? <ConnectionStatus /> : status}
|
|
237
|
+
{headerSlot}
|
|
238
|
+
<ThemeToggle />
|
|
239
|
+
</div>
|
|
240
|
+
</header>
|
|
241
|
+
<main id="main" className={cn("min-h-0 min-w-0 flex-1 overflow-y-auto", contentPadding && "p-6")}>
|
|
242
|
+
{children}
|
|
243
|
+
</main>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<QuickJump items={items} open={jumpOpen} onOpenChange={setJumpOpen} />
|
|
247
|
+
</TooltipProvider>
|
|
33
248
|
);
|
|
34
249
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { KeyRound } from "lucide-react";
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@graph8/sdk/ui";
|
|
6
|
+
|
|
7
|
+
export type State =
|
|
8
|
+
| { kind: "checking" }
|
|
9
|
+
| { kind: "ok"; who: string }
|
|
10
|
+
| { kind: "setup"; message: string }
|
|
11
|
+
| { kind: "hidden"; message: string }
|
|
12
|
+
| { kind: "down"; message: string };
|
|
13
|
+
|
|
14
|
+
const PATH = "/api/g8/me";
|
|
15
|
+
|
|
16
|
+
function describe(data: unknown): string {
|
|
17
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
18
|
+
const org = (d.organization as Record<string, unknown> | undefined)?.name ?? d.org_name ?? d.organization_name;
|
|
19
|
+
const name = d.name ?? d.key_name ?? d.label;
|
|
20
|
+
return [name, org].filter((x): x is string => typeof x === "string" && x.length > 0).join(" at ") || "graph8";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function messageOf(body: Record<string, unknown>, status: number): string {
|
|
24
|
+
const raw = body.message ?? body.detail ?? body.error;
|
|
25
|
+
if (typeof raw === "string" && raw) return raw;
|
|
26
|
+
if (raw && typeof raw === "object") {
|
|
27
|
+
const nested = (raw as Record<string, unknown>).message;
|
|
28
|
+
if (typeof nested === "string" && nested) return nested;
|
|
29
|
+
}
|
|
30
|
+
return `HTTP ${status}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map the `/api/g8/me` answer to a state. Only the proxy's own refusals (identified by their `error`
|
|
35
|
+
* code, not the status, because graph8's 401/403/500 pass through unchanged) are setup hints.
|
|
36
|
+
*/
|
|
37
|
+
export function connectionStateFor(status: number, body: Record<string, unknown>): Exclude<State, { kind: "checking" }> {
|
|
38
|
+
if (status >= 200 && status < 300) return { kind: "ok", who: describe(body.data ?? body) };
|
|
39
|
+
const message = messageOf(body, status);
|
|
40
|
+
if (body.error === "g8_proxy_misconfigured") return { kind: "setup", message };
|
|
41
|
+
if (body.error === "path_not_allowed") {
|
|
42
|
+
return { kind: "hidden", message: 'The proxy runs, but "me" is not in allowedPrefixes (app/api/g8/[...path]/route.ts): add it to show the connected key here.' };
|
|
43
|
+
}
|
|
44
|
+
if (status === 401 || status === 403) {
|
|
45
|
+
return { kind: "down", message: `${message} (HTTP ${status}: graph8 or your authorize hook refused it; check the server's graph8 API key and your session.)` };
|
|
46
|
+
}
|
|
47
|
+
return { kind: "down", message };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Shows whether this app reaches graph8 through its own proxy route (`/api/g8/me`), without ever
|
|
52
|
+
* holding the key in the browser. The proxy passes graph8's status codes straight through, so only the
|
|
53
|
+
* proxy's OWN refusals (tagged by their `error` code) count as setup steps:
|
|
54
|
+
* - setup (warning): the proxy refuses to run (`g8_proxy_misconfigured`: no G8_API_KEY, no `authorize`,
|
|
55
|
+
* no `allowedPrefixes`): fix route.ts
|
|
56
|
+
* - hidden (warning): the proxy runs but "me" is not an allowed prefix (`path_not_allowed`): add it to
|
|
57
|
+
* show who the key belongs to. Unknown is a warning, never green.
|
|
58
|
+
* - ok (green): connected, the tooltip names the key and the workspace
|
|
59
|
+
* - down (red): anything else (graph8 401/403/5xx, your `authorize` hook refusing, unreachable), with
|
|
60
|
+
* the message, so the developer sees the real cause
|
|
61
|
+
* Replace or remove it from app-shell.tsx once your own session UI is in place.
|
|
62
|
+
*/
|
|
63
|
+
export function ConnectionStatus({ className }: { className?: string }) {
|
|
64
|
+
const [state, setState] = useState<State>({ kind: "checking" });
|
|
65
|
+
|
|
66
|
+
const check = useCallback(async () => {
|
|
67
|
+
setState({ kind: "checking" });
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(PATH, { headers: { accept: "application/json" }, cache: "no-store" });
|
|
70
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
71
|
+
setState(connectionStateFor(res.status, body));
|
|
72
|
+
} catch {
|
|
73
|
+
setState({ kind: "down", message: "Could not reach this app's /api/g8 route." });
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
void check();
|
|
79
|
+
}, [check]);
|
|
80
|
+
|
|
81
|
+
const label = state.kind === "ok" ? "Connected" : state.kind === "checking" ? "Checking" : state.kind === "hidden" ? "Proxy ready" : state.kind === "setup" ? "Set up the proxy" : "Not connected";
|
|
82
|
+
const dot = state.kind === "ok" ? "bg-positive-foreground" : state.kind === "checking" ? "bg-text-color-muted" : state.kind === "setup" || state.kind === "hidden" ? "bg-warning-foreground" : "bg-error-foreground";
|
|
83
|
+
const detail = state.kind === "ok" ? state.who : state.kind === "checking" ? "Asking graph8 who this key belongs to." : state.message;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<TooltipProvider delayDuration={150}>
|
|
87
|
+
<Tooltip>
|
|
88
|
+
<TooltipTrigger asChild>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={() => void check()}
|
|
92
|
+
aria-label={`graph8: ${label}. ${detail}`}
|
|
93
|
+
className={cn(
|
|
94
|
+
"inline-flex h-8 items-center gap-2 rounded-md border border-border bg-background px-2.5 text-xs text-foreground transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<KeyRound className="size-3.5 text-text-color-muted" strokeWidth={1.5} aria-hidden />
|
|
99
|
+
<span aria-hidden className={cn("size-1.5 rounded-full", dot)} />
|
|
100
|
+
<span className="hidden sm:inline">{label}</span>
|
|
101
|
+
</button>
|
|
102
|
+
</TooltipTrigger>
|
|
103
|
+
<TooltipContent side="bottom" align="end" className="max-w-xs text-xs">
|
|
104
|
+
{detail}
|
|
105
|
+
</TooltipContent>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
</TooltipProvider>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sidebar's view of `lib/nav.ts`. `icon`, `group` and `description` are optional: a nav generated
|
|
5
|
+
* without them still renders (letter glyphs, one unlabelled section). Edit freely.
|
|
6
|
+
*/
|
|
7
|
+
export interface NavEntry {
|
|
8
|
+
label: string;
|
|
9
|
+
path: string;
|
|
10
|
+
icon?: LucideIcon;
|
|
11
|
+
group?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NavGroup {
|
|
16
|
+
/** null for the unlabelled top section (entries without a group). */
|
|
17
|
+
label: string | null;
|
|
18
|
+
items: NavEntry[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Groups in first-seen order; ungrouped entries come first, in one unlabelled section. */
|
|
22
|
+
export function groupNav(items: readonly NavEntry[]): NavGroup[] {
|
|
23
|
+
const groups: NavGroup[] = [];
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const label = item.group?.trim() || null;
|
|
26
|
+
let group = groups.find((g) => g.label === label);
|
|
27
|
+
if (!group) {
|
|
28
|
+
group = { label, items: [] };
|
|
29
|
+
groups.push(group);
|
|
30
|
+
}
|
|
31
|
+
group.items.push(item);
|
|
32
|
+
}
|
|
33
|
+
return [...groups.filter((g) => g.label === null), ...groups.filter((g) => g.label !== null)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `/contacts` is active on `/contacts` and `/contacts/42`; `/` only on `/`. */
|
|
37
|
+
export function isActivePath(itemPath: string, pathname: string | null): boolean {
|
|
38
|
+
if (!pathname) return false;
|
|
39
|
+
if (itemPath === "/") return pathname === "/";
|
|
40
|
+
return pathname === itemPath || pathname.startsWith(`${itemPath}/`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The nav entry for the current URL (the longest matching path wins). */
|
|
44
|
+
export function currentNavEntry(items: readonly NavEntry[], pathname: string | null): NavEntry | undefined {
|
|
45
|
+
return [...items].filter((i) => isActivePath(i.path, pathname)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Case-insensitive match on label, group and description, for the quick-jump palette. */
|
|
49
|
+
export function matchesNav(item: NavEntry, query: string): boolean {
|
|
50
|
+
const q = query.trim().toLowerCase();
|
|
51
|
+
if (!q) return true;
|
|
52
|
+
return [item.label, item.group ?? "", item.description ?? "", item.path].some((s) => s.toLowerCase().includes(q));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when a key event comes from a text field, so app shortcuts (Cmd/Ctrl+B is bold there) leave it alone. */
|
|
56
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
57
|
+
const el = target as (HTMLElement & { isContentEditable?: boolean }) | null;
|
|
58
|
+
if (!el || typeof el.tagName !== "string") return false;
|
|
59
|
+
if (el.isContentEditable || el.closest?.("[contenteditable=''],[contenteditable='true'],[contenteditable='plaintext-only']")) return true;
|
|
60
|
+
const tag = el.tagName.toLowerCase();
|
|
61
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
62
|
+
if (tag !== "input") return false;
|
|
63
|
+
const type = ((el as HTMLInputElement).type || "text").toLowerCase();
|
|
64
|
+
return !["button", "checkbox", "radio", "range", "color", "file", "image", "reset", "submit"].includes(type);
|
|
65
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { ArrowRight, Search } from "lucide-react";
|
|
6
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle, Kbd, cn } from "@graph8/sdk/ui";
|
|
7
|
+
import { groupNav, matchesNav, type NavEntry } from "./nav-model";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Quick jump (Cmd/Ctrl+K): type to filter the screens, arrows to move, Enter to go. No extra
|
|
11
|
+
* dependency: a Dialog, an input and a list. Add your own entries (records, actions) to `extra`.
|
|
12
|
+
*/
|
|
13
|
+
export function QuickJump({ items, open, onOpenChange, extra = [] }: { items: readonly NavEntry[]; open: boolean; onOpenChange: (open: boolean) => void; extra?: readonly NavEntry[] }) {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const [query, setQuery] = useState("");
|
|
16
|
+
const [cursor, setCursor] = useState(0);
|
|
17
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
19
|
+
|
|
20
|
+
const results = useMemo(() => {
|
|
21
|
+
const all = [...items, ...extra].filter((i) => matchesNav(i, query));
|
|
22
|
+
return groupNav(all).flatMap((g) => g.items.map((item) => ({ item, group: g.label })));
|
|
23
|
+
}, [items, extra, query]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open) {
|
|
27
|
+
setQuery("");
|
|
28
|
+
setCursor(0);
|
|
29
|
+
}
|
|
30
|
+
}, [open]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setCursor((c) => Math.min(c, Math.max(results.length - 1, 0)));
|
|
34
|
+
}, [results.length]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${cursor}"]`);
|
|
38
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
39
|
+
}, [cursor]);
|
|
40
|
+
|
|
41
|
+
const go = (item: NavEntry | undefined) => {
|
|
42
|
+
if (!item) return;
|
|
43
|
+
onOpenChange(false);
|
|
44
|
+
router.push(item.path);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const listboxId = "g8-quick-jump-results";
|
|
48
|
+
const activeId = results[cursor] ? `g8-quick-jump-${cursor}` : undefined;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
52
|
+
<DialogContent
|
|
53
|
+
showCloseButton={false}
|
|
54
|
+
className="top-[18%] max-w-lg translate-y-0 gap-0 overflow-hidden rounded-xl border border-border bg-popover p-0 shadow-xl sm:max-w-lg"
|
|
55
|
+
onOpenAutoFocus={(e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
inputRef.current?.focus();
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<DialogTitle className="sr-only">Jump to a screen</DialogTitle>
|
|
61
|
+
<DialogDescription className="sr-only">Type to filter, use the arrow keys, press Enter to open.</DialogDescription>
|
|
62
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
63
|
+
<Search className="size-4 shrink-0 text-text-color-muted" strokeWidth={1.5} aria-hidden />
|
|
64
|
+
<input
|
|
65
|
+
ref={inputRef}
|
|
66
|
+
role="combobox"
|
|
67
|
+
aria-expanded="true"
|
|
68
|
+
aria-controls={listboxId}
|
|
69
|
+
aria-activedescendant={activeId}
|
|
70
|
+
aria-label="Jump to a screen"
|
|
71
|
+
autoComplete="off"
|
|
72
|
+
spellCheck={false}
|
|
73
|
+
value={query}
|
|
74
|
+
placeholder="Jump to a screen"
|
|
75
|
+
onChange={(e) => {
|
|
76
|
+
setQuery(e.target.value);
|
|
77
|
+
setCursor(0);
|
|
78
|
+
}}
|
|
79
|
+
onKeyDown={(e) => {
|
|
80
|
+
if (e.key === "ArrowDown") {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setCursor((c) => (results.length ? (c + 1) % results.length : 0));
|
|
83
|
+
} else if (e.key === "ArrowUp") {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
setCursor((c) => (results.length ? (c - 1 + results.length) % results.length : 0));
|
|
86
|
+
} else if (e.key === "Enter") {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
go(results[cursor]?.item);
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
className="h-12 w-full min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-text-color-muted"
|
|
92
|
+
/>
|
|
93
|
+
<Kbd>esc</Kbd>
|
|
94
|
+
</div>
|
|
95
|
+
<ul ref={listRef} id={listboxId} role="listbox" aria-label="Screens" className="max-h-80 overflow-y-auto p-1.5">
|
|
96
|
+
{results.length === 0 ? (
|
|
97
|
+
<li className="px-3 py-8 text-center text-sm text-text-color-muted">No screen matches "{query}".</li>
|
|
98
|
+
) : (
|
|
99
|
+
results.map(({ item, group }, index) => {
|
|
100
|
+
const Icon = item.icon;
|
|
101
|
+
const active = index === cursor;
|
|
102
|
+
return (
|
|
103
|
+
<li
|
|
104
|
+
key={`${index}:${item.path}`}
|
|
105
|
+
id={`g8-quick-jump-${index}`}
|
|
106
|
+
data-index={index}
|
|
107
|
+
role="option"
|
|
108
|
+
aria-selected={active}
|
|
109
|
+
onMouseEnter={() => setCursor(index)}
|
|
110
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
111
|
+
onClick={() => go(item)}
|
|
112
|
+
className={cn("flex h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm", active ? "bg-surface-selected text-foreground" : "text-foreground")}
|
|
113
|
+
>
|
|
114
|
+
<span className="flex size-5 shrink-0 items-center justify-center text-text-color-muted">
|
|
115
|
+
{Icon ? <Icon className="size-4" strokeWidth={1.5} aria-hidden /> : <span className="text-[10px] font-medium uppercase">{item.label.charAt(0)}</span>}
|
|
116
|
+
</span>
|
|
117
|
+
<span className="min-w-0 flex-1 truncate">
|
|
118
|
+
{item.label}
|
|
119
|
+
{item.description ? <span className="ml-2 text-xs text-text-color-muted">{item.description}</span> : null}
|
|
120
|
+
</span>
|
|
121
|
+
{group ? <span className="shrink-0 text-xs text-text-color-muted">{group}</span> : null}
|
|
122
|
+
{active ? <ArrowRight className="size-3.5 shrink-0 text-text-color-muted" strokeWidth={1.5} aria-hidden /> : null}
|
|
123
|
+
</li>
|
|
124
|
+
);
|
|
125
|
+
})
|
|
126
|
+
)}
|
|
127
|
+
</ul>
|
|
128
|
+
</DialogContent>
|
|
129
|
+
</Dialog>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Moon, Sun } from "lucide-react";
|
|
5
|
+
import { cn } from "@graph8/sdk/ui";
|
|
6
|
+
|
|
7
|
+
const KEY = "g8.theme";
|
|
8
|
+
|
|
9
|
+
/** Light or dark, remembered in localStorage and applied as the `dark` class on <html> (see app/layout.tsx). */
|
|
10
|
+
export function ThemeToggle({ className }: { className?: string }) {
|
|
11
|
+
const [dark, setDark] = useState<boolean | null>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setDark(document.documentElement.classList.contains("dark"));
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
const toggle = () => {
|
|
18
|
+
const next = !document.documentElement.classList.contains("dark");
|
|
19
|
+
document.documentElement.classList.toggle("dark", next);
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(KEY, next ? "dark" : "light");
|
|
22
|
+
} catch {
|
|
23
|
+
// Private mode: the choice lasts for this page only.
|
|
24
|
+
}
|
|
25
|
+
setDark(next);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const Icon = dark ? Sun : Moon;
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={toggle}
|
|
33
|
+
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
|
34
|
+
aria-pressed={dark ?? false}
|
|
35
|
+
className={cn("inline-flex size-8 items-center justify-center rounded-md text-text-color-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
|
|
36
|
+
>
|
|
37
|
+
<Icon className="size-4" strokeWidth={1.5} aria-hidden />
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by `graph8-sdk create {{stackId}}`. One entry per screen; edit freely.
|
|
2
|
-
export const nav: {
|
|
2
|
+
{{navImports}}export const nav: {{navType}} = {{navJson}};
|