@pablozaiden/webapp 0.2.3 → 0.3.1

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/docs/sidebar.md CHANGED
@@ -30,15 +30,15 @@ Sidebar nodes support:
30
30
  | `route` | Hash route object used by `WebAppRoot` |
31
31
  | `children` | Collapsible nesting |
32
32
  | `action` | Single inline per-section/item action; use sparingly and prefer `actions` menus when possible |
33
- | `actions` | Context menu items shown on sidebar right-click |
33
+ | `actions` | Entity action menu items shown on sidebar right-click and automatically in the active route title-bar menu |
34
34
  | `pinnable` | Enables framework Pin/Unpin actions |
35
35
  | `pinId` | Stable pin identity when it should differ from `id` |
36
- | `badge` | Status/count label |
36
+ | `badge` | Status/count value; sidebar items render it as a compact status dot with accessible label/tooltip |
37
37
  | `defaultCollapsed` | Initial collapsed state |
38
38
 
39
39
  Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered. Set `sidebar.search: false` when an app has a small fixed navigation tree and should not show the sidebar search box.
40
40
 
41
- Use `actions` when an entity needs commands in the sidebar. The same `ActionMenuItem[]` should also be returned from `header.getActions` for that entity route so right-click actions and the title-bar three-line menu stay consistent:
41
+ Use `actions` when an entity needs commands in the sidebar. `WebAppRoot` finds the active route-backed sidebar node and automatically renders its `ActionMenuItem[]` in the title-bar three-line menu, so the sidebar right-click menu and header menu stay consistent from one source of truth. Use `header.getActions` only for extra route-level actions that are not represented by the active sidebar node.
42
42
 
43
43
  ```tsx
44
44
  const actions = projectActions(project);
@@ -56,9 +56,6 @@ return {
56
56
  pinning: { sectionTitle: "Pinned" },
57
57
  getNodes,
58
58
  }}
59
- header={{
60
- getActions: ({ route }) => route.view === "project" ? projectActionsForRoute(route) : [],
61
- }}
62
59
  />
63
60
  ```
64
61
 
@@ -18,9 +18,9 @@ Use these first:
18
18
  | --- | --- |
19
19
  | `Toolbar` | Page title/actions inside main content |
20
20
  | `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
21
- | `ActionMenu` | Three-line action menu for secondary surfaces; entity-level menus should usually be exposed through `WebAppRoot.header.getActions` so they render in the fixed title bar |
21
+ | `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
22
22
  | `Button` / `IconButton` | Form submission and true inline controls; prefer action menus for entity/app commands |
23
- | `Badge` | Status/count labels |
23
+ | `Badge` | Status/count labels; sidebar badges render as compact colored dots to preserve sidebar width |
24
24
  | `EntityHeader` | Main-content entity title/description/actions |
25
25
  | `DataList` / `DataListRow` | Lists with title, description, metadata, badge and actions |
26
26
  | `TextField`, `TextAreaField`, `SelectField` | Forms |
@@ -32,7 +32,7 @@ Use these first:
32
32
  | `EmptyState` | Empty or missing content |
33
33
  | `ConfirmDialog` | Destructive confirmation |
34
34
 
35
- For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder, attach it to `SidebarNode.actions` for right-click, and return the same actions from `WebAppRoot.header.getActions` for the selected route.
35
+ For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder and attach it to `SidebarNode.actions` for the route-backed node. The framework reuses those actions for both sidebar right-click and the active route title-bar menu. Use `WebAppRoot.header.getActions` only for additional actions not owned by a sidebar node.
36
36
 
37
37
  ## Visual validation captures
38
38
 
@@ -58,4 +58,8 @@ Use these captures as the manual visual baseline before changing shell, sidebar,
58
58
 
59
59
  All destructive delete actions must use the framework `ConfirmDialog` before calling the delete endpoint. Server lifecycle actions such as kill/reboot must also require confirmation and show the standard 15-second shutdown countdown progress bar after the request succeeds.
60
60
 
