@pablozaiden/webapp 0.6.1 → 0.6.3
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/README.md +1 -1
- package/docs/getting-started.md +103 -0
- package/docs/release.md +1 -1
- package/docs/server.md +6 -0
- package/docs/settings.md +17 -0
- package/docs/ui-guidelines.md +34 -1
- package/package.json +8 -4
- package/src/server/create-web-app-server.ts +2 -2
- package/src/server/framework-endpoints.ts +6 -3
- package/src/server/runtime-config.ts +18 -3
- package/src/web/WebAppRoot.tsx +40 -58
- package/src/web/index.ts +6 -0
- package/src/web/log-level.tsx +22 -0
- package/src/web/render.tsx +6 -1
- package/src/web/settings/account-section.tsx +60 -17
- package/src/web/settings/settings-view.tsx +3 -8
- package/src/web/styles.css +136 -0
- package/src/web/theme.tsx +147 -0
- package/src/web/toast.tsx +218 -0
- package/src/web/webapp-config.tsx +117 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Each application package must declare `react` and `react-dom`; they are peer dep
|
|
|
23
23
|
| Export | Use |
|
|
24
24
|
| --- | --- |
|
|
25
25
|
| `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, request-origin helpers, SQLite store |
|
|
26
|
-
| `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
|
|
26
|
+
| `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, `useToast`, sidebar types, UI controls, realtime hooks |
|
|
27
27
|
| `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
|
|
28
28
|
| `@pablozaiden/webapp/cli` | One-binary command helpers, device/environment auth credentials and generic API CLI caller |
|
|
29
29
|
| `@pablozaiden/webapp/build` | Bun single-binary compile helper |
|
package/docs/getting-started.md
CHANGED
|
@@ -122,6 +122,109 @@ renderWebApp(
|
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
`renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
|
|
125
|
+
|
|
126
|
+
### Theme state
|
|
127
|
+
|
|
128
|
+
The framework applies the local or system theme before the first React render and
|
|
129
|
+
loads the signed-in user's saved preference in the background. Components
|
|
130
|
+
rendered inside `WebAppRoot` can use the public hook when JavaScript needs the
|
|
131
|
+
effective theme, such as for a terminal, editor, or chart:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { Page, useTheme } from "@pablozaiden/webapp/web";
|
|
135
|
+
|
|
136
|
+
function ThemeAwareRoute() {
|
|
137
|
+
const { preference, resolvedTheme } = useTheme();
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Page>
|
|
141
|
+
<p>Preference: {preference}</p>
|
|
142
|
+
<p>Resolved theme: {resolvedTheme}</p>
|
|
143
|
+
</Page>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`preference` is `system`, `light`, or `dark`; `resolvedTheme` is always
|
|
149
|
+
`light` or `dark` and follows operating-system changes while `preference` is
|
|
150
|
+
`system`. Prefer CSS dark-mode styling when JavaScript does not need the
|
|
151
|
+
resolved value. Do not inspect or mutate framework-owned root classes or
|
|
152
|
+
attributes to infer theme state.
|
|
153
|
+
|
|
154
|
+
### Client log-level state
|
|
155
|
+
|
|
156
|
+
The framework loads the effective client log level as part of the shared
|
|
157
|
+
`WebAppRoot` configuration request. Components rendered inside `WebAppRoot`
|
|
158
|
+
can subscribe with `useLogLevel()`; they should not fetch framework
|
|
159
|
+
configuration or `/api/preferences/log-level` in an app-local initializer:
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { useEffect } from "react";
|
|
163
|
+
import { useLogLevel } from "@pablozaiden/webapp/web";
|
|
164
|
+
import type { LogLevelName } from "@pablozaiden/webapp/contracts";
|
|
165
|
+
|
|
166
|
+
function LoggerAdapter({ setLevel }: { setLevel: (level: LogLevelName) => void }) {
|
|
167
|
+
const { level, fromEnv, loading, error, retry } = useLogLevel();
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (level !== undefined) {
|
|
171
|
+
setLevel(level);
|
|
172
|
+
}
|
|
173
|
+
}, [level, setLevel]);
|
|
174
|
+
|
|
175
|
+
if (error) {
|
|
176
|
+
return <button type="button" onClick={() => void retry()}>Retry log-level configuration</button>;
|
|
177
|
+
}
|
|
178
|
+
if (loading || level === undefined) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return <span aria-hidden="true">{fromEnv ? "Log level is environment-controlled." : null}</span>;
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`level` and `fromEnv` are unavailable until valid framework configuration has
|
|
187
|
+
loaded. Do not substitute a default level when `error` is set. The framework
|
|
188
|
+
Settings control owns persistence and updates the shared state without a page
|
|
189
|
+
reload; `fromEnv` indicates that the server environment locks the value.
|
|
190
|
+
`LoggerAdapter` remains application-owned and can adapt the state to any
|
|
191
|
+
logging library.
|
|
192
|
+
|
|
193
|
+
### Transient notifications
|
|
194
|
+
|
|
195
|
+
The standard `renderWebApp` runtime provides the framework notification service
|
|
196
|
+
to the application tree. Use the public hook for short-lived action outcomes;
|
|
197
|
+
do not add an application-owned provider, queue, timer system, or notification
|
|
198
|
+
CSS:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { useToast } from "@pablozaiden/webapp/web";
|
|
202
|
+
|
|
203
|
+
function SaveButton() {
|
|
204
|
+
const toast = useToast();
|
|
205
|
+
|
|
206
|
+
async function save() {
|
|
207
|
+
await saveRecord();
|
|
208
|
+
toast.success("Saved");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return <button type="button" onClick={() => void save()}>Save</button>;
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`useToast()` exposes `success`, `error`, `warning`, and `info` helpers. Each
|
|
216
|
+
returns a stable ID that can be passed to `dismiss`; `dismissAll` removes every
|
|
217
|
+
active notification. Pass an `id` to replace an existing notification and
|
|
218
|
+
reset its timer. Notifications dismiss after 8 seconds by default; provide a
|
|
219
|
+
positive `duration` in milliseconds for a custom timeout or use
|
|
220
|
+
`duration: 0` for a persistent notification that requires explicit dismissal.
|
|
221
|
+
The framework keeps at most five active notifications.
|
|
222
|
+
|
|
223
|
+
Use `ErrorState`, loading states, and field validation for persistent page or
|
|
224
|
+
form state. Use `ConfirmDialog` for destructive confirmation. Do not report the
|
|
225
|
+
same failure in both an inline error state and a toast unless both surfaces
|
|
226
|
+
serve distinct, intentional purposes.
|
|
227
|
+
|
|
125
228
|
`WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent. `Page` uses the padded layout by default. For routes whose child content fills the available shell viewport and owns its own spacing or scrolling, use the framework full layout instead of overriding framework CSS:
|
|
126
229
|
|
|
127
230
|
```tsx
|
package/docs/release.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
1. Install dependencies with `bun install --frozen-lockfile`.
|
|
8
8
|
2. Run `bun run tsc`.
|
|
9
|
-
3. Run `bun test
|
|
9
|
+
3. Run `bun run test`, which runs the build-binary integration tests in an isolated process before the remaining suites.
|
|
10
10
|
4. Build both example apps.
|
|
11
11
|
5. Start the compiled example binaries and smoke-test `/api/health` plus one app endpoint.
|
|
12
12
|
|
package/docs/server.md
CHANGED
|
@@ -124,6 +124,12 @@ Built-in endpoints include:
|
|
|
124
124
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
125
125
|
| `/api/ws` | Realtime websocket by default |
|
|
126
126
|
|
|
127
|
+
The effective log level in `GET /api/config` and `GET
|
|
128
|
+
/api/preferences/log-level` is resolved identically: an environment-provided
|
|
129
|
+
`{PREFIX}_LOG_LEVEL` wins over the persisted preference, and sets `fromEnv`
|
|
130
|
+
to `true`. PUT remains an authenticated, same-origin admin mutation and
|
|
131
|
+
returns a conflict when the environment controls the value.
|
|
132
|
+
|
|
127
133
|
## Framework-owned web document and PWA metadata
|
|
128
134
|
|
|
129
135
|
`createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
|
package/docs/settings.md
CHANGED
|
@@ -16,6 +16,23 @@ user preference loads in the background. If that request fails, the current
|
|
|
16
16
|
theme remains active and Display Settings shows a non-blocking error with a
|
|
17
17
|
retry action; the rest of Settings remains usable.
|
|
18
18
|
|
|
19
|
+
Application code rendered inside `WebAppRoot` can use `useTheme()` from
|
|
20
|
+
`@pablozaiden/webapp/web` when JavaScript needs theme state. The hook exposes
|
|
21
|
+
the selected `preference` and concrete `resolvedTheme`; the latter updates
|
|
22
|
+
when the operating-system color scheme changes in `system` mode. Prefer CSS
|
|
23
|
+
dark-mode styling when no JavaScript theme value is required, and do not infer
|
|
24
|
+
theme state by observing framework-owned DOM classes or attributes.
|
|
25
|
+
|
|
26
|
+
Application code can use `useLogLevel()` from `@pablozaiden/webapp/web` to
|
|
27
|
+
adapt an application-owned logger to the framework's effective client setting.
|
|
28
|
+
The hook exposes `level`, `fromEnv`, `loading`, `error`, and `retry`. The level
|
|
29
|
+
and environment metadata are unavailable until `/api/config` has loaded and
|
|
30
|
+
validated; configuration failures remain visible through `error` instead of
|
|
31
|
+
falling back to a fabricated level. The framework Settings selector updates
|
|
32
|
+
the shared hook state after a successful save, and `fromEnv` is true when
|
|
33
|
+
`{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
|
|
34
|
+
configuration fetch or initializer component.
|
|
35
|
+
|
|
19
36
|
Security lists only show useful active credentials. Expired API keys are purged before listing, and revoked or expired device-auth refresh sessions are not shown. Revoked refresh sessions may remain in storage when needed for token-reuse protection, but they are hidden from Settings.
|
|
20
37
|
|
|
21
38
|
Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -11,6 +11,20 @@ The framework intentionally provides a consistent base UI:
|
|
|
11
11
|
- Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
|
|
12
12
|
- `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
|
|
13
13
|
|
|
14
|
+
## Transient notifications
|
|
15
|
+
|
|
16
|
+
Use `useToast()` from `@pablozaiden/webapp/web` for short-lived success,
|
|
17
|
+
error, warning, or informational feedback. The standard `renderWebApp` runtime
|
|
18
|
+
already owns the provider, queue, timers, live region, dismissal controls, and
|
|
19
|
+
styling, so applications must not add a parallel toast provider, queue, or
|
|
20
|
+
notification CSS. Use the returned ID with `dismiss` when an operation needs
|
|
21
|
+
to close a persistent notification, or use `dismissAll` to clear the queue.
|
|
22
|
+
|
|
23
|
+
Use inline `ErrorState`, loading states, and field validation when the
|
|
24
|
+
information belongs to persistent page or form state. Use `ConfirmDialog` for
|
|
25
|
+
destructive confirmation rather than a toast, and avoid reporting one failure
|
|
26
|
+
twice without a distinct user-facing reason.
|
|
27
|
+
|
|
14
28
|
## Mobile breakpoint and viewport synchronization
|
|
15
29
|
|
|
16
30
|
The framework owns the mobile shell breakpoint. `MOBILE_BREAKPOINT_PX` and `MOBILE_MEDIA_QUERY` are exported from `@pablozaiden/webapp/web` for application JavaScript that needs to coordinate with the shell; do not add an independent `innerWidth` threshold for shell behavior. The generated document initializes the `data-wapp-mobile` marker on the root element before the client and styles load, and `WebAppRoot` keeps it synchronized with media-query changes. Custom CSS that follows the framework mobile mode should use that marker rather than repeating a numeric media query.
|
|
@@ -64,7 +78,26 @@ Reference screenshots live in `artifacts/screenshots`:
|
|
|
64
78
|
| `kitchen-dialog-dark.png` | Confirm dialog overlay |
|
|
65
79
|
| `kitchen-device-light.png` | Device auth approval flow |
|
|
66
80
|
|
|
67
|
-
Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
|
|
81
|
+
Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
|
|
82
|
+
|
|
83
|
+
The framework does not add Playwright to an application's dependencies or ship a
|
|
84
|
+
screenshot command. Install the official Playwright package and Chromium in an
|
|
85
|
+
isolated temporary directory instead:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
playwright_workdir="$(mktemp -d)"
|
|
89
|
+
trap 'rm -rf "$playwright_workdir"' EXIT
|
|
90
|
+
cd "$playwright_workdir"
|
|
91
|
+
npm init -y >/dev/null
|
|
92
|
+
npm install --no-save --package-lock=false playwright
|
|
93
|
+
npx playwright install chromium
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run the Playwright Node API from that temporary harness against the already
|
|
97
|
+
running application, and keep screenshots and browser state there. Do not add
|
|
98
|
+
Playwright, browser binaries, scripts, or configuration files to the
|
|
99
|
+
application repository. If Linux browser system libraries are missing, run
|
|
100
|
+
`npx playwright install-deps` from the same harness environment.
|
|
68
101
|
|
|
69
102
|
Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles. When screenshots are captured to validate a visual change, review them against the specific goal; capturing files without checking the result is not validation.
|
|
70
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,11 +33,15 @@
|
|
|
33
33
|
"dev": "bun run --cwd examples/notes-todo dev",
|
|
34
34
|
"dev:kitchen-sink": "bun run --cwd examples/kitchen-sink dev",
|
|
35
35
|
"dev:notes-todo": "bun run --cwd examples/notes-todo dev",
|
|
36
|
-
"build": "bun run
|
|
36
|
+
"build": "bun run typecheck && bun run build:examples",
|
|
37
|
+
"build:examples": "bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
|
|
37
38
|
"build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
|
|
38
39
|
"build:notes-todo": "bun run --cwd examples/notes-todo build",
|
|
39
|
-
"test": "bun test",
|
|
40
|
-
"
|
|
40
|
+
"test": "bun run test:build-binary && bun run test:remaining",
|
|
41
|
+
"test:build-binary": "bun test tests/build-binary.test.ts",
|
|
42
|
+
"test:remaining": "bun test --path-ignore-patterns=**/build-binary.test.ts",
|
|
43
|
+
"typecheck": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo",
|
|
44
|
+
"tsc": "bun run typecheck"
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@simplewebauthn/browser": "13.3.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import { sqliteWebAppStore } from "./auth/sqlite-store";
|
|
3
3
|
import { createRealtimeBus } from "./realtime/bus";
|
|
4
|
-
import { readRuntimeConfig } from "./runtime-config";
|
|
4
|
+
import { readRuntimeConfig, resolveEffectiveLogLevel } from "./runtime-config";
|
|
5
5
|
import {
|
|
6
6
|
WEBAPP_SOCKET_HANDLER,
|
|
7
7
|
type WebAppServer,
|
|
@@ -45,7 +45,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
45
45
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
46
46
|
store.initialize();
|
|
47
47
|
const savedLogLevel = store.getLogLevelPreference();
|
|
48
|
-
const activeLogLevel = config
|
|
48
|
+
const activeLogLevel = resolveEffectiveLogLevel(config, savedLogLevel);
|
|
49
49
|
setLogLevel(activeLogLevel);
|
|
50
50
|
input.logLevel?.onChange?.(activeLogLevel);
|
|
51
51
|
const realtime = createRealtimeBus<TEvent>();
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
tokenRequestSchema,
|
|
60
60
|
userRoleRequestSchema,
|
|
61
61
|
} from "./request-schemas";
|
|
62
|
-
import type
|
|
62
|
+
import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
|
|
63
63
|
import { setLogLevel } from "./logger";
|
|
64
64
|
import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
|
|
65
65
|
import type { WebSocketData } from "./realtime/bus";
|
|
@@ -112,7 +112,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
112
112
|
canManageUsers: Boolean(user?.isAdmin),
|
|
113
113
|
},
|
|
114
114
|
logLevel: {
|
|
115
|
-
level: (config
|
|
115
|
+
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
116
116
|
fromEnv: config.logLevelFromEnv,
|
|
117
117
|
},
|
|
118
118
|
apiKeys: { enabled: Boolean(apiKeysEnabled) },
|
|
@@ -416,7 +416,10 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
416
416
|
if (auth instanceof Response) return auth;
|
|
417
417
|
ensureAdmin(auth);
|
|
418
418
|
if (req.method === "GET") {
|
|
419
|
-
return jsonResponse({
|
|
419
|
+
return jsonResponse({
|
|
420
|
+
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
421
|
+
fromEnv: config.logLevelFromEnv,
|
|
422
|
+
});
|
|
420
423
|
}
|
|
421
424
|
if (req.method === "PUT") {
|
|
422
425
|
const originFailure = checkSameOrigin(req, config, auth, "mutations");
|
|
@@ -26,7 +26,22 @@ export interface RuntimeConfig {
|
|
|
26
26
|
development: false | { hmr: true; console: true };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const LOG_LEVELS = new Set(["trace", "debug", "info", "warn", "error"]);
|
|
29
|
+
const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
|
|
30
|
+
|
|
31
|
+
function isSupportedLogLevel(value: unknown): value is LogLevelName {
|
|
32
|
+
return typeof value === "string" && LOG_LEVELS.has(value as LogLevelName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveEffectiveLogLevel(
|
|
36
|
+
config: Pick<RuntimeConfig, "logLevel" | "logLevelFromEnv">,
|
|
37
|
+
savedLogLevel?: unknown,
|
|
38
|
+
): LogLevelName {
|
|
39
|
+
return config.logLevelFromEnv
|
|
40
|
+
? config.logLevel
|
|
41
|
+
: isSupportedLogLevel(savedLogLevel)
|
|
42
|
+
? savedLogLevel
|
|
43
|
+
: config.logLevel;
|
|
44
|
+
}
|
|
30
45
|
|
|
31
46
|
export function isTruthyEnv(value: string | undefined): boolean {
|
|
32
47
|
const normalized = value?.trim().toLowerCase();
|
|
@@ -64,10 +79,10 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
|
|
|
64
79
|
if (!raw) {
|
|
65
80
|
return fallback;
|
|
66
81
|
}
|
|
67
|
-
if (!
|
|
82
|
+
if (!isSupportedLogLevel(raw)) {
|
|
68
83
|
throw new Error(`${name} must be one of trace, debug, info, warn, error; received "${raw}"`);
|
|
69
84
|
}
|
|
70
|
-
return raw
|
|
85
|
+
return raw;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
function parseBoolean(raw: string | undefined, fallback: boolean, name: string): boolean {
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import type {
|
|
3
|
-
import { appJson } from "./api-client";
|
|
2
|
+
import type { WebAppConfigResponse } from "../contracts";
|
|
4
3
|
import { AppShell } from "./app-shell";
|
|
5
4
|
import { DeviceVerificationScreen, PasskeyAuthScreen, UserSetupScreen } from "./auth-screens";
|
|
6
5
|
import { EmptyState, Panel } from "./components";
|
|
@@ -10,35 +9,12 @@ import { flattenSidebarItems, toStoredPin, useSidebarCollapsedState, useSidebarP
|
|
|
10
9
|
import { SettingsView } from "./settings/settings-view";
|
|
11
10
|
import type { HeaderContext, WebAppRootProps } from "./root-types";
|
|
12
11
|
import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
12
|
+
import { ThemeProvider } from "./theme";
|
|
13
|
+
import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
|
|
13
14
|
|
|
14
15
|
export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
|
|
15
16
|
export type { HeaderContext, SettingsAction, SettingsRow, SettingsSection, WebAppRootProps } from "./root-types";
|
|
16
17
|
|
|
17
|
-
function useConfig() {
|
|
18
|
-
const [config, setConfig] = useState<WebAppConfigResponse>();
|
|
19
|
-
const [error, setError] = useState<string>();
|
|
20
|
-
const refresh = useCallback(async () => {
|
|
21
|
-
try {
|
|
22
|
-
setConfig(await appJson<WebAppConfigResponse>("/api/config"));
|
|
23
|
-
setError(undefined);
|
|
24
|
-
} catch (err) {
|
|
25
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
26
|
-
}
|
|
27
|
-
}, []);
|
|
28
|
-
useEffect(() => void refresh(), [refresh]);
|
|
29
|
-
return { config, error, refresh };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function useTheme() {
|
|
33
|
-
const [theme, setTheme] = useState<ThemePreference>(() => (localStorage.getItem("webapp.theme") as ThemePreference | null) ?? "system");
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
36
|
-
document.documentElement.classList.toggle("dark", dark);
|
|
37
|
-
localStorage.setItem("webapp.theme", theme);
|
|
38
|
-
}, [theme]);
|
|
39
|
-
return { theme, setTheme };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
18
|
function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
|
|
43
19
|
if (!left) {
|
|
44
20
|
return false;
|
|
@@ -46,14 +22,26 @@ function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolea
|
|
|
46
22
|
return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
|
|
47
23
|
}
|
|
48
24
|
|
|
49
|
-
|
|
25
|
+
function WebAppRootContent({
|
|
26
|
+
appName,
|
|
27
|
+
homeRoute,
|
|
28
|
+
sidebar,
|
|
29
|
+
routes,
|
|
30
|
+
header,
|
|
31
|
+
onRouteChange,
|
|
32
|
+
settings,
|
|
33
|
+
version,
|
|
34
|
+
config,
|
|
35
|
+
error,
|
|
36
|
+
refresh,
|
|
37
|
+
}: WebAppRootProps & {
|
|
38
|
+
config?: WebAppConfigResponse;
|
|
39
|
+
error?: Error;
|
|
40
|
+
refresh: () => Promise<void>;
|
|
41
|
+
}) {
|
|
50
42
|
const isMobile = useMobileBreakpoint();
|
|
51
43
|
useMobileViewportHeight(isMobile);
|
|
52
|
-
const { config, error, refresh } = useConfig();
|
|
53
44
|
const { route, navigate } = useRoute(homeRoute);
|
|
54
|
-
const { theme, setTheme } = useTheme();
|
|
55
|
-
const [themeLoading, setThemeLoading] = useState(false);
|
|
56
|
-
const [themeLoadError, setThemeLoadError] = useState<Error>();
|
|
57
45
|
const [search, setSearch] = useState("");
|
|
58
46
|
const sidebarSearchId = useId();
|
|
59
47
|
const sidebarSearchInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -125,35 +113,12 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
125
113
|
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
|
|
126
114
|
const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
|
|
127
115
|
|
|
128
|
-
const retryThemeLoad = useCallback(async () => {
|
|
129
|
-
if (!config?.currentUser) {
|
|
130
|
-
setThemeLoading(false);
|
|
131
|
-
setThemeLoadError(undefined);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
setThemeLoading(true);
|
|
136
|
-
setThemeLoadError(undefined);
|
|
137
|
-
try {
|
|
138
|
-
const result = await appJson<{ theme: ThemePreference }>("/api/preferences/theme");
|
|
139
|
-
setTheme(result.theme);
|
|
140
|
-
} catch (err) {
|
|
141
|
-
setThemeLoadError(err instanceof Error ? err : new Error(String(err)));
|
|
142
|
-
} finally {
|
|
143
|
-
setThemeLoading(false);
|
|
144
|
-
}
|
|
145
|
-
}, [config?.currentUser?.id, setTheme]);
|
|
146
|
-
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
void retryThemeLoad();
|
|
149
|
-
}, [retryThemeLoad]);
|
|
150
|
-
|
|
151
116
|
useEffect(() => {
|
|
152
117
|
onRouteChange?.(route);
|
|
153
118
|
}, [onRouteChange, route]);
|
|
154
119
|
|
|
155
|
-
if (error) {
|
|
156
|
-
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
|
|
120
|
+
if (error && !config) {
|
|
121
|
+
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error.message} /></main>;
|
|
157
122
|
}
|
|
158
123
|
if (!config) {
|
|
159
124
|
return <main className="wapp-auth-screen">Loading...</main>;
|
|
@@ -171,7 +136,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
171
136
|
const effectiveVersion = version ?? config.version;
|
|
172
137
|
let view: ReactNode;
|
|
173
138
|
if (route.view === "settings") {
|
|
174
|
-
view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []}
|
|
139
|
+
view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} />;
|
|
175
140
|
} else {
|
|
176
141
|
const registeredView = routes[route.view];
|
|
177
142
|
view = typeof registeredView === "function"
|
|
@@ -220,3 +185,20 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
220
185
|
/>
|
|
221
186
|
);
|
|
222
187
|
}
|
|
188
|
+
|
|
189
|
+
function WebAppRootWithConfig(props: WebAppRootProps) {
|
|
190
|
+
const { config, error, refresh } = useWebAppConfig();
|
|
191
|
+
return (
|
|
192
|
+
<ThemeProvider userId={config?.currentUser?.id}>
|
|
193
|
+
<WebAppRootContent {...props} config={config} error={error} refresh={refresh} />
|
|
194
|
+
</ThemeProvider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function WebAppRoot(props: WebAppRootProps) {
|
|
199
|
+
return (
|
|
200
|
+
<WebAppConfigProvider>
|
|
201
|
+
<WebAppRootWithConfig {...props} />
|
|
202
|
+
</WebAppConfigProvider>
|
|
203
|
+
);
|
|
204
|
+
}
|
package/src/web/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ export * from "./render";
|
|
|
4
4
|
export * from "./api-client";
|
|
5
5
|
export * from "./sidebar/types";
|
|
6
6
|
export * from "./realtime/useRealtime";
|
|
7
|
+
export { useToast } from "./toast";
|
|
8
|
+
export type { Toast, ToastId, ToastOptions, ToastService, ToastShowOptions, ToastVariant } from "./toast";
|
|
7
9
|
export { MOBILE_BREAKPOINT_PX, MOBILE_MEDIA_QUERY, MOBILE_STATE_ATTRIBUTE } from "./mobile";
|
|
10
|
+
export { useTheme } from "./theme";
|
|
11
|
+
export type { ResolvedTheme, WebAppThemeState } from "./theme";
|
|
12
|
+
export { useLogLevel } from "./log-level";
|
|
13
|
+
export type { WebAppLogLevelState } from "./log-level";
|
|
8
14
|
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
9
15
|
export type { ThemePreference } from "../contracts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { LogLevelName } from "../contracts";
|
|
3
|
+
import { useWebAppConfig } from "./webapp-config";
|
|
4
|
+
|
|
5
|
+
export interface WebAppLogLevelState {
|
|
6
|
+
level?: LogLevelName;
|
|
7
|
+
fromEnv?: boolean;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error?: Error;
|
|
10
|
+
retry: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useLogLevel(): WebAppLogLevelState {
|
|
14
|
+
const { config, loading, error, refresh } = useWebAppConfig("useLogLevel");
|
|
15
|
+
return useMemo(() => ({
|
|
16
|
+
level: config?.logLevel.level,
|
|
17
|
+
fromEnv: config?.logLevel.fromEnv,
|
|
18
|
+
loading,
|
|
19
|
+
error,
|
|
20
|
+
retry: refresh,
|
|
21
|
+
}), [config?.logLevel.fromEnv, config?.logLevel.level, error, loading, refresh]);
|
|
22
|
+
}
|
package/src/web/render.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { Root } from "react-dom/client";
|
|
3
|
+
import { ToastProvider } from "./toast";
|
|
3
4
|
|
|
4
5
|
declare global {
|
|
5
6
|
var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
|
|
@@ -11,6 +12,10 @@ function roots(): WeakMap<Element, Root> {
|
|
|
11
12
|
return globalThis.__pablozaidenWebAppRoots;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function WebAppRuntime({ element }: { element: ReactNode }) {
|
|
16
|
+
return <ToastProvider>{element}</ToastProvider>;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export function configureWebAppRenderer(createRoot: (target: Element) => Root): void {
|
|
15
20
|
globalThis.__pablozaidenCreateWebAppRoot = createRoot;
|
|
16
21
|
}
|
|
@@ -30,6 +35,6 @@ export function renderWebApp(element: ReactNode, container: Element | string = "
|
|
|
30
35
|
root = createRoot(target);
|
|
31
36
|
registry.set(target, root);
|
|
32
37
|
}
|
|
33
|
-
root.render(element);
|
|
38
|
+
root.render(<WebAppRuntime element={element} />);
|
|
34
39
|
return root;
|
|
35
40
|
}
|
|
@@ -1,19 +1,44 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
|
|
2
3
|
import { appJson } from "../api-client";
|
|
3
4
|
import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
5
|
+
import { useLogLevel } from "../log-level";
|
|
6
|
+
import { isThemePreference, useTheme } from "../theme";
|
|
7
|
+
|
|
8
|
+
const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
|
|
9
|
+
|
|
10
|
+
function isLogLevelName(value: string): value is LogLevelName {
|
|
11
|
+
return LOG_LEVEL_NAMES.some((level) => level === value);
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
export interface AccountSectionProps {
|
|
6
15
|
config: WebAppConfigResponse;
|
|
7
|
-
theme: ThemePreference;
|
|
8
|
-
setTheme: (theme: ThemePreference) => void;
|
|
9
|
-
themeLoading: boolean;
|
|
10
|
-
themeLoadError?: Error;
|
|
11
|
-
retryThemeLoad: () => Promise<void>;
|
|
12
16
|
refresh: () => Promise<void>;
|
|
13
17
|
setError: (error: string | undefined) => void;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
export function AccountSection({ config,
|
|
20
|
+
export function AccountSection({ config, refresh, setError }: AccountSectionProps) {
|
|
21
|
+
const { preference, setPreference, loading, error, retry } = useTheme();
|
|
22
|
+
const logLevel = useLogLevel();
|
|
23
|
+
const [logLevelSaving, setLogLevelSaving] = useState(false);
|
|
24
|
+
|
|
25
|
+
async function updateLogLevel(value: string) {
|
|
26
|
+
if (!isLogLevelName(value)) {
|
|
27
|
+
setError(`Unknown log level: ${value}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
setError(undefined);
|
|
32
|
+
setLogLevelSaving(true);
|
|
33
|
+
await appJson<unknown>("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: value }) });
|
|
34
|
+
await refresh();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
37
|
+
} finally {
|
|
38
|
+
setLogLevelSaving(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
return (
|
|
18
43
|
<>
|
|
19
44
|
{config.currentUser ? (
|
|
@@ -22,29 +47,47 @@ export function AccountSection({ config, theme, setTheme, themeLoading, themeLoa
|
|
|
22
47
|
</FormSection>
|
|
23
48
|
) : null}
|
|
24
49
|
<FormSection title="Display Settings">
|
|
25
|
-
<SelectField label="Theme" value={
|
|
26
|
-
const next = event.currentTarget.value
|
|
27
|
-
|
|
50
|
+
<SelectField label="Theme" value={preference} onChange={(event) => {
|
|
51
|
+
const next = event.currentTarget.value;
|
|
52
|
+
if (!isThemePreference(next)) {
|
|
53
|
+
setError(`Unknown theme preference: ${next}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setPreference(next);
|
|
28
57
|
void appJson("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
|
|
29
58
|
}}>
|
|
30
59
|
<option value="system">System</option>
|
|
31
60
|
<option value="light">Light</option>
|
|
32
61
|
<option value="dark">Dark</option>
|
|
33
62
|
</SelectField>
|
|
34
|
-
{
|
|
35
|
-
{
|
|
63
|
+
{loading ? <LoadingState title="Loading saved theme" /> : null}
|
|
64
|
+
{error ? (
|
|
36
65
|
<ErrorState
|
|
37
|
-
description={
|
|
38
|
-
action={<Button type="button" loading={
|
|
66
|
+
description={error.message}
|
|
67
|
+
action={<Button type="button" loading={loading} onClick={() => void retry()}>Retry</Button>}
|
|
39
68
|
/>
|
|
40
69
|
) : null}
|
|
41
70
|
</FormSection>
|
|
42
71
|
|
|
43
72
|
{config.currentUser?.isAdmin ? (
|
|
44
73
|
<FormSection title="Developer Settings">
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
{logLevel.error ? (
|
|
75
|
+
<ErrorState
|
|
76
|
+
description={logLevel.error.message}
|
|
77
|
+
action={<Button type="button" loading={logLevel.loading} onClick={() => void logLevel.retry()}>Retry</Button>}
|
|
78
|
+
/>
|
|
79
|
+
) : null}
|
|
80
|
+
{logLevel.loading && logLevel.level === undefined ? <LoadingState title="Loading log level" /> : null}
|
|
81
|
+
{logLevel.level !== undefined ? (
|
|
82
|
+
<SelectField
|
|
83
|
+
label={logLevel.fromEnv ? `Log level (${logLevel.level}, controlled by env)` : "Log level"}
|
|
84
|
+
value={logLevel.level}
|
|
85
|
+
disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
|
|
86
|
+
onChange={(event) => void updateLogLevel(event.currentTarget.value)}
|
|
87
|
+
>
|
|
88
|
+
{LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
|
|
89
|
+
</SelectField>
|
|
90
|
+
) : null}
|
|
48
91
|
</FormSection>
|
|
49
92
|
) : null}
|
|
50
93
|
</>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { WebAppConfigResponse } from "../../contracts";
|
|
3
3
|
import { Button, DangerZone, FormSection } from "../components";
|
|
4
4
|
import type { SettingsRow, SettingsScope, SettingsSection } from "../root-types";
|
|
5
5
|
import { AccountSection } from "./account-section";
|
|
@@ -64,20 +64,15 @@ export interface SettingsViewProps {
|
|
|
64
64
|
config: WebAppConfigResponse;
|
|
65
65
|
refresh: () => Promise<void>;
|
|
66
66
|
customSections: SettingsSection[];
|
|
67
|
-
theme: ThemePreference;
|
|
68
|
-
setTheme: (theme: ThemePreference) => void;
|
|
69
|
-
themeLoading: boolean;
|
|
70
|
-
themeLoadError?: Error;
|
|
71
|
-
retryThemeLoad: () => Promise<void>;
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
export function SettingsView({ config, refresh, customSections
|
|
69
|
+
export function SettingsView({ config, refresh, customSections }: SettingsViewProps) {
|
|
75
70
|
const [error, setError] = useState<string>();
|
|
76
71
|
|
|
77
72
|
return (
|
|
78
73
|
<div className="wapp-settings">
|
|
79
74
|
{error ? <p className="wapp-error">{error}</p> : null}
|
|
80
|
-
<AccountSection config={config}
|
|
75
|
+
<AccountSection config={config} refresh={refresh} setError={setError} />
|
|
81
76
|
<FormSection title="Security">
|
|
82
77
|
<SecuritySection config={config} refresh={refresh} setError={setError} />
|
|
83
78
|
<SessionsSection config={config} setError={setError} />
|
package/src/web/styles.css
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
--wapp-muted-2: #64748b;
|
|
12
12
|
--wapp-danger: #991b1b;
|
|
13
13
|
--wapp-danger-bg: #991b1b;
|
|
14
|
+
--wapp-toast-success: #166534;
|
|
15
|
+
--wapp-toast-success-surface: #f0fdf4;
|
|
16
|
+
--wapp-toast-success-border: #86efac;
|
|
17
|
+
--wapp-toast-error: #991b1b;
|
|
18
|
+
--wapp-toast-error-surface: #fef2f2;
|
|
19
|
+
--wapp-toast-error-border: #fca5a5;
|
|
20
|
+
--wapp-toast-warning: #92400e;
|
|
21
|
+
--wapp-toast-warning-surface: #fffbeb;
|
|
22
|
+
--wapp-toast-warning-border: #fcd34d;
|
|
23
|
+
--wapp-toast-info: #1e40af;
|
|
24
|
+
--wapp-toast-info-surface: #eff6ff;
|
|
25
|
+
--wapp-toast-info-border: #93c5fd;
|
|
14
26
|
--wapp-primary: #111827;
|
|
15
27
|
--wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
16
28
|
--wapp-overlay-bg: rgb(0 0 0 / 0.5);
|
|
@@ -49,6 +61,18 @@
|
|
|
49
61
|
--wapp-muted-2: #71717a;
|
|
50
62
|
--wapp-danger: #fca5a5;
|
|
51
63
|
--wapp-danger-bg: #7f1d1d;
|
|
64
|
+
--wapp-toast-success: #bbf7d0;
|
|
65
|
+
--wapp-toast-success-surface: #14532d;
|
|
66
|
+
--wapp-toast-success-border: #166534;
|
|
67
|
+
--wapp-toast-error: #fecaca;
|
|
68
|
+
--wapp-toast-error-surface: #7f1d1d;
|
|
69
|
+
--wapp-toast-error-border: #991b1b;
|
|
70
|
+
--wapp-toast-warning: #fde68a;
|
|
71
|
+
--wapp-toast-warning-surface: #78350f;
|
|
72
|
+
--wapp-toast-warning-border: #92400e;
|
|
73
|
+
--wapp-toast-info: #bfdbfe;
|
|
74
|
+
--wapp-toast-info-surface: #1e3a8a;
|
|
75
|
+
--wapp-toast-info-border: #1e40af;
|
|
52
76
|
--wapp-primary: #f9fafb;
|
|
53
77
|
--wapp-shadow: none;
|
|
54
78
|
}
|
|
@@ -1453,6 +1477,110 @@ textarea::placeholder {
|
|
|
1453
1477
|
color: var(--wapp-danger);
|
|
1454
1478
|
}
|
|
1455
1479
|
|
|
1480
|
+
.wapp-toast-viewport {
|
|
1481
|
+
position: fixed;
|
|
1482
|
+
right: max(1rem, var(--wapp-safe-area-right));
|
|
1483
|
+
bottom: max(1rem, var(--wapp-safe-area-bottom));
|
|
1484
|
+
z-index: 70;
|
|
1485
|
+
display: flex;
|
|
1486
|
+
flex-direction: column;
|
|
1487
|
+
align-items: stretch;
|
|
1488
|
+
gap: 0.5rem;
|
|
1489
|
+
width: min(24rem, calc(100vw - 2rem));
|
|
1490
|
+
max-height: calc(var(--wapp-viewport-height) - 2rem);
|
|
1491
|
+
overflow-y: auto;
|
|
1492
|
+
padding: 0.25rem;
|
|
1493
|
+
pointer-events: none;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
.wapp-toast {
|
|
1497
|
+
display: flex;
|
|
1498
|
+
align-items: flex-start;
|
|
1499
|
+
gap: 0.75rem;
|
|
1500
|
+
min-width: 0;
|
|
1501
|
+
border: 1px solid var(--wapp-border);
|
|
1502
|
+
border-radius: 0.75rem;
|
|
1503
|
+
background: var(--wapp-surface);
|
|
1504
|
+
color: var(--wapp-text);
|
|
1505
|
+
padding: 0.75rem;
|
|
1506
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 0.14);
|
|
1507
|
+
pointer-events: auto;
|
|
1508
|
+
animation: wapp-toast-enter 160ms ease-out;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.wapp-toast-success {
|
|
1512
|
+
border-color: var(--wapp-toast-success-border);
|
|
1513
|
+
background: var(--wapp-toast-success-surface);
|
|
1514
|
+
color: var(--wapp-toast-success);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
.wapp-toast-error {
|
|
1518
|
+
border-color: var(--wapp-toast-error-border);
|
|
1519
|
+
background: var(--wapp-toast-error-surface);
|
|
1520
|
+
color: var(--wapp-toast-error);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
.wapp-toast-warning {
|
|
1524
|
+
border-color: var(--wapp-toast-warning-border);
|
|
1525
|
+
background: var(--wapp-toast-warning-surface);
|
|
1526
|
+
color: var(--wapp-toast-warning);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
.wapp-toast-info {
|
|
1530
|
+
border-color: var(--wapp-toast-info-border);
|
|
1531
|
+
background: var(--wapp-toast-info-surface);
|
|
1532
|
+
color: var(--wapp-toast-info);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.wapp-toast-message {
|
|
1536
|
+
min-width: 0;
|
|
1537
|
+
flex: 1 1 auto;
|
|
1538
|
+
overflow-wrap: anywhere;
|
|
1539
|
+
color: inherit;
|
|
1540
|
+
font-size: 0.875rem;
|
|
1541
|
+
line-height: 1.35;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
.wapp-toast-dismiss {
|
|
1545
|
+
display: inline-flex;
|
|
1546
|
+
flex: 0 0 1.75rem;
|
|
1547
|
+
align-items: center;
|
|
1548
|
+
justify-content: center;
|
|
1549
|
+
width: 1.75rem;
|
|
1550
|
+
height: 1.75rem;
|
|
1551
|
+
border: 0;
|
|
1552
|
+
border-radius: 0.5rem;
|
|
1553
|
+
background: transparent;
|
|
1554
|
+
color: inherit;
|
|
1555
|
+
cursor: pointer;
|
|
1556
|
+
font-size: 1.25rem;
|
|
1557
|
+
line-height: 1;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
.wapp-toast-dismiss:hover,
|
|
1561
|
+
.wapp-toast-dismiss:focus-visible {
|
|
1562
|
+
background: rgb(0 0 0 / 0.1);
|
|
1563
|
+
outline: none;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
@keyframes wapp-toast-enter {
|
|
1567
|
+
from {
|
|
1568
|
+
opacity: 0;
|
|
1569
|
+
transform: translateY(0.5rem);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
to {
|
|
1573
|
+
opacity: 1;
|
|
1574
|
+
transform: translateY(0);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1579
|
+
.wapp-toast {
|
|
1580
|
+
animation: none;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1456
1584
|
.wapp-notice {
|
|
1457
1585
|
color: var(--wapp-muted);
|
|
1458
1586
|
}
|
|
@@ -1961,6 +2089,14 @@ textarea::placeholder {
|
|
|
1961
2089
|
margin-top: 0.75rem;
|
|
1962
2090
|
}
|
|
1963
2091
|
|
|
2092
|
+
:root[data-wapp-mobile] .wapp-toast-viewport {
|
|
2093
|
+
right: max(0.75rem, var(--wapp-safe-area-right));
|
|
2094
|
+
bottom: max(0.75rem, var(--wapp-safe-area-bottom));
|
|
2095
|
+
left: max(0.75rem, var(--wapp-safe-area-left));
|
|
2096
|
+
width: auto;
|
|
2097
|
+
max-height: calc(var(--wapp-viewport-height) - 1.5rem);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1964
2100
|
@media (max-width: 640px) {
|
|
1965
2101
|
.wapp-settings .wapp-list-row {
|
|
1966
2102
|
display: grid;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import type { ThemePreference } from "../contracts";
|
|
3
|
+
import { appJson } from "./api-client";
|
|
4
|
+
|
|
5
|
+
const THEME_STORAGE_KEY = "webapp.theme";
|
|
6
|
+
const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
|
7
|
+
|
|
8
|
+
export type ResolvedTheme = "light" | "dark";
|
|
9
|
+
|
|
10
|
+
export interface WebAppThemeState {
|
|
11
|
+
preference: ThemePreference;
|
|
12
|
+
resolvedTheme: ResolvedTheme;
|
|
13
|
+
setPreference: (preference: ThemePreference) => void;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error?: Error;
|
|
16
|
+
retry: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ThemeContext = createContext<WebAppThemeState | null>(null);
|
|
20
|
+
|
|
21
|
+
export function isThemePreference(value: unknown): value is ThemePreference {
|
|
22
|
+
return value === "system" || value === "light" || value === "dark";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readStoredPreference(): ThemePreference {
|
|
30
|
+
if (typeof window === "undefined") {
|
|
31
|
+
return "system";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
35
|
+
return isThemePreference(stored) ? stored : "system";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readSystemTheme(): ResolvedTheme {
|
|
39
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
40
|
+
return "light";
|
|
41
|
+
}
|
|
42
|
+
return window.matchMedia(THEME_MEDIA_QUERY).matches ? "dark" : "light";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseThemeResponse(value: unknown): ThemePreference {
|
|
46
|
+
if (!isRecord(value) || !isThemePreference(value["theme"])) {
|
|
47
|
+
throw new Error("Theme preference response was invalid.");
|
|
48
|
+
}
|
|
49
|
+
return value["theme"];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toError(value: unknown): Error {
|
|
53
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useTheme(): WebAppThemeState {
|
|
57
|
+
const context = useContext(ThemeContext);
|
|
58
|
+
if (!context) {
|
|
59
|
+
throw new Error("useTheme must be used within the framework WebAppRoot.");
|
|
60
|
+
}
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ThemeProvider({ userId, children }: { userId?: string; children: ReactNode }) {
|
|
65
|
+
const [preference, setPreferenceState] = useState<ThemePreference>(readStoredPreference);
|
|
66
|
+
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [error, setError] = useState<Error>();
|
|
69
|
+
const requestIdRef = useRef(0);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const query = window.matchMedia(THEME_MEDIA_QUERY);
|
|
77
|
+
const sync = () => setSystemTheme(query.matches ? "dark" : "light");
|
|
78
|
+
sync();
|
|
79
|
+
query.addEventListener("change", sync);
|
|
80
|
+
return () => query.removeEventListener("change", sync);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
|
84
|
+
if (!isThemePreference(nextPreference)) {
|
|
85
|
+
throw new TypeError(`Unknown theme preference: ${String(nextPreference)}.`);
|
|
86
|
+
}
|
|
87
|
+
setPreferenceState(nextPreference);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const resolvedTheme = preference === "system" ? systemTheme : preference;
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (typeof document === "undefined") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const root = document.documentElement;
|
|
98
|
+
root.classList.toggle("dark", resolvedTheme === "dark");
|
|
99
|
+
root.style.colorScheme = resolvedTheme;
|
|
100
|
+
root.dataset["theme"] = preference;
|
|
101
|
+
root.dataset["resolvedTheme"] = resolvedTheme;
|
|
102
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, preference);
|
|
103
|
+
}, [preference, resolvedTheme]);
|
|
104
|
+
|
|
105
|
+
const retry = useCallback(async () => {
|
|
106
|
+
const requestId = requestIdRef.current + 1;
|
|
107
|
+
requestIdRef.current = requestId;
|
|
108
|
+
if (!userId) {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
setError(undefined);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setLoading(true);
|
|
115
|
+
setError(undefined);
|
|
116
|
+
try {
|
|
117
|
+
const response = await appJson<unknown>("/api/preferences/theme");
|
|
118
|
+
if (requestId !== requestIdRef.current) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setPreference(parseThemeResponse(response));
|
|
122
|
+
} catch (value) {
|
|
123
|
+
if (requestId === requestIdRef.current) {
|
|
124
|
+
setError(toError(value));
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
if (requestId === requestIdRef.current) {
|
|
128
|
+
setLoading(false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, [setPreference, userId]);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
void retry();
|
|
135
|
+
}, [retry]);
|
|
136
|
+
|
|
137
|
+
const state = useMemo<WebAppThemeState>(() => ({
|
|
138
|
+
preference,
|
|
139
|
+
resolvedTheme,
|
|
140
|
+
setPreference,
|
|
141
|
+
loading,
|
|
142
|
+
error,
|
|
143
|
+
retry,
|
|
144
|
+
}), [error, loading, preference, resolvedTheme, retry, setPreference]);
|
|
145
|
+
|
|
146
|
+
return <ThemeContext.Provider value={state}>{children}</ThemeContext.Provider>;
|
|
147
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
export type ToastVariant = "success" | "error" | "warning" | "info";
|
|
5
|
+
export type ToastId = string;
|
|
6
|
+
|
|
7
|
+
export interface Toast {
|
|
8
|
+
id: ToastId;
|
|
9
|
+
message: string;
|
|
10
|
+
variant: ToastVariant;
|
|
11
|
+
duration: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ToastOptions {
|
|
15
|
+
id?: ToastId;
|
|
16
|
+
duration?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToastShowOptions extends ToastOptions {
|
|
20
|
+
variant?: ToastVariant;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ToastService {
|
|
24
|
+
toasts: readonly Toast[];
|
|
25
|
+
show: (message: string, options?: ToastShowOptions) => ToastId;
|
|
26
|
+
success: (message: string, options?: ToastOptions) => ToastId;
|
|
27
|
+
error: (message: string, options?: ToastOptions) => ToastId;
|
|
28
|
+
warning: (message: string, options?: ToastOptions) => ToastId;
|
|
29
|
+
info: (message: string, options?: ToastOptions) => ToastId;
|
|
30
|
+
dismiss: (id: ToastId) => void;
|
|
31
|
+
dismissAll: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TOAST_DURATION_MS = 8_000;
|
|
35
|
+
const MAX_TOASTS = 5;
|
|
36
|
+
|
|
37
|
+
const ToastContext = createContext<ToastService | null>(null);
|
|
38
|
+
|
|
39
|
+
function isToastVariant(value: unknown): value is ToastVariant {
|
|
40
|
+
return value === "success" || value === "error" || value === "warning" || value === "info";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeMessage(message: string): string {
|
|
44
|
+
if (typeof message !== "string") {
|
|
45
|
+
throw new TypeError("Toast messages must be strings.");
|
|
46
|
+
}
|
|
47
|
+
return message;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeId(id: ToastId | undefined, nextId: () => ToastId): ToastId {
|
|
51
|
+
if (id === undefined) {
|
|
52
|
+
return nextId();
|
|
53
|
+
}
|
|
54
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
55
|
+
throw new TypeError("Toast IDs must be non-empty strings.");
|
|
56
|
+
}
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeDuration(duration: number | undefined): number {
|
|
61
|
+
if (duration === undefined) {
|
|
62
|
+
return DEFAULT_TOAST_DURATION_MS;
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
65
|
+
throw new RangeError("Toast duration must be a finite, non-negative number.");
|
|
66
|
+
}
|
|
67
|
+
return duration;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: ToastId) => void }) {
|
|
71
|
+
if (toasts.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return createPortal(
|
|
76
|
+
<div className="wapp-toast-viewport" role="region" aria-label="Notifications">
|
|
77
|
+
{toasts.map((toast) => {
|
|
78
|
+
const isError = toast.variant === "error";
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
key={toast.id}
|
|
82
|
+
className={`wapp-toast wapp-toast-${toast.variant}`}
|
|
83
|
+
data-toast-variant={toast.variant}
|
|
84
|
+
role={isError ? "alert" : "status"}
|
|
85
|
+
aria-live={isError ? "assertive" : "polite"}
|
|
86
|
+
aria-atomic="true"
|
|
87
|
+
>
|
|
88
|
+
<span className="wapp-toast-message">{toast.message}</span>
|
|
89
|
+
<button type="button" className="wapp-toast-dismiss" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
|
|
90
|
+
×
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>,
|
|
96
|
+
document.body,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function useToast(): ToastService {
|
|
101
|
+
const context = useContext(ToastContext);
|
|
102
|
+
if (!context) {
|
|
103
|
+
throw new Error("useToast must be used within the framework webapp runtime.");
|
|
104
|
+
}
|
|
105
|
+
return context;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
109
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
110
|
+
const timersRef = useRef(new Map<ToastId, ReturnType<typeof setTimeout>>());
|
|
111
|
+
const nextIdRef = useRef(0);
|
|
112
|
+
|
|
113
|
+
const nextId = useCallback((): ToastId => {
|
|
114
|
+
nextIdRef.current += 1;
|
|
115
|
+
return `toast-${nextIdRef.current}`;
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const clearTimer = useCallback((id: ToastId) => {
|
|
119
|
+
const timer = timersRef.current.get(id);
|
|
120
|
+
if (timer === undefined) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
timersRef.current.delete(id);
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const dismiss = useCallback((id: ToastId) => {
|
|
128
|
+
clearTimer(id);
|
|
129
|
+
setToasts((current) => current.filter((toast) => toast.id !== id));
|
|
130
|
+
}, [clearTimer]);
|
|
131
|
+
|
|
132
|
+
const dismissAll = useCallback(() => {
|
|
133
|
+
for (const id of Array.from(timersRef.current.keys())) {
|
|
134
|
+
clearTimer(id);
|
|
135
|
+
}
|
|
136
|
+
setToasts([]);
|
|
137
|
+
}, [clearTimer]);
|
|
138
|
+
|
|
139
|
+
const scheduleDismiss = useCallback((toast: Toast) => {
|
|
140
|
+
clearTimer(toast.id);
|
|
141
|
+
if (toast.duration === 0) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
if (timersRef.current.get(toast.id) !== timer) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
timersRef.current.delete(toast.id);
|
|
150
|
+
setToasts((current) => current.filter((item) => item.id !== toast.id));
|
|
151
|
+
}, toast.duration);
|
|
152
|
+
timersRef.current.set(toast.id, timer);
|
|
153
|
+
}, [clearTimer]);
|
|
154
|
+
|
|
155
|
+
const show = useCallback((message: string, options?: ToastShowOptions): ToastId => {
|
|
156
|
+
const normalizedMessage = normalizeMessage(message);
|
|
157
|
+
const variant = options?.variant ?? "info";
|
|
158
|
+
if (!isToastVariant(variant)) {
|
|
159
|
+
throw new TypeError(`Unknown toast variant: ${String(variant)}.`);
|
|
160
|
+
}
|
|
161
|
+
const id = normalizeId(options?.id, nextId);
|
|
162
|
+
const duration = normalizeDuration(options?.duration);
|
|
163
|
+
const toast: Toast = { id, message: normalizedMessage, variant, duration };
|
|
164
|
+
|
|
165
|
+
clearTimer(id);
|
|
166
|
+
setToasts((current) => {
|
|
167
|
+
const existingIndex = current.findIndex((item) => item.id === id);
|
|
168
|
+
if (existingIndex >= 0) {
|
|
169
|
+
const next = [...current];
|
|
170
|
+
next[existingIndex] = toast;
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
const next = [...current, toast];
|
|
174
|
+
return next.length > MAX_TOASTS ? next.slice(next.length - MAX_TOASTS) : next;
|
|
175
|
+
});
|
|
176
|
+
scheduleDismiss(toast);
|
|
177
|
+
return id;
|
|
178
|
+
}, [clearTimer, nextId, scheduleDismiss]);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const activeIds = new Set(toasts.map((toast) => toast.id));
|
|
182
|
+
for (const id of timersRef.current.keys()) {
|
|
183
|
+
if (activeIds.has(id)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
clearTimer(id);
|
|
187
|
+
}
|
|
188
|
+
}, [clearTimer, toasts]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => () => {
|
|
191
|
+
for (const timer of timersRef.current.values()) {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
}
|
|
194
|
+
timersRef.current.clear();
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
const success = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "success" }), [show]);
|
|
198
|
+
const error = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "error" }), [show]);
|
|
199
|
+
const warning = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "warning" }), [show]);
|
|
200
|
+
const info = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "info" }), [show]);
|
|
201
|
+
const service = useMemo<ToastService>(() => ({
|
|
202
|
+
toasts,
|
|
203
|
+
show,
|
|
204
|
+
success,
|
|
205
|
+
error,
|
|
206
|
+
warning,
|
|
207
|
+
info,
|
|
208
|
+
dismiss,
|
|
209
|
+
dismissAll,
|
|
210
|
+
}), [dismiss, dismissAll, error, info, show, success, toasts, warning]);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<ToastContext.Provider value={service}>
|
|
214
|
+
{children}
|
|
215
|
+
<ToastViewport toasts={toasts} onDismiss={dismiss} />
|
|
216
|
+
</ToastContext.Provider>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import type { WebAppConfigResponse } from "../contracts";
|
|
3
|
+
import { appJson } from "./api-client";
|
|
4
|
+
|
|
5
|
+
export interface WebAppConfigState {
|
|
6
|
+
config?: WebAppConfigResponse;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error?: Error;
|
|
9
|
+
refresh: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const WebAppConfigContext = createContext<WebAppConfigState | null>(null);
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLogLevelName(value: unknown): boolean {
|
|
19
|
+
return value === "trace" || value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isCurrentUser(value: unknown): boolean {
|
|
23
|
+
return isRecord(value)
|
|
24
|
+
&& typeof value["id"] === "string"
|
|
25
|
+
&& typeof value["username"] === "string"
|
|
26
|
+
&& (value["role"] === "owner" || value["role"] === "admin" || value["role"] === "user")
|
|
27
|
+
&& typeof value["isOwner"] === "boolean"
|
|
28
|
+
&& typeof value["isAdmin"] === "boolean";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasBooleanFields(value: unknown, fields: readonly string[]): boolean {
|
|
32
|
+
return isRecord(value) && fields.every((field) => typeof value[field] === "boolean");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
|
|
36
|
+
return isRecord(value)
|
|
37
|
+
&& typeof value["appName"] === "string"
|
|
38
|
+
&& typeof value["version"] === "string"
|
|
39
|
+
&& (value["currentUser"] === undefined || isCurrentUser(value["currentUser"]))
|
|
40
|
+
&& hasBooleanFields(value["passkeyAuth"], [
|
|
41
|
+
"enabled",
|
|
42
|
+
"passkeyConfigured",
|
|
43
|
+
"passkeyDisabled",
|
|
44
|
+
"passkeyRequired",
|
|
45
|
+
"authenticated",
|
|
46
|
+
"bootstrapRequired",
|
|
47
|
+
"ownerPasskeySetupRequired",
|
|
48
|
+
])
|
|
49
|
+
&& hasBooleanFields(value["userManagement"], ["enabled", "canManageUsers"])
|
|
50
|
+
&& isRecord(value["logLevel"])
|
|
51
|
+
&& isLogLevelName(value["logLevel"]["level"])
|
|
52
|
+
&& typeof value["logLevel"]["fromEnv"] === "boolean"
|
|
53
|
+
&& hasBooleanFields(value["deviceAuth"], ["enabled"])
|
|
54
|
+
&& hasBooleanFields(value["apiKeys"], ["enabled"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseWebAppConfigResponse(value: unknown): WebAppConfigResponse {
|
|
58
|
+
if (!isWebAppConfigResponse(value)) {
|
|
59
|
+
throw new Error("Web app configuration response was invalid.");
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toError(value: unknown): Error {
|
|
65
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useWebAppConfig(consumer = "useWebAppConfig"): WebAppConfigState {
|
|
69
|
+
const context = useContext(WebAppConfigContext);
|
|
70
|
+
if (!context) {
|
|
71
|
+
throw new Error(`${consumer} must be used within the framework WebAppRoot.`);
|
|
72
|
+
}
|
|
73
|
+
return context;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function WebAppConfigProvider({ children }: { children: ReactNode }) {
|
|
77
|
+
const [config, setConfig] = useState<WebAppConfigResponse>();
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [error, setError] = useState<Error>();
|
|
80
|
+
const requestIdRef = useRef(0);
|
|
81
|
+
|
|
82
|
+
const refresh = useCallback(async () => {
|
|
83
|
+
const requestId = requestIdRef.current + 1;
|
|
84
|
+
requestIdRef.current = requestId;
|
|
85
|
+
setLoading(true);
|
|
86
|
+
setError(undefined);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = parseWebAppConfigResponse(await appJson<unknown>("/api/config"));
|
|
90
|
+
if (requestId !== requestIdRef.current) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setConfig(response);
|
|
94
|
+
} catch (value) {
|
|
95
|
+
if (requestId === requestIdRef.current) {
|
|
96
|
+
setError(toError(value));
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
if (requestId === requestIdRef.current) {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
void refresh();
|
|
107
|
+
}, [refresh]);
|
|
108
|
+
|
|
109
|
+
const state = useMemo<WebAppConfigState>(() => ({
|
|
110
|
+
config,
|
|
111
|
+
loading,
|
|
112
|
+
error,
|
|
113
|
+
refresh,
|
|
114
|
+
}), [config, error, loading, refresh]);
|
|
115
|
+
|
|
116
|
+
return <WebAppConfigContext.Provider value={state}>{children}</WebAppConfigContext.Provider>;
|
|
117
|
+
}
|