@pablozaiden/webapp 0.2.3 → 0.3.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/docs/sidebar.md +3 -6
- package/docs/ui-guidelines.md +8 -4
- package/package.json +1 -1
- package/src/server/auth/sqlite-store.ts +0 -1
- package/src/server/create-web-app-server.ts +17 -3
- package/src/server/route-catalog.ts +1 -1
- package/src/server/routes.ts +8 -1
- package/src/web/WebAppRoot.tsx +47 -20
- package/src/web/components/index.tsx +437 -36
- package/src/web/sidebar/types.ts +17 -1
- package/src/web/styles.css +431 -27
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` |
|
|
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.
|
|
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
|
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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=
|
|
408
|
-
<div className="wapp-sidebar-section-title" style={
|
|
409
|
-
|
|
410
|
-
<
|
|
411
|
-
|
|
412
|
-
|
|
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 &&
|
|
415
|
-
{!isCollapsed && !
|
|
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={
|
|
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}
|
|
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
|
|
972
|
-
const
|
|
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
|
-
...
|
|
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
|
-
|
|
1032
|
+
{primaryHeaderActions}
|
|
1033
|
+
{headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
|
|
1007
1034
|
</div>
|
|
1008
1035
|
) : null}
|
|
1009
1036
|
</header>
|