61
- Prefer the framework action-menu pattern for app commands. Actions such as New task, New note, New project, archive, delete, or state transitions should live in `SidebarNode.actions` and/or `WebAppRoot.header.getActions` so they appear behind the three-line title-bar/item action menus. Use discrete buttons mainly for form submission or inline controls that cannot reasonably live in an action menu.
61
+ Framework dialogs and modal forms handle Enter as the primary/confirm action and Escape as cancel/close. Do not reimplement this keyboard behavior in app-local modal code.
62
+
63
+ Prefer the framework action-menu pattern for app commands. Actions such as New task, New note, New project, archive, delete, or state transitions should live in `SidebarNode.actions` so they appear behind the three-line title-bar/item action menus. Destructive actions should be marked `destructive: true`; the framework also treats delete-labelled actions defensively, renders them red, and orders them last. Use discrete buttons mainly for form submission or inline controls that cannot reasonably live in an action menu.
64
+
65
+ Headers and menus should be allowed to shrink safely: titles and subtitles truncate, while icon buttons and action buttons keep their shape and remain visible. Context menus are framework-positioned to stay inside the visible viewport.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,7 +6,6 @@ import type {
6
6
  AuditEventRecord,
7
7
  DeviceAuthRequestRecord,
8
8
  RefreshSessionRecord,
9
- SigningKeyRecord,
10
9
  StoredPasskey,
11
10
  UserRecord,
12
11
  UserSetupLinkRecord,
@@ -63,6 +63,7 @@ export interface WebAppServerConfig<TEvent = unknown> {
63
63
  logLevel?: {
64
64
  onChange?: (level: LogLevelName) => void;
65
65
  };
66
+ configResponse?: (req: Request, base: Readonly<WebAppConfigResponse>) => Record<string, unknown>;
66
67
  }
67
68
 
68
69
  export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
@@ -364,9 +365,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
364
365
  return { kind: "anonymous" };
365
366
  }
366
367
 
367
- function configResponse(req: Request): WebAppConfigResponse {
368
+ function configResponse(req: Request): WebAppConfigResponse & Record<string, unknown> {
368
369
  const user = passkeysEnabled && config.passkeyDisabled ? disabledAuthOwner() : passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
369
- return {
370
+ const base = {
370
371
  appName: config.appName,
371
372
  version,
372
373
  currentUser: user,
@@ -381,7 +382,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
381
382
  },
382
383
  apiKeys: { enabled: Boolean(apiKeysEnabled) },
383
384
  deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
384
- };
385
+ } satisfies WebAppConfigResponse;
386
+ return { ...(input.configResponse?.(req, base) ?? {}), ...base };
385
387
  }
386
388
 
387
389
  function setupUrl(req: Request, token: string): string {
@@ -427,6 +429,18 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
427
429
  const upgraded = server.upgrade(req, { data: { filters, userId: currentUser(auth)?.id } });
428
430
  return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
429
431
  }
432
+ if (path === "/api/auth/status" && req.method === "GET") {
433
+ const auth = await authorize(req, true);
434
+ if (auth instanceof Response) return auth;
435
+ const user = currentUser(auth);
436
+ return jsonResponse({
437
+ authenticated: auth.kind !== "anonymous",
438
+ authKind: auth.kind,
439
+ subject: user?.id ?? null,
440
+ clientId: auth.kind === "bearer" ? auth.claims.clientId : null,
441
+ scope: auth.kind === "bearer" ? auth.claims.scope : null,
442
+ });
443
+ }
430
444
  if (path === "/api/passkey-auth/status" && req.method === "GET") {
431
445
  return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
432
446
  }
@@ -30,7 +30,7 @@ function defaultCliPath(path: string): string {
30
30
  return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
31
31
  }
32
32
 
