@orthacms/bootstrap-admin 0.0.0-reserve.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/AppErrorBoundary/index.d.ts +40 -0
- package/dist/lib/AppErrorBoundary/index.d.ts.map +1 -0
- package/dist/lib/AppErrorBoundary/index.js +91 -0
- package/dist/lib/DesignSystemLabels/index.d.ts +19 -0
- package/dist/lib/DesignSystemLabels/index.d.ts.map +1 -0
- package/dist/lib/DesignSystemLabels/index.js +32 -0
- package/dist/lib/RouteAnnouncer/index.d.ts +38 -0
- package/dist/lib/RouteAnnouncer/index.d.ts.map +1 -0
- package/dist/lib/RouteAnnouncer/index.js +125 -0
- package/dist/lib/UnsavedChangesGuard/index.d.ts +13 -0
- package/dist/lib/UnsavedChangesGuard/index.d.ts.map +1 -0
- package/dist/lib/UnsavedChangesGuard/index.js +34 -0
- package/dist/lib/createAdmin/index.d.ts +24 -0
- package/dist/lib/createAdmin/index.d.ts.map +1 -0
- package/dist/lib/createAdmin/index.js +209 -0
- package/dist/lib/types/adminPlugin/index.d.ts +66 -0
- package/dist/lib/types/adminPlugin/index.d.ts.map +1 -0
- package/dist/lib/types/adminPlugin/index.js +1 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ortha CMS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EACR,WAAW,EACX,SAAS,EACT,kBAAkB,EACrB,MAAM,yBAAyB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createAdmin } from './lib/createAdmin';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
/** Props for {@link AppErrorBoundary}. */
|
|
3
|
+
type AppErrorBoundaryProps = {
|
|
4
|
+
/** The routed application tree being isolated. */
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
type AppErrorBoundaryState = {
|
|
8
|
+
failed: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The host's last line of defence: catches a render-phase throw from anywhere
|
|
12
|
+
* in the routed tree.
|
|
13
|
+
*
|
|
14
|
+
* Without it a single misbehaving plugin takes the whole product with it. React
|
|
15
|
+
* unmounts the entire tree when a throw reaches the root, so `#root` is left
|
|
16
|
+
* with **zero children** — no sidebar to navigate away with, no message, nothing
|
|
17
|
+
* to focus, and no hint that reloading is the fix. The two realistic causes are
|
|
18
|
+
* a **lazy chunk that never arrives** (a deploy while the tab was open leaves
|
|
19
|
+
* the browser asking for content-hashed files that no longer exist; `Suspense`
|
|
20
|
+
* handles waiting, not failing, so it re-throws) and a page that reads an
|
|
21
|
+
* unexpected shape out of an API response.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately mounted **outside** `BrowserRouter` so a throw from the router
|
|
24
|
+
* itself is caught too, and outside the `Toaster` so notifications survive the
|
|
25
|
+
* failure. It is not a substitute for a narrower boundary: a plugin that can
|
|
26
|
+
* usefully degrade one region should catch there (as `insights-admin` does
|
|
27
|
+
* per-widget, and `identity-admin` does around the auth screens), because a
|
|
28
|
+
* boundary this high can only offer a reload.
|
|
29
|
+
*
|
|
30
|
+
* A class component because React still offers no hook equivalent of
|
|
31
|
+
* `componentDidCatch`, and a render-phase throw is exactly what needs catching.
|
|
32
|
+
*/
|
|
33
|
+
export declare class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
|
34
|
+
state: AppErrorBoundaryState;
|
|
35
|
+
static getDerivedStateFromError(): AppErrorBoundaryState;
|
|
36
|
+
componentDidCatch(error: Error, info: ErrorInfo): void;
|
|
37
|
+
render(): ReactNode;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/AppErrorBoundary/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EACH,SAAS,EAGT,KAAK,SAAS,EACd,KAAK,SAAS,EACjB,MAAM,OAAO,CAAC;AA8Ff,0CAA0C;AAC1C,KAAK,qBAAqB,GAAG;IACzB,kDAAkD;IAClD,QAAQ,EAAE,SAAS,CAAC;CACvB,CAAC;AAEF,KAAK,qBAAqB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,gBAAiB,SAAQ,SAAS,CAC3C,qBAAqB,EACrB,qBAAqB,CACxB;IACY,KAAK,EAAE,qBAAqB,CAAqB;IAE1D,MAAM,CAAC,wBAAwB,IAAI,qBAAqB;IAI/C,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAQtD,MAAM,IAAI,SAAS;CAK/B"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Component, useEffect, useRef } from 'react';
|
|
3
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
4
|
+
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Logo } from '@orthacms/design-system';
|
|
5
|
+
/** Intl descriptors for the fallback, co-located with it. */
|
|
6
|
+
const messages = defineMessages({
|
|
7
|
+
title: {
|
|
8
|
+
id: 'app.errorBoundary.title',
|
|
9
|
+
defaultMessage: 'Something went wrong'
|
|
10
|
+
},
|
|
11
|
+
description: {
|
|
12
|
+
id: 'app.errorBoundary.description',
|
|
13
|
+
defaultMessage: 'This page stopped working. Reloading usually fixes it — if it keeps happening, the last thing you did is worth reporting.'
|
|
14
|
+
},
|
|
15
|
+
reload: {
|
|
16
|
+
id: 'app.errorBoundary.reload',
|
|
17
|
+
defaultMessage: 'Reload the page'
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* The card shown when the routed tree throws.
|
|
22
|
+
*
|
|
23
|
+
* A function component because the boundary itself must be a class, and because
|
|
24
|
+
* the copy has to go through the host's single `IntlProvider` like every other
|
|
25
|
+
* string in the admin.
|
|
26
|
+
*
|
|
27
|
+
* It takes focus on mount. The failure replaces whatever the user was reading
|
|
28
|
+
* with no navigation the browser would report, so without this focus stays on a
|
|
29
|
+
* control that no longer exists and the browser resets it to `<body>`: a
|
|
30
|
+
* keyboard user tabs from the top of a page nobody told them they had reached,
|
|
31
|
+
* and a screen reader keeps reading a buffer of content that has been unmounted.
|
|
32
|
+
* The heading carries `tabIndex={-1}` so it can receive focus without joining
|
|
33
|
+
* the tab order.
|
|
34
|
+
*/
|
|
35
|
+
function AppCrashed() {
|
|
36
|
+
const intl = useIntl();
|
|
37
|
+
const headingRef = useRef(null);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
headingRef.current?.focus();
|
|
40
|
+
}, []);
|
|
41
|
+
return (_jsxs("div", { className: "flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6", children: [_jsx(Logo, {}), _jsxs(Card, { className: "w-full max-w-md", children: [_jsxs(CardHeader, { className: "text-center", children: [_jsx(CardTitle, { asChild: true, children: _jsx("h1", { ref: headingRef, tabIndex: -1,
|
|
42
|
+
// Programmatic focus on a heading is `:focus-visible`
|
|
43
|
+
// in Chrome, which draws a box around the page title
|
|
44
|
+
// as if it were something to interact with. Safe to
|
|
45
|
+
// suppress only because `tabIndex={-1}` keeps it out
|
|
46
|
+
// of the tab order, so nobody can navigate onto it
|
|
47
|
+
// and need the indicator.
|
|
48
|
+
className: "text-xl font-semibold outline-none", children: intl.formatMessage(messages.title) }) }), _jsx(CardDescription, { children: intl.formatMessage(messages.description) })] }), _jsx(CardContent, { children: _jsx(Button, { type: "button", className: "w-full",
|
|
49
|
+
// A failed lazy chunk is cached as failed by the module
|
|
50
|
+
// registry, and a render that threw on bad state will
|
|
51
|
+
// throw again — re-rendering cannot recover either, so
|
|
52
|
+
// only a fresh document request is offered.
|
|
53
|
+
onClick: () => window.location.reload(), children: intl.formatMessage(messages.reload) }) })] })] }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The host's last line of defence: catches a render-phase throw from anywhere
|
|
57
|
+
* in the routed tree.
|
|
58
|
+
*
|
|
59
|
+
* Without it a single misbehaving plugin takes the whole product with it. React
|
|
60
|
+
* unmounts the entire tree when a throw reaches the root, so `#root` is left
|
|
61
|
+
* with **zero children** — no sidebar to navigate away with, no message, nothing
|
|
62
|
+
* to focus, and no hint that reloading is the fix. The two realistic causes are
|
|
63
|
+
* a **lazy chunk that never arrives** (a deploy while the tab was open leaves
|
|
64
|
+
* the browser asking for content-hashed files that no longer exist; `Suspense`
|
|
65
|
+
* handles waiting, not failing, so it re-throws) and a page that reads an
|
|
66
|
+
* unexpected shape out of an API response.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately mounted **outside** `BrowserRouter` so a throw from the router
|
|
69
|
+
* itself is caught too, and outside the `Toaster` so notifications survive the
|
|
70
|
+
* failure. It is not a substitute for a narrower boundary: a plugin that can
|
|
71
|
+
* usefully degrade one region should catch there (as `insights-admin` does
|
|
72
|
+
* per-widget, and `identity-admin` does around the auth screens), because a
|
|
73
|
+
* boundary this high can only offer a reload.
|
|
74
|
+
*
|
|
75
|
+
* A class component because React still offers no hook equivalent of
|
|
76
|
+
* `componentDidCatch`, and a render-phase throw is exactly what needs catching.
|
|
77
|
+
*/
|
|
78
|
+
export class AppErrorBoundary extends Component {
|
|
79
|
+
state = { failed: false };
|
|
80
|
+
static getDerivedStateFromError() {
|
|
81
|
+
return { failed: true };
|
|
82
|
+
}
|
|
83
|
+
componentDidCatch(error, info) {
|
|
84
|
+
console.error('[bootstrap-admin] the application tree failed to render', error, info.componentStack);
|
|
85
|
+
}
|
|
86
|
+
render() {
|
|
87
|
+
if (!this.state.failed)
|
|
88
|
+
return this.props.children;
|
|
89
|
+
return _jsx(AppCrashed, {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Translates the design system's built-in chrome strings and hands them down
|
|
4
|
+
* through its labels context.
|
|
5
|
+
*
|
|
6
|
+
* The library ships English defaults and no i18n runtime — deliberately, it is
|
|
7
|
+
* consumed outside this app too. That left every dialog's and sheet's close
|
|
8
|
+
* button announcing "Close" in a German session, because localizing it meant
|
|
9
|
+
* passing `closeLabel` at each of twenty-odd call sites and no consumer did
|
|
10
|
+
* (`ORT-159`). Doing it once here means a new dialog is localized by existing,
|
|
11
|
+
* rather than by remembering.
|
|
12
|
+
*
|
|
13
|
+
* A call site that passes its own `closeLabel` still wins — this is the default
|
|
14
|
+
* underneath, not a ceiling.
|
|
15
|
+
*/
|
|
16
|
+
export declare function DesignSystemLabels({ children }: {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/DesignSystemLabels/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAYhD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAevE"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
4
|
+
import { DesignSystemLabelsProvider } from '@orthacms/design-system';
|
|
5
|
+
/** Intl descriptors for the design system's own chrome, co-located here. */
|
|
6
|
+
const messages = defineMessages({
|
|
7
|
+
close: {
|
|
8
|
+
id: 'designSystem.close',
|
|
9
|
+
defaultMessage: 'Close'
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Translates the design system's built-in chrome strings and hands them down
|
|
14
|
+
* through its labels context.
|
|
15
|
+
*
|
|
16
|
+
* The library ships English defaults and no i18n runtime — deliberately, it is
|
|
17
|
+
* consumed outside this app too. That left every dialog's and sheet's close
|
|
18
|
+
* button announcing "Close" in a German session, because localizing it meant
|
|
19
|
+
* passing `closeLabel` at each of twenty-odd call sites and no consumer did
|
|
20
|
+
* (`ORT-159`). Doing it once here means a new dialog is localized by existing,
|
|
21
|
+
* rather than by remembering.
|
|
22
|
+
*
|
|
23
|
+
* A call site that passes its own `closeLabel` still wins — this is the default
|
|
24
|
+
* underneath, not a ceiling.
|
|
25
|
+
*/
|
|
26
|
+
export function DesignSystemLabels({ children }) {
|
|
27
|
+
const intl = useIntl();
|
|
28
|
+
// Memoized because the context value is an object: a fresh one per render
|
|
29
|
+
// would re-render every dialog and sheet in the tree for nothing.
|
|
30
|
+
const labels = useMemo(() => ({ close: intl.formatMessage(messages.close) }), [intl]);
|
|
31
|
+
return (_jsx(DesignSystemLabelsProvider, { labels: labels, children: children }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaks the name of each new view after a client-side navigation.
|
|
3
|
+
*
|
|
4
|
+
* A single-page app changes the whole view without the browser navigating, and a
|
|
5
|
+
* screen reader is given nothing to report: `<Routes>` swaps the matched
|
|
6
|
+
* element, focus stays on the link that was activated, and the tab title is
|
|
7
|
+
* unchanged. The result is that no page change in the admin is announced at all
|
|
8
|
+
* — the archetypal SPA failure of WCAG `4.1.3 Status Messages`.
|
|
9
|
+
*
|
|
10
|
+
* This mounts one visually-hidden polite live region for the life of the app —
|
|
11
|
+
* present before any message exists, which is the precondition for a live region
|
|
12
|
+
* to be announced at all — and writes the new view's name into it on every
|
|
13
|
+
* pathname change after the first. The first is skipped deliberately: a fresh
|
|
14
|
+
* page load is a real navigation the browser already reports, and announcing it
|
|
15
|
+
* again would double up.
|
|
16
|
+
*
|
|
17
|
+
* **It waits for the heading to actually change.** Naively reading the `<h1>` a
|
|
18
|
+
* moment after the location changes announces the page the user just *left*: the
|
|
19
|
+
* incoming route is a lazy chunk behind a `Suspense` skeleton, so for the first
|
|
20
|
+
* frames of a cold navigation the only heading in the DOM is the outgoing one.
|
|
21
|
+
* Measured on a live stack before this guard existed — `/workspaces` →
|
|
22
|
+
* `/activity` announced "Workspaces" — which is worse than silence, because it
|
|
23
|
+
* tells a screen-reader user they are somewhere they have just left. So the
|
|
24
|
+
* announcer remembers the heading it last saw and polls until it finds a
|
|
25
|
+
* *different* one (a different element, or the same element with different text,
|
|
26
|
+
* which is how a page that fills its own title in asynchronously looks). If the
|
|
27
|
+
* heading never changes within {@link POLL_TIMEOUT_MS} it stays quiet rather
|
|
28
|
+
* than guess — the cost is that two routes sharing one heading text are not
|
|
29
|
+
* announced, which is the right way round.
|
|
30
|
+
*
|
|
31
|
+
* It does **not** move focus. Where focus belongs is a decision only the
|
|
32
|
+
* arriving page can make (identity's auth screens focus their own heading; a
|
|
33
|
+
* page opening an editor may want the first field), and taking it at the host
|
|
34
|
+
* would fight them. The keyboard route past the sidebar is the shell's "Skip to
|
|
35
|
+
* main content" link.
|
|
36
|
+
*/
|
|
37
|
+
export declare function RouteAnnouncer(): import("react").JSX.Element;
|
|
38
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/RouteAnnouncer/index.tsx"],"names":[],"mappings":"AA2CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,cAAc,gCAiE7B"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useLocation } from 'react-router-dom';
|
|
4
|
+
/**
|
|
5
|
+
* How long to keep looking for the new route's heading, and how often. A
|
|
6
|
+
* private route is a lazy chunk behind a `Suspense` skeleton, so the heading
|
|
7
|
+
* that names it does not exist at the moment the location changes — and for the
|
|
8
|
+
* first ~100ms of a cold navigation the heading still in the DOM is the one
|
|
9
|
+
* belonging to the page being *left*.
|
|
10
|
+
*/
|
|
11
|
+
const POLL_INTERVAL_MS = 100;
|
|
12
|
+
const POLL_TIMEOUT_MS = 5_000;
|
|
13
|
+
/**
|
|
14
|
+
* What the announcer last saw, so it can tell "the new route has rendered" from
|
|
15
|
+
* "the old route is still on screen".
|
|
16
|
+
*
|
|
17
|
+
* Module state rather than a `useRef` on purpose: React 19's `StrictMode` mounts
|
|
18
|
+
* every effect twice in development, and a ref is re-created by that remount —
|
|
19
|
+
* so the deliberately silent first render would announce the landing page on the
|
|
20
|
+
* second pass. One host runs per document, so a module-level value has the same
|
|
21
|
+
* lifetime as the app.
|
|
22
|
+
*/
|
|
23
|
+
let lastPath = null;
|
|
24
|
+
let lastHeading = null;
|
|
25
|
+
let lastHeadingText = null;
|
|
26
|
+
/**
|
|
27
|
+
* The element that names the view currently rendered, and its text.
|
|
28
|
+
*
|
|
29
|
+
* The `<h1>` is the one label every page in the admin already has and keeps
|
|
30
|
+
* accurate — unlike the document title, which most routes never set (they
|
|
31
|
+
* inherit the host HTML's "Admin"). Scoped to the `<main>` landmark when the
|
|
32
|
+
* layout provides one, so the shell's own chrome cannot be mistaken for the
|
|
33
|
+
* page.
|
|
34
|
+
*/
|
|
35
|
+
function readHeading() {
|
|
36
|
+
const scope = document.querySelector('main') ?? document;
|
|
37
|
+
const element = scope.querySelector('h1');
|
|
38
|
+
const text = element?.textContent?.trim();
|
|
39
|
+
return element && text ? { element, text } : null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Speaks the name of each new view after a client-side navigation.
|
|
43
|
+
*
|
|
44
|
+
* A single-page app changes the whole view without the browser navigating, and a
|
|
45
|
+
* screen reader is given nothing to report: `<Routes>` swaps the matched
|
|
46
|
+
* element, focus stays on the link that was activated, and the tab title is
|
|
47
|
+
* unchanged. The result is that no page change in the admin is announced at all
|
|
48
|
+
* — the archetypal SPA failure of WCAG `4.1.3 Status Messages`.
|
|
49
|
+
*
|
|
50
|
+
* This mounts one visually-hidden polite live region for the life of the app —
|
|
51
|
+
* present before any message exists, which is the precondition for a live region
|
|
52
|
+
* to be announced at all — and writes the new view's name into it on every
|
|
53
|
+
* pathname change after the first. The first is skipped deliberately: a fresh
|
|
54
|
+
* page load is a real navigation the browser already reports, and announcing it
|
|
55
|
+
* again would double up.
|
|
56
|
+
*
|
|
57
|
+
* **It waits for the heading to actually change.** Naively reading the `<h1>` a
|
|
58
|
+
* moment after the location changes announces the page the user just *left*: the
|
|
59
|
+
* incoming route is a lazy chunk behind a `Suspense` skeleton, so for the first
|
|
60
|
+
* frames of a cold navigation the only heading in the DOM is the outgoing one.
|
|
61
|
+
* Measured on a live stack before this guard existed — `/workspaces` →
|
|
62
|
+
* `/activity` announced "Workspaces" — which is worse than silence, because it
|
|
63
|
+
* tells a screen-reader user they are somewhere they have just left. So the
|
|
64
|
+
* announcer remembers the heading it last saw and polls until it finds a
|
|
65
|
+
* *different* one (a different element, or the same element with different text,
|
|
66
|
+
* which is how a page that fills its own title in asynchronously looks). If the
|
|
67
|
+
* heading never changes within {@link POLL_TIMEOUT_MS} it stays quiet rather
|
|
68
|
+
* than guess — the cost is that two routes sharing one heading text are not
|
|
69
|
+
* announced, which is the right way round.
|
|
70
|
+
*
|
|
71
|
+
* It does **not** move focus. Where focus belongs is a decision only the
|
|
72
|
+
* arriving page can make (identity's auth screens focus their own heading; a
|
|
73
|
+
* page opening an editor may want the first field), and taking it at the host
|
|
74
|
+
* would fight them. The keyboard route past the sidebar is the shell's "Skip to
|
|
75
|
+
* main content" link.
|
|
76
|
+
*/
|
|
77
|
+
export function RouteAnnouncer() {
|
|
78
|
+
const { pathname } = useLocation();
|
|
79
|
+
const [announcement, setAnnouncement] = useState('');
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const initial = lastPath === null;
|
|
82
|
+
// The landing page, or a re-render on the same path: nothing changed
|
|
83
|
+
// that the user was not already told about. The landing page's heading
|
|
84
|
+
// is still recorded, so it becomes the baseline the first real
|
|
85
|
+
// navigation has to differ from.
|
|
86
|
+
const samePath = lastPath === pathname;
|
|
87
|
+
lastPath = pathname;
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
let timer = 0;
|
|
90
|
+
const startedAt = Date.now();
|
|
91
|
+
// Cleared first so that going A → B → A announces "A" again: an
|
|
92
|
+
// unchanged live region is never re-read.
|
|
93
|
+
if (!initial && !samePath)
|
|
94
|
+
setAnnouncement('');
|
|
95
|
+
const tick = () => {
|
|
96
|
+
if (cancelled)
|
|
97
|
+
return;
|
|
98
|
+
const heading = readHeading();
|
|
99
|
+
const changed = heading &&
|
|
100
|
+
(heading.element !== lastHeading ||
|
|
101
|
+
heading.text !== lastHeadingText);
|
|
102
|
+
if (heading && (initial || samePath)) {
|
|
103
|
+
// Baseline only: remember what is on screen, say nothing.
|
|
104
|
+
lastHeading = heading.element;
|
|
105
|
+
lastHeadingText = heading.text;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (changed) {
|
|
109
|
+
lastHeading = heading.element;
|
|
110
|
+
lastHeadingText = heading.text;
|
|
111
|
+
setAnnouncement(heading.text);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (Date.now() - startedAt >= POLL_TIMEOUT_MS)
|
|
115
|
+
return;
|
|
116
|
+
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
|
|
117
|
+
};
|
|
118
|
+
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
window.clearTimeout(timer);
|
|
122
|
+
};
|
|
123
|
+
}, [pathname]);
|
|
124
|
+
return (_jsx("p", { "data-testid": "route-announcer", "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: announcement }));
|
|
125
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Mounts the app-wide unsaved-changes guard and supplies its confirm dialog.
|
|
4
|
+
*
|
|
5
|
+
* The guard itself is copy-free (it lives in `utils-admin`, which owns no
|
|
6
|
+
* strings); this host binds it to the design-system dialog and the app's single
|
|
7
|
+
* `IntlProvider`, so every form in every plugin gets the same prompt without
|
|
8
|
+
* each one re-implementing it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function UnsavedChangesGuard({ children }: {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/UnsavedChangesGuard/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAyBvC;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAoBxE"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
3
|
+
import { ConfirmDialog } from '@orthacms/design-system';
|
|
4
|
+
import { UnsavedChangesProvider } from '@orthacms/utils-admin';
|
|
5
|
+
const messages = defineMessages({
|
|
6
|
+
title: {
|
|
7
|
+
id: 'app.unsaved.title',
|
|
8
|
+
defaultMessage: 'Discard your unsaved changes?'
|
|
9
|
+
},
|
|
10
|
+
body: {
|
|
11
|
+
id: 'app.unsaved.body',
|
|
12
|
+
defaultMessage: 'You have edits on this page that haven’t been saved. Leaving now loses them.'
|
|
13
|
+
},
|
|
14
|
+
confirm: {
|
|
15
|
+
id: 'app.unsaved.confirm',
|
|
16
|
+
defaultMessage: 'Leave and discard'
|
|
17
|
+
},
|
|
18
|
+
cancel: {
|
|
19
|
+
id: 'app.unsaved.cancel',
|
|
20
|
+
defaultMessage: 'Keep editing'
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Mounts the app-wide unsaved-changes guard and supplies its confirm dialog.
|
|
25
|
+
*
|
|
26
|
+
* The guard itself is copy-free (it lives in `utils-admin`, which owns no
|
|
27
|
+
* strings); this host binds it to the design-system dialog and the app's single
|
|
28
|
+
* `IntlProvider`, so every form in every plugin gets the same prompt without
|
|
29
|
+
* each one re-implementing it.
|
|
30
|
+
*/
|
|
31
|
+
export function UnsavedChangesGuard({ children }) {
|
|
32
|
+
const intl = useIntl();
|
|
33
|
+
return (_jsx(UnsavedChangesProvider, { dialog: ({ open, onOpenChange, onConfirm }) => (_jsx(ConfirmDialog, { open: open, onOpenChange: onOpenChange, title: intl.formatMessage(messages.title), description: intl.formatMessage(messages.body), confirmLabel: intl.formatMessage(messages.confirm), cancelLabel: intl.formatMessage(messages.cancel), confirmVariant: "destructive", onConfirm: onConfirm })), children: children }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CreateAdminOptions } from '../types/adminPlugin';
|
|
2
|
+
/**
|
|
3
|
+
* Bootstraps the Ortha CMS admin app: mounts the React root, wraps it in
|
|
4
|
+
* the data, i18n, and router providers, and renders the routes contributed by
|
|
5
|
+
* every plugin.
|
|
6
|
+
*
|
|
7
|
+
* Plugins author user-facing strings with `react-intl` (`defineMessages` +
|
|
8
|
+
* `useIntl`), so the host provides a single `IntlProvider`. Messages are
|
|
9
|
+
* resolved from each descriptor's `defaultMessage`; a translation catalogue
|
|
10
|
+
* can be wired in here later without touching plugins.
|
|
11
|
+
*
|
|
12
|
+
* Server state is fetched with TanStack Query, so the host also provides one
|
|
13
|
+
* `QueryClient`. Plugins call `useQuery`/`useMutation` (e.g. identity's
|
|
14
|
+
* `useLoginMutation`) without owning a client of their own.
|
|
15
|
+
*
|
|
16
|
+
* Routes split by `public`: public routes mount as top-level siblings, while
|
|
17
|
+
* every other route mounts under a single pathless parent that renders the
|
|
18
|
+
* `layout` a plugin contributed (or a bare `<Outlet/>`). The host is
|
|
19
|
+
* auth-agnostic — it does not know that `layout` may wrap its children in a
|
|
20
|
+
* gate; the contributing plugin (the shell) owns that. A `public:false` route
|
|
21
|
+
* with no gating `layout` therefore renders ungated.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createAdmin(options: CreateAdminOptions): void;
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/createAdmin/index.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAe,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA8J5E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CA6G7D"}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import * as ReactDOM from 'react-dom/client';
|
|
4
|
+
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
|
5
|
+
import { IntlProvider } from 'react-intl';
|
|
6
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
7
|
+
import { queryClient } from '@orthacms/utils-admin';
|
|
8
|
+
import { AppearanceProvider, TooltipProvider, Toaster } from '@orthacms/design-system';
|
|
9
|
+
import { UnsavedChangesGuard } from '../UnsavedChangesGuard';
|
|
10
|
+
import { AppErrorBoundary } from '../AppErrorBoundary';
|
|
11
|
+
import { RouteAnnouncer } from '../RouteAnnouncer';
|
|
12
|
+
import { DesignSystemLabels } from '../DesignSystemLabels';
|
|
13
|
+
/**
|
|
14
|
+
* Warns when more than one plugin contributes a `layout`.
|
|
15
|
+
*
|
|
16
|
+
* Only the first survives — `find(Boolean)` below — and the loser is whichever
|
|
17
|
+
* plugin happens to be registered later, which is a decision nobody made. The
|
|
18
|
+
* shell's layout is what composes identity's `RequireAuth`, so losing it does
|
|
19
|
+
* not merely change the chrome: every private route renders **ungated**, with
|
|
20
|
+
* the sidebar, the skip link and the `<main>` landmark gone with it. The host
|
|
21
|
+
* cannot pick a winner for the app — it has no way to know which layout was
|
|
22
|
+
* meant — but it can refuse to do it silently.
|
|
23
|
+
*/
|
|
24
|
+
/** The locale every descriptor's `defaultMessage` is authored in. */
|
|
25
|
+
const DEFAULT_LOCALE = 'en';
|
|
26
|
+
/**
|
|
27
|
+
* Language subtags whose script is written right-to-left, used when the runtime
|
|
28
|
+
* cannot say.
|
|
29
|
+
*
|
|
30
|
+
* A fallback for `Intl.Locale`'s text-info API, which is recent enough that a
|
|
31
|
+
* browser in the support window may not have it. Deliberately a short list of
|
|
32
|
+
* the languages an admin UI is plausibly translated into rather than every RTL
|
|
33
|
+
* script in Unicode: a wrong `dir` is worse than a missing one, and anything not
|
|
34
|
+
* listed simply gets the correct `ltr` it had before.
|
|
35
|
+
*/
|
|
36
|
+
const RTL_LANGUAGES = new Set([
|
|
37
|
+
'ar',
|
|
38
|
+
'arc',
|
|
39
|
+
'ckb',
|
|
40
|
+
'dv',
|
|
41
|
+
'fa',
|
|
42
|
+
'he',
|
|
43
|
+
'ks',
|
|
44
|
+
'ps',
|
|
45
|
+
'sd',
|
|
46
|
+
'ug',
|
|
47
|
+
'ur',
|
|
48
|
+
'yi'
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* The writing direction of `locale`, for `<html dir>`.
|
|
52
|
+
*
|
|
53
|
+
* Nothing was setting it, so every `ml-`/`mr-`/`pl-`/`pr-` in the admin was a
|
|
54
|
+
* physical direction with no chance of mirroring, and a bidi run truncated the
|
|
55
|
+
* wrong end of a string at 320px (WCAG 1.3.2, 1.4.10 — `ORT-86`). Setting the
|
|
56
|
+
* attribute is the host's half and the prerequisite for the rest: a component
|
|
57
|
+
* that wants to mirror has nothing to mirror *against* until the document
|
|
58
|
+
* declares a direction.
|
|
59
|
+
*
|
|
60
|
+
* **This is the UI locale, not the content locale.** A content surface showing
|
|
61
|
+
* an Arabic entry inside a German admin is two directions on one page, and the
|
|
62
|
+
* per-field `dir` the entry editor already sets is what resolves that — the
|
|
63
|
+
* document says which way the *chrome* runs.
|
|
64
|
+
*/
|
|
65
|
+
function directionOf(locale) {
|
|
66
|
+
try {
|
|
67
|
+
const info = new Intl.Locale(locale);
|
|
68
|
+
const direction = info.getTextInfo?.().direction ?? info.textInfo?.direction;
|
|
69
|
+
if (direction === 'rtl' || direction === 'ltr')
|
|
70
|
+
return direction;
|
|
71
|
+
const language = new Intl.Locale(locale).language;
|
|
72
|
+
return RTL_LANGUAGES.has(language) ? 'rtl' : 'ltr';
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// An unparseable tag is the caller's bug, reported by the `IntlProvider`
|
|
76
|
+
// rather than here. Left-to-right is what the document already said.
|
|
77
|
+
return 'ltr';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function warnOnLayoutCollision(plugins) {
|
|
81
|
+
const contributors = plugins
|
|
82
|
+
.filter((plugin) => plugin.layout)
|
|
83
|
+
.map((plugin) => plugin.name);
|
|
84
|
+
if (contributors.length < 2)
|
|
85
|
+
return;
|
|
86
|
+
const [winner, ...ignored] = contributors;
|
|
87
|
+
console.warn(`[bootstrap-admin] ${contributors.length} plugins contribute a layout; only the first is mounted. ` +
|
|
88
|
+
`Using "${winner}", ignoring ${ignored.map((name) => `"${name}"`).join(', ')}. ` +
|
|
89
|
+
'Every non-public route renders inside the winner, so if it is not the app shell they are no longer gated.');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Warns when two routes claim the same `path`.
|
|
93
|
+
*
|
|
94
|
+
* React Router matches by rank, not by declaration, so which element renders at
|
|
95
|
+
* a duplicated path is unspecified; React additionally logs a bare
|
|
96
|
+
* "two children with the same key" that names the path but not the plugins
|
|
97
|
+
* behind it. Naming them here is the difference between a five-minute fix and
|
|
98
|
+
* an afternoon.
|
|
99
|
+
*/
|
|
100
|
+
function warnOnRouteCollisions(plugins) {
|
|
101
|
+
const owners = new Map();
|
|
102
|
+
for (const plugin of plugins) {
|
|
103
|
+
for (const route of plugin.routes ?? []) {
|
|
104
|
+
owners.set(route.path, [
|
|
105
|
+
...(owners.get(route.path) ?? []),
|
|
106
|
+
plugin.name
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [path, contributors] of owners) {
|
|
111
|
+
if (contributors.length < 2)
|
|
112
|
+
continue;
|
|
113
|
+
console.warn(`[bootstrap-admin] route "${path}" is contributed by ${contributors
|
|
114
|
+
.map((name) => `"${name}"`)
|
|
115
|
+
.join(', ')}; only one of them will ever render.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Reports a missing translation once per message id, per locale.
|
|
120
|
+
*
|
|
121
|
+
* `IntlProvider` logs every `MISSING_TRANSLATION` at `error` level, once per
|
|
122
|
+
* render of every descriptor — 460 of them on a single `/activity` load with
|
|
123
|
+
* `locale: 'de'` — and gives the host no way to downgrade, sample or collect
|
|
124
|
+
* them. The volume is not a style complaint: it buries anything real in the
|
|
125
|
+
* console, which is the only place a developer looks (`ORT-141`).
|
|
126
|
+
*
|
|
127
|
+
* Two rules. A locale that *is* the default has nothing to be missing, so the
|
|
128
|
+
* fallback is the expected path and says nothing at all. Otherwise each id is
|
|
129
|
+
* reported once, at `warn` — a missing catalogue entry is a gap to fill, not a
|
|
130
|
+
* failure of this render — and everything that is not a missing translation is
|
|
131
|
+
* passed through untouched, because those are real formatting errors.
|
|
132
|
+
*/
|
|
133
|
+
function makeIntlErrorHandler(locale, defaultLocale) {
|
|
134
|
+
const reported = new Set();
|
|
135
|
+
return (error) => {
|
|
136
|
+
const code = error.code;
|
|
137
|
+
if (code !== 'MISSING_TRANSLATION') {
|
|
138
|
+
console.error(error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (locale === defaultLocale)
|
|
142
|
+
return;
|
|
143
|
+
const id = error
|
|
144
|
+
.descriptor?.id;
|
|
145
|
+
const key = id ?? error.message;
|
|
146
|
+
if (reported.has(key))
|
|
147
|
+
return;
|
|
148
|
+
reported.add(key);
|
|
149
|
+
console.warn(`[bootstrap-admin] no "${locale}" translation for "${key}"; using the default message.`);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bootstraps the Ortha CMS admin app: mounts the React root, wraps it in
|
|
154
|
+
* the data, i18n, and router providers, and renders the routes contributed by
|
|
155
|
+
* every plugin.
|
|
156
|
+
*
|
|
157
|
+
* Plugins author user-facing strings with `react-intl` (`defineMessages` +
|
|
158
|
+
* `useIntl`), so the host provides a single `IntlProvider`. Messages are
|
|
159
|
+
* resolved from each descriptor's `defaultMessage`; a translation catalogue
|
|
160
|
+
* can be wired in here later without touching plugins.
|
|
161
|
+
*
|
|
162
|
+
* Server state is fetched with TanStack Query, so the host also provides one
|
|
163
|
+
* `QueryClient`. Plugins call `useQuery`/`useMutation` (e.g. identity's
|
|
164
|
+
* `useLoginMutation`) without owning a client of their own.
|
|
165
|
+
*
|
|
166
|
+
* Routes split by `public`: public routes mount as top-level siblings, while
|
|
167
|
+
* every other route mounts under a single pathless parent that renders the
|
|
168
|
+
* `layout` a plugin contributed (or a bare `<Outlet/>`). The host is
|
|
169
|
+
* auth-agnostic — it does not know that `layout` may wrap its children in a
|
|
170
|
+
* gate; the contributing plugin (the shell) owns that. A `public:false` route
|
|
171
|
+
* with no gating `layout` therefore renders ungated.
|
|
172
|
+
*/
|
|
173
|
+
export function createAdmin(options) {
|
|
174
|
+
const { plugins, rootElement = 'root', locale = 'en' } = options;
|
|
175
|
+
// The document's language, which nothing was setting: `apps/admin/index.html`
|
|
176
|
+
// ships `lang="en"` and the host never touched it, so a `locale: 'de'` app
|
|
177
|
+
// was German content announced with an English synthesizer — WCAG 3.1.1
|
|
178
|
+
// Language of Page, and the one part of `ORT-141` that is unambiguously the
|
|
179
|
+
// host's to fix. Set before render so assistive tech sees it with the first
|
|
180
|
+
// paint rather than after a reconciliation.
|
|
181
|
+
document.documentElement.lang = locale;
|
|
182
|
+
document.documentElement.dir = directionOf(locale);
|
|
183
|
+
warnOnLayoutCollision(plugins);
|
|
184
|
+
warnOnRouteCollisions(plugins);
|
|
185
|
+
const routes = plugins.flatMap((plugin) => plugin.routes ?? []);
|
|
186
|
+
const publicRoutes = routes.filter((route) => route.public);
|
|
187
|
+
const privateRoutes = routes.filter((route) => !route.public);
|
|
188
|
+
// Wire every plugin's slot contributions into their target slots before
|
|
189
|
+
// render, so consumers (e.g. the shell toolbar) see all contributed items.
|
|
190
|
+
for (const plugin of plugins) {
|
|
191
|
+
for (const contribution of plugin.slots ?? []) {
|
|
192
|
+
contribution.slot._register(contribution.items);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// The single layout that wraps every private route; falls back to a bare
|
|
196
|
+
// outlet before any layout plugin (the shell) is registered.
|
|
197
|
+
const layout = plugins.map((plugin) => plugin.layout).find(Boolean) ?? (_jsx(Outlet, {}));
|
|
198
|
+
// Checked rather than cast. `createRoot(null)` throws deep inside React with
|
|
199
|
+
// a message about a "target container", which is true but says nothing about
|
|
200
|
+
// *which* id the host was told to mount into — and the page the developer is
|
|
201
|
+
// looking at is blank either way, so the console is all they have.
|
|
202
|
+
const container = document.getElementById(rootElement);
|
|
203
|
+
if (!container) {
|
|
204
|
+
throw new Error(`[bootstrap-admin] no element with id "${rootElement}" to mount into. ` +
|
|
205
|
+
'The host HTML must contain it (see `apps/admin/index.html`), or pass a different `rootElement` to createAdmin().');
|
|
206
|
+
}
|
|
207
|
+
const root = ReactDOM.createRoot(container);
|
|
208
|
+
root.render(_jsx(StrictMode, { children: _jsx(AppearanceProvider, { children: _jsx(QueryClientProvider, { client: queryClient, children: _jsx(IntlProvider, { locale: locale, defaultLocale: DEFAULT_LOCALE, onError: makeIntlErrorHandler(locale, DEFAULT_LOCALE), children: _jsx(DesignSystemLabels, { children: _jsxs(TooltipProvider, { delayDuration: 200, children: [_jsx(AppErrorBoundary, { children: _jsxs(BrowserRouter, { children: [_jsx(RouteAnnouncer, {}), _jsx(UnsavedChangesGuard, { children: _jsxs(Routes, { children: [publicRoutes.map((route) => (_jsx(Route, { path: route.path, element: route.element }, route.path))), _jsxs(Route, { element: layout, children: [privateRoutes.map((route) => (_jsx(Route, { path: route.path, element: route.element }, route.path))), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/", replace: true }) })] })] }) })] }) }), _jsx(Toaster, {})] }) }) }) }) }) }));
|
|
209
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SlotContribution } from '@orthacms/utils-admin';
|
|
3
|
+
/** A route a plugin mounts into the app router. */
|
|
4
|
+
export type RouteItem = {
|
|
5
|
+
/** Route path (e.g. "/users/*"). */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Element rendered at that path. */
|
|
8
|
+
element: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* When `true`, the route mounts as a top-level sibling (e.g. the sign-in
|
|
11
|
+
* page). Omitted/`false` means it mounts under the contributed `layout`.
|
|
12
|
+
* The host attaches no auth meaning to this — whether "under the layout"
|
|
13
|
+
* means "gated" is up to the layout (the shell wraps it in identity's
|
|
14
|
+
* `RequireAuth`).
|
|
15
|
+
*/
|
|
16
|
+
public?: boolean;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Contract every admin-side plugin must implement: a name, the routes it
|
|
20
|
+
* contributes, and optionally the layout its non-`public` siblings render
|
|
21
|
+
* inside.
|
|
22
|
+
*/
|
|
23
|
+
export type AdminPlugin = {
|
|
24
|
+
/** Unique identifier. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Routes this plugin contributes. */
|
|
27
|
+
routes?: RouteItem[];
|
|
28
|
+
/**
|
|
29
|
+
* The app shell — chrome that renders an `<Outlet/>`. The host mounts the
|
|
30
|
+
* first plugin-provided `layout` as the single parent of every non-`public`
|
|
31
|
+
* route. The host treats it as opaque; the shell plugin composes its auth
|
|
32
|
+
* provider + gate inside it. Omitted by most plugins.
|
|
33
|
+
*/
|
|
34
|
+
layout?: ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* Slot contributions (e.g. toolbar nav items). The host wires these into
|
|
37
|
+
* their target slots before render and attaches no meaning beyond wiring —
|
|
38
|
+
* the consuming plugin (the shell) defines and reads the slot.
|
|
39
|
+
*/
|
|
40
|
+
slots?: SlotContribution[];
|
|
41
|
+
};
|
|
42
|
+
/** Options for {@link createAdmin}. */
|
|
43
|
+
export type CreateAdminOptions = {
|
|
44
|
+
/** Plugins to register. */
|
|
45
|
+
plugins: AdminPlugin[];
|
|
46
|
+
/** DOM element id to mount into. Defaults to "root". */
|
|
47
|
+
rootElement?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Active locale for `react-intl`, and the value written to `<html lang>`.
|
|
50
|
+
* Defaults to `'en'`.
|
|
51
|
+
*
|
|
52
|
+
* **It does not translate anything yet.** The admin ships exactly one
|
|
53
|
+
* catalogue — the `defaultMessage` on each descriptor — and no `messages`
|
|
54
|
+
* are passed to `IntlProvider`, so every string resolves to its English
|
|
55
|
+
* default whatever this is set to. What *does* follow it is `Intl`
|
|
56
|
+
* formatting (dates, numbers, plurals) and the document language, which is
|
|
57
|
+
* why setting it is still better than not.
|
|
58
|
+
*
|
|
59
|
+
* It is typed `string` rather than a union of shipped locales because there
|
|
60
|
+
* is no set of shipped locales to name yet; when catalogues land, this
|
|
61
|
+
* should narrow so the option cannot promise something it does not do
|
|
62
|
+
* (`ORT-141`).
|
|
63
|
+
*/
|
|
64
|
+
locale?: string;
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/types/adminPlugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,mDAAmD;AACnD,MAAM,MAAM,SAAS,GAAG;IACpB,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,OAAO,EAAE,SAAS,CAAC;IACnB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG;IACtB,yBAAyB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAC9B,CAAC;AAEF,uCAAuC;AACvC,MAAM,MAAM,kBAAkB,GAAG;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orthacms/bootstrap-admin",
|
|
3
|
+
"version": "0.0.0-reserve.0",
|
|
4
|
+
"description": "@orthacms/bootstrap-admin — part of Ortha CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/bootstrap/admin",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ortha-source/ortha-cms.git",
|
|
10
|
+
"directory": "packages/bootstrap/admin"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ortha-source/ortha-cms/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@orthacms/design-system": "^0.0.1",
|
|
29
|
+
"@orthacms/utils-admin": "^0.0.1",
|
|
30
|
+
"@tanstack/react-query": "^5.0.0",
|
|
31
|
+
"react-intl": "^7.0.0",
|
|
32
|
+
"tslib": "^2.3.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"react-dom": "^19.0.0",
|
|
37
|
+
"react-router-dom": "^6.0.0"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|