33
- function methodsFor(route: RouteDefinition): HttpMethod[] {
33
+ function methodsFor<TEvent>(route: RouteDefinition<TEvent>): HttpMethod[] {
34
34
  return HTTP_METHODS.filter((method) => typeof route[method] === "function");
35
35
  }
36
36
 
@@ -10,6 +10,7 @@ export type UserOwnedResource = { userId: string };
10
10
  export type UserIdSelector<TResource> = (resource: TResource) => string | undefined;
11
11
 
12
12
  export interface UserScopedRealtimePublisher<TEvent = unknown> {
13
+ readonly _eventType?: TEvent;
13
14
  publishChanged<TPayload = unknown>(resource: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget }): void;
14
15
  publishEntityChanged<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
15
16
  publishDeleted<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
@@ -75,13 +76,19 @@ export function matchRoute<TEvent>(routes: RouteTable<TEvent>, pathname: string)
75
76
  const requestParts = splitPath(pathname);
76
77
  for (const [pattern, route] of Object.entries(routes)) {
77
78
  const patternParts = splitPath(pattern);
78
- if (patternParts.length !== requestParts.length) {
79
+ const wildcardIndex = patternParts.indexOf("*");
80
+ const hasTrailingWildcard = wildcardIndex === patternParts.length - 1;
81
+ if ((!hasTrailingWildcard && patternParts.length !== requestParts.length) || (hasTrailingWildcard && requestParts.length < wildcardIndex)) {
79
82
  continue;
80
83
  }
81
84
  const params: Record<string, string> = {};
82
85
  let matched = true;
83
86
  for (let index = 0; index < patternParts.length; index += 1) {
84
87
  const patternPart = patternParts[index]!;
88
+ if (patternPart === "*" && index === patternParts.length - 1) {
89
+ params["*"] = requestParts.slice(index).map(decodeURIComponent).join("/");
90
+ break;
91
+ }
85
92
  const requestPart = requestParts[index]!;
86
93
  if (patternPart.startsWith(":")) {
87
94
  params[patternPart.slice(1)] = decodeURIComponent(requestPart);
@@ -51,8 +51,10 @@ export interface WebAppRootProps {
51
51
  routes: Record<string, ReactNode | ((route: WebAppRoute) => ReactNode)>;
52
52
  header?: {
53
53
  renderTitle?: (ctx: HeaderContext) => ReactNode;
54
+ renderActions?: (ctx: HeaderContext) => ReactNode;
54
55
  getActions?: (ctx: HeaderContext) => ActionMenuItem[];
55
56
  };
57
+ onRouteChange?: (route: WebAppRoute) => void;
56
58
  settings?: {
57
59
  sections?: SettingsSection[];
58
60
  };
@@ -203,7 +205,7 @@ function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "sear
203
205
  if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
204
206
  if (name === "bolt") return <svg {...common}><path d="m13 2-8 12h7l-1 8 8-12h-7l1-8Z" /></svg>;
205
207
  if (name === "chat") return <svg {...common}><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z" /><path d="M8 9h8M8 13h5" /></svg>;
206
- if (name === "code") return <svg {...common}><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>;
208
+ if (name === "code") return <svg {...common}><path d="M8 8 4 12l4 4M16 8l4 4-4 4M14 4l-4 16" /></svg>;
207
209
  if (name === "refresh") return <svg {...common}><path d="M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5M3 12A9 9 0 0 1 18.5 5.7L21 8M21 3v5h-5" /></svg>;
208
210
  return <svg {...common}><path d="M4 10.5 12 4l8 6.5V20H5v-7h14" /></svg>;
209
211
  }
@@ -394,7 +396,18 @@ function DeviceVerificationScreen() {
394
396
  );
395
397
  }
396
398
 
397
- function SidebarTree({ nodes, route, navigate, level = 0 }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number }) {
399
+ type SidebarTreeParentKind = "root" | "section" | "item";
400
+
401
+ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
402
+ if (level <= 0) {
403
+ return undefined;
404
+ }
405
+ const baseIndentRem = level * 0.375;
406
+ const nestedSectionIndentRem = parentKind === "section" && level > 1 ? 0.875 : 0;
407
+ return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
408
+ }
409
+
410
+ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
398
411
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
399
412
  const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
400
413
  return (
@@ -404,21 +417,25 @@ function SidebarTree({ nodes, route, navigate, level = 0 }: { nodes: SidebarNode
404
417
  const isCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
405
418
  if (node.type === "section") {
406
419
  return (
407
- <section className="wapp-sidebar-section" key={node.id}>
408
- <div className="wapp-sidebar-section-title" style={{ marginLeft: level ? `${level * 0.375}rem` : undefined }}>
409
- <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>
410
- <span>{isCollapsed ? "" : ""}</span>{node.title}
411
- </button>
412
- {node.action ? <button type="button" className="wapp-sidebar-action" onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
420
+ <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
421
+ <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
422
+ {hasChildren ? (
423
+ <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>
424
+ <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
425
+ </button>
426
+ ) : (
427
+ <div className="wapp-sidebar-section-label">{node.title}</div>
428
+ )}
429
+ {node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
413
430
  </div>
414
- {!isCollapsed && node.children ? <SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} /> : null}
415
- {!isCollapsed && !node.children?.length ? <div className="wapp-sidebar-empty">No items.</div> : null}
431
+ {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} level={level + 1} parentKind="section" /> : null}
432
+ {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
416
433
  </section>
417
434
  );
418
435
  }
419
436
  const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
420
437
  return (
421
- <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={{ marginLeft: level ? `${level * 0.375}rem` : undefined }}>
438
+ <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
422
439
  {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>{isCollapsed ? "▶" : "▼"}</button> : null}
423
440
  <button
424
441
  type="button"
@@ -434,9 +451,9 @@ function SidebarTree({ nodes, route, navigate, level = 0 }: { nodes: SidebarNode
434
451
  <strong>{node.title}</strong>
435
452
  {node.subtitle ? <small>{node.subtitle}</small> : null}
436
453
  </span>
437
- {node.badge ? <Badge variant={node.badgeVariant}>{node.badge}</Badge> : null}
454
+ {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
438
455
  </button>
439
- {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} /></div> : null}
456
+ {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} parentKind="item" /></div> : null}
440
457
  </div>
441
458
  );
442
459
  })}
@@ -454,6 +471,10 @@ function renderSettingsActions(actions: SettingsRow["actions"]): ReactNode {
454
471
  }
455
472
 
456
473
  function StructuredSettingsSection({ section }: { section: SettingsSection }) {
474
+ if (!section.rows?.length && section.render) {
475
+ return <section className="wapp-custom-settings-section">{section.render()}</section>;
476
+ }
477
+
457
478
  return (
458
479
  <FormSection title={section.title} description={section.description}>
459
480
  {section.rows?.map((row) => {
@@ -870,7 +891,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
870
891
  );
871
892
  }
872
893
 
873
- export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settings, version }: WebAppRootProps) {
894
+ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
874
895
  const { config, error, refresh } = useConfig();
875
896
  const { route, navigate } = useRoute(homeRoute);
876
897
  const { theme, setTheme } = useTheme();
@@ -938,6 +959,10 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
938
959
  .catch(() => undefined);
939
960
  }, [config?.currentUser?.id, setTheme]);
940
961
 
962
+ useEffect(() => {
963
+ onRouteChange?.(route);
964
+ }, [onRouteChange, route]);
965
+
941
966
  if (error) {
942
967
  return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
943
968
  }
@@ -968,13 +993,14 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
968
993
  const topActions = sidebar.topActions?.slice(0, 2) ?? [];
969
994
  const defaultTitle = route.view === "settings" ? "Settings" : route.view === homeRoute.view ? appName : route.view.replace(/-/g, " ");
970
995
  const headerContext = { route, defaultTitle };
971
- const activePinnableNode = allPinnableItems.find((node) => routeMatches(node.route, route));
972
- const activePinningAction = activePinnableNode ? pinningActionFor(activePinnableNode) : undefined;
996
+ const activeSidebarNode = flattenSidebarItems(nodes).find((node) => routeMatches(node.route, route));
997
+ const activeSidebarActions = activeSidebarNode?.actions ?? [];
973
998
  const headerActions = [
974
999
  ...(header?.getActions?.(headerContext) ?? []),
975
- ...(activePinningAction ? [activePinningAction] : []),
1000
+ ...activeSidebarActions,
976
1001
  ];
977
1002
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
1003
+ const primaryHeaderActions = header?.renderActions?.(headerContext);
978
1004
  const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
979
1005
 
980
1006
  return (
@@ -992,8 +1018,8 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
992
1018
  <div className="wapp-sidebar-scroll">
993
1019
  {sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
994
1020
  <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
1021
+ <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
995
1022
  </div>
996
- <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
997
1023
  </aside>
998
1024
  <section className="wapp-main">
999
1025
  <header className="wapp-main-header">
@@ -1001,9 +1027,10 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
1001
1027
  {sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => { setSidebarCollapsed(false); setSidebarOpen(true); }}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
1002
1028
  <h1>{headerTitle}</h1>
1003
1029
  </div>
1004
- {headerActions.length ? (
1030
+ {primaryHeaderActions || headerActions.length ? (
1005
1031
  <div className="wapp-main-header-actions">
1006
- <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} />
1032
+ {primaryHeaderActions}
1033
+ {headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
1007
1034
  </div>
1008
1035
  ) : null}
1009
1036
  </header>