@sybilion/uilib 1.2.1 → 1.2.4
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/dist/esm/components/ui/AppHeader/AppHeader.js +3 -3
- package/dist/esm/components/ui/Page/AppShell/AppShell.styl.js +1 -1
- package/dist/esm/components/ui/Page/PageScroll/PageScroll.js +4 -4
- package/dist/esm/components/widgets/SidebarDatasetsItemsGrouped/SidebarDatasetsItemsGrouped.js +9 -9
- package/dist/esm/types/src/components/ui/AppHeader/AppHeader.d.ts +2 -1
- package/dist/esm/types/src/components/ui/Page/PageScroll/PageScroll.d.ts +2 -1
- package/dist/esm/types/src/components/widgets/SidebarDatasetsItemsGrouped/SidebarDatasetsItemsGrouped.d.ts +3 -1
- package/dist/esm/types/src/docs/pages/StandaloneAppLayoutPage.d.ts +1 -0
- package/dist/standalone/vite-sybilion-standalone-dev.d.ts +13 -0
- package/dist/standalone/vite-sybilion-standalone-dev.js +49 -0
- package/docs/standalone-apps.md +291 -100
- package/package.json +13 -3
- package/src/components/ui/AppHeader/AppHeader.tsx +7 -3
- package/src/components/ui/Page/AppShell/AppShell.styl +1 -0
- package/src/components/ui/Page/PageScroll/PageScroll.tsx +9 -2
- package/src/components/widgets/SidebarDatasetsItemsGrouped/SidebarDatasetsItemsGrouped.tsx +9 -0
- package/src/docs/pages/StandaloneAppLayoutPage.styl +46 -0
- package/src/docs/pages/StandaloneAppLayoutPage.styl.d.ts +8 -0
- package/src/docs/pages/StandaloneAppLayoutPage.tsx +242 -0
- package/src/docs/registry.ts +6 -0
- package/src/standalone/vite-sybilion-standalone-dev.ts +65 -0
|
@@ -8,11 +8,11 @@ import { PAGE_HEADER_ID } from './appChromeAnchors.js';
|
|
|
8
8
|
function AppHeaderHost({ className, anchorId = PAGE_HEADER_ID, }) {
|
|
9
9
|
return jsx("header", { className: cn(S.root, className), id: anchorId });
|
|
10
10
|
}
|
|
11
|
-
function AppHeaderPortal({ children }) {
|
|
11
|
+
function AppHeaderPortal({ children, pageHeaderId = PAGE_HEADER_ID, }) {
|
|
12
12
|
const [container, setContainer] = useState(null);
|
|
13
13
|
useLayoutEffect(() => {
|
|
14
|
-
setContainer(document.getElementById(
|
|
15
|
-
}, []);
|
|
14
|
+
setContainer(document.getElementById(pageHeaderId));
|
|
15
|
+
}, [pageHeaderId]);
|
|
16
16
|
if (!container) {
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import styleInject from 'style-inject';
|
|
2
2
|
|
|
3
|
-
var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.AppShell_root__ONlNK{display:grid;flex:1;grid-template-areas:\"main\";grid-template-columns:1fr;min-height:0;width:100%}@media (min-width:768px){.AppShell_root__ONlNK{grid-template-areas:\"sidebar main\";grid-template-columns:var(--sidebar-width) 1fr}[data-slot=sidebar-wrapper][data-state=collapsed] .AppShell_root__ONlNK{grid-template-columns:0 1fr}}.AppShell_mainColumn__Emn1p{display:flex;flex:1;flex-direction:column;grid-area:main;min-height:0;min-width:0}[data-slot=sidebar-wrapper][data-state=collapsed] .AppShell_mainColumn__Emn1p{margin-left:var(--p-3)}.AppShell_mainBody__IoVuy{background-color:var(--page-color);border-radius:var(--p-4);flex:1;min-width:0;padding-bottom:var(--page-y-padding)}";
|
|
3
|
+
var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.AppShell_root__ONlNK{display:grid;flex:1;grid-template-areas:\"main\";grid-template-columns:1fr;min-height:0;min-height:100%;width:100%}@media (min-width:768px){.AppShell_root__ONlNK{grid-template-areas:\"sidebar main\";grid-template-columns:var(--sidebar-width) 1fr}[data-slot=sidebar-wrapper][data-state=collapsed] .AppShell_root__ONlNK{grid-template-columns:0 1fr}}.AppShell_mainColumn__Emn1p{display:flex;flex:1;flex-direction:column;grid-area:main;min-height:0;min-width:0}[data-slot=sidebar-wrapper][data-state=collapsed] .AppShell_mainColumn__Emn1p{margin-left:var(--p-3)}.AppShell_mainBody__IoVuy{background-color:var(--page-color);border-radius:var(--p-4);flex:1;min-width:0;padding-bottom:var(--page-y-padding)}";
|
|
4
4
|
var S = {"root":"AppShell_root__ONlNK","mainColumn":"AppShell_mainColumn__Emn1p","mainBody":"AppShell_mainBody__IoVuy"};
|
|
5
5
|
styleInject(css_248z);
|
|
6
6
|
|
|
@@ -6,7 +6,7 @@ import { PageContext } from '../pageContext.js';
|
|
|
6
6
|
import { Scroll } from '@homecode/ui';
|
|
7
7
|
import S from './PageScroll.styl.js';
|
|
8
8
|
|
|
9
|
-
function PageScrollInner({ className, children, }) {
|
|
9
|
+
function PageScrollInner({ className, rootClassName, children, }) {
|
|
10
10
|
// const { setIsScrolled } = useContext(PageContext);
|
|
11
11
|
// const prevScrollTop = useRef(0);
|
|
12
12
|
const location = useLocation();
|
|
@@ -25,13 +25,13 @@ function PageScrollInner({ className, children, }) {
|
|
|
25
25
|
// };
|
|
26
26
|
return (jsx(Scroll, { y: true,
|
|
27
27
|
// fadeSize="l"
|
|
28
|
-
offset: { y: { before: 120, after: 130 } }, className: S.root, autoHide: true, innerClassName: cn(S.inner, className), yScrollbarClassName: S.scrollbar, onInnerRef: el => {
|
|
28
|
+
offset: { y: { before: 120, after: 130 } }, className: cn(S.root, rootClassName), autoHide: true, innerClassName: cn(S.inner, className), yScrollbarClassName: S.scrollbar, onInnerRef: el => {
|
|
29
29
|
scrollInnerRef.current = el;
|
|
30
30
|
}, children: children }));
|
|
31
31
|
}
|
|
32
|
-
const PageScroll = ({ children, className, }) => {
|
|
32
|
+
const PageScroll = ({ children, className, rootClassName, }) => {
|
|
33
33
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
34
|
-
return (jsx(PageContext.Provider, { value: { isScrolled, setIsScrolled }, children: jsx(PageScrollInner, { className: className, children: children }) }));
|
|
34
|
+
return (jsx(PageContext.Provider, { value: { isScrolled, setIsScrolled }, children: jsx(PageScrollInner, { className: className, rootClassName: rootClassName, children: children }) }));
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export { PageScroll };
|
package/dist/esm/components/widgets/SidebarDatasetsItemsGrouped/SidebarDatasetsItemsGrouped.js
CHANGED
|
@@ -6,7 +6,7 @@ import { PackageOpen, ChevronDown, ChevronRight } from 'lucide-react';
|
|
|
6
6
|
import S from './SidebarDatasetsItemsGrouped.styl.js';
|
|
7
7
|
import { groupSidebarDatasets } from './groupSidebarDatasets.js';
|
|
8
8
|
|
|
9
|
-
function SidebarDatasetsItemsGrouped({ groupBy, datasets, selectedDatasetId, onDatasetClick, defaultExpandedGroupNames, className, }) {
|
|
9
|
+
function SidebarDatasetsItemsGrouped({ groupBy, datasets, preItems, postItems, selectedDatasetId, onDatasetClick, defaultExpandedGroupNames, className, }) {
|
|
10
10
|
const grouped = useMemo(() => groupSidebarDatasets(datasets, groupBy), [datasets, groupBy]);
|
|
11
11
|
const [expanded, setExpanded] = useState(new Set());
|
|
12
12
|
useEffect(() => {
|
|
@@ -35,14 +35,14 @@ function SidebarDatasetsItemsGrouped({ groupBy, datasets, selectedDatasetId, onD
|
|
|
35
35
|
return next;
|
|
36
36
|
});
|
|
37
37
|
};
|
|
38
|
-
return (jsx(SidebarGroup, { className: className, children:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
return (jsx(SidebarGroup, { className: className, children: jsxs(SidebarMenu, { children: [preItems, grouped.map(([groupName, groupDatasets]) => {
|
|
39
|
+
const isExpanded = expanded.has(groupName);
|
|
40
|
+
const parentActive = groupDatasets.some(d => d.id === selectedDatasetId);
|
|
41
|
+
return (jsxs(SidebarMenuItem, { children: [jsxs(SidebarMenuButton, { type: "button", isActive: parentActive, onClick: () => toggleGroup(groupName), children: [jsx(PackageOpen, { strokeWidth: 1.5, size: 16 }), jsx(SmartTextTruncate, { children: groupName }), jsx("div", { className: S.chevronContainer, children: isExpanded ? (jsx(ChevronDown, { size: 12 })) : (jsx(ChevronRight, { size: 12 })) })] }), isExpanded && (jsxs(SidebarMenuSub, { className: S.subMenuContainer, children: [jsx("div", { className: S.subMenuBorder }), groupDatasets.map(dataset => (jsx(SidebarMenuSubItem, { className: S.subMenuItem, children: jsx(SidebarMenuSubButton, { href: `#dataset-${dataset.id}`, isActive: dataset.id === selectedDatasetId, onClick: e => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
onDatasetClick?.(dataset.id);
|
|
44
|
+
}, children: jsx(SmartTextTruncate, { children: dataset.name }) }) }, dataset.id)))] }))] }, groupName));
|
|
45
|
+
}), postItems] }) }));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export { SidebarDatasetsItemsGrouped };
|
|
@@ -7,5 +7,6 @@ export type AppHeaderProps = {
|
|
|
7
7
|
export declare function AppHeaderHost({ className, anchorId, }: AppHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
8
8
|
export type AppHeaderPortalProps = {
|
|
9
9
|
children: ReactNode;
|
|
10
|
+
pageHeaderId?: string;
|
|
10
11
|
};
|
|
11
|
-
export declare function AppHeaderPortal({ children }: AppHeaderPortalProps): import("react").ReactPortal;
|
|
12
|
+
export declare function AppHeaderPortal({ children, pageHeaderId, }: AppHeaderPortalProps): import("react").ReactPortal;
|
|
@@ -2,10 +2,12 @@ import { type SidebarDatasetsItemsGroupBy, type SidebarDatasetsItemsGroupedDatas
|
|
|
2
2
|
export type SidebarDatasetsItemsGroupedProps = {
|
|
3
3
|
groupBy: SidebarDatasetsItemsGroupBy;
|
|
4
4
|
datasets: SidebarDatasetsItemsGroupedDataset[];
|
|
5
|
+
preItems?: React.ReactNode;
|
|
6
|
+
postItems?: React.ReactNode;
|
|
5
7
|
selectedDatasetId?: number;
|
|
6
8
|
onDatasetClick?: (datasetId: number) => void;
|
|
7
9
|
/** When omitted, all groups start expanded. */
|
|
8
10
|
defaultExpandedGroupNames?: string[];
|
|
9
11
|
className?: string;
|
|
10
12
|
};
|
|
11
|
-
export declare function SidebarDatasetsItemsGrouped({ groupBy, datasets, selectedDatasetId, onDatasetClick, defaultExpandedGroupNames, className, }: SidebarDatasetsItemsGroupedProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare function SidebarDatasetsItemsGrouped({ groupBy, datasets, preItems, postItems, selectedDatasetId, onDatasetClick, defaultExpandedGroupNames, className, }: SidebarDatasetsItemsGroupedProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function StandaloneAppLayoutPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { UserConfig } from 'vite';
|
|
2
|
+
export type SybilionStandaloneViteDevOptions = {
|
|
3
|
+
mode: string;
|
|
4
|
+
/** Directory containing `.env*` files. @default process.cwd() */
|
|
5
|
+
envDir?: string;
|
|
6
|
+
/** Prefix proxied to Sybilion API (SDK `apiPrefix`). @default `/api` */
|
|
7
|
+
apiPrefix?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Vite `server` + `preview` fragment for standalone Sybilion SPAs: same-origin `/api` in dev,
|
|
11
|
+
* proxied to `VITE_SYBILION_API_BASE_URL`. Uses `PORT` from env (default `3000`).
|
|
12
|
+
*/
|
|
13
|
+
export declare function sybilionStandaloneViteDev(options: SybilionStandaloneViteDevOptions): Pick<UserConfig, 'server' | 'preview'>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadEnv } from 'vite';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PORT = 3000;
|
|
4
|
+
const SYBILION_API_ENV = 'VITE_SYBILION_API_BASE_URL';
|
|
5
|
+
let warnedMissingApiUrl = false;
|
|
6
|
+
function parsePort(raw) {
|
|
7
|
+
if (raw == null || raw === '')
|
|
8
|
+
return DEFAULT_PORT;
|
|
9
|
+
const n = Number.parseInt(raw, 10);
|
|
10
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65_535)
|
|
11
|
+
return DEFAULT_PORT;
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
function normalizeApiPrefix(apiPrefix) {
|
|
15
|
+
return apiPrefix.startsWith('/') ? apiPrefix : `/${apiPrefix}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Vite `server` + `preview` fragment for standalone Sybilion SPAs: same-origin `/api` in dev,
|
|
19
|
+
* proxied to `VITE_SYBILION_API_BASE_URL`. Uses `PORT` from env (default `3000`).
|
|
20
|
+
*/
|
|
21
|
+
function sybilionStandaloneViteDev(options) {
|
|
22
|
+
const envDir = options.envDir ?? process.cwd();
|
|
23
|
+
const apiPrefix = normalizeApiPrefix(options.apiPrefix ?? '/api');
|
|
24
|
+
const env = loadEnv(options.mode, envDir, '');
|
|
25
|
+
const port = parsePort(env.PORT);
|
|
26
|
+
const target = (env[SYBILION_API_ENV] ?? '').replace(/\/$/, '');
|
|
27
|
+
const proxy = {};
|
|
28
|
+
if (target) {
|
|
29
|
+
proxy[apiPrefix] = {
|
|
30
|
+
target,
|
|
31
|
+
changeOrigin: true,
|
|
32
|
+
secure: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
else if (options.mode === 'development' && !warnedMissingApiUrl) {
|
|
36
|
+
warnedMissingApiUrl = true;
|
|
37
|
+
console.warn(`[@sybilion/uilib] ${SYBILION_API_ENV} is not set; API dev proxy disabled.`);
|
|
38
|
+
}
|
|
39
|
+
const serverPreview = {
|
|
40
|
+
port,
|
|
41
|
+
proxy,
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
server: serverPreview,
|
|
45
|
+
preview: serverPreview,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { sybilionStandaloneViteDev };
|
package/docs/standalone-apps.md
CHANGED
|
@@ -1,26 +1,212 @@
|
|
|
1
1
|
# Standalone Sybilion apps (@sybilion/uilib)
|
|
2
2
|
|
|
3
|
-
Greenfield SPA on **your own origin**: `@sybilion/uilib` for layout/UI,
|
|
3
|
+
Greenfield SPA on **your own origin**: `@sybilion/uilib` for layout/UI, `SybilionAuthProvider` + Sybilion API for data—no iframe in the main client.
|
|
4
4
|
|
|
5
|
-
**Agents / humans:**
|
|
5
|
+
**Agents / humans:** use `AppShell` + `AppShellMainContent`; stick to uilib spacing primitives (e.g. `Gap`) instead of ad hoc root horizontal gutters.
|
|
6
|
+
|
|
7
|
+
**Local-first:** After scaffolding, the user should run **`yarn dev`** (or `npm run dev`) on **localhost** with **no deploy** (e.g. Vercel) required. Commit **`.env.example`**; the user copies it to **`.env`**. In development, the Sybilion API is reached via a **same-origin `/api` proxy** so the browser avoids CORS; production builds still use `VITE_SYBILION_API_BASE_URL` on the client, and the real API must allow **CORS** for your deployed `Origin` unless you terminate API calls on the same host.
|
|
6
8
|
|
|
7
9
|
## 1. Dependencies and global CSS
|
|
8
10
|
|
|
11
|
+
Vite-based apps (recommended for new standalone SPAs):
|
|
12
|
+
|
|
9
13
|
```bash
|
|
10
14
|
yarn add react react-dom react-router-dom @auth0/auth0-react @sybilion/uilib @sybilion/sdk
|
|
15
|
+
yarn add -D vite @vitejs/plugin-react
|
|
11
16
|
```
|
|
12
17
|
|
|
13
|
-
Import tokens/fonts once:
|
|
18
|
+
Import tokens/fonts once (typically `src/main.tsx`):
|
|
14
19
|
|
|
15
20
|
```ts
|
|
16
21
|
import '@sybilion/uilib/standalone-global.css';
|
|
17
22
|
```
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
Mount the tree with `ReactDOM.createRoot` → `App` (wrap with `StrictMode` if you want).
|
|
25
|
+
|
|
26
|
+
### `package.json` scripts (required)
|
|
27
|
+
|
|
28
|
+
Add a **`dev`** script so the app is runnable immediately after clone. Typical Vite setup:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "vite",
|
|
34
|
+
"build": "vite build",
|
|
35
|
+
"preview": "vite preview"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### `.env.example`
|
|
41
|
+
|
|
42
|
+
Commit **`.env.example`** at the app root (no secrets). Minimum variables:
|
|
43
|
+
|
|
44
|
+
| Variable | Purpose |
|
|
45
|
+
| -------- | ------- |
|
|
46
|
+
| `PORT` | Vite **dev** and **preview** server port. **`PORT=3000`** matches the current **Auth0 test tenant** (`http://localhost:3000` callback / web origins). Override if your tenant uses another port. |
|
|
47
|
+
| `VITE_SYBILION_API_BASE_URL` | Real Sybilion API origin (no trailing slash). Used as **proxy target** in dev and as SDK `baseUrl` in production builds. |
|
|
48
|
+
| `VITE_AUTH0_DOMAIN` | Auth0 domain (§3). |
|
|
49
|
+
| `VITE_AUTH0_CLIENT_ID` | Auth0 SPA client id (§3). |
|
|
50
|
+
|
|
51
|
+
Example **`.env.example`** content for the app root:
|
|
52
|
+
|
|
53
|
+
```env
|
|
54
|
+
# Copy to `.env` and fill in values. Do not commit `.env`.
|
|
55
|
+
# PORT: Vite dev/preview bind (sybilionStandaloneViteDev reads this). 3000 matches Auth0 test tenant localhost URLs.
|
|
56
|
+
PORT=3000
|
|
57
|
+
|
|
58
|
+
VITE_SYBILION_API_BASE_URL=https://api-dev.sybilion.com
|
|
59
|
+
VITE_AUTH0_DOMAIN=your-tenant.eu.auth0.com
|
|
60
|
+
VITE_AUTH0_CLIENT_ID=your-spa-client-id
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 2. SDK (`@sybilion/sdk`)
|
|
64
|
+
|
|
65
|
+
Typed HTTP client for the Sybilion API. Env vars depend on bundler; for Vite, only `import.meta.env.VITE_*` reaches the client.
|
|
66
|
+
|
|
67
|
+
Create **one** instance at module scope (e.g. `src/libs/sybilion-sdk.ts`) so React never reconstructs the client (`useMemo` not needed), and reuse it for auth + data:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createSybilionSDK } from '@sybilion/sdk';
|
|
71
|
+
|
|
72
|
+
export const sybilionJwtStorageKey = 'sybilion.standalone.jwt';
|
|
73
|
+
|
|
74
|
+
export const sybilionSdk = createSybilionSDK({
|
|
75
|
+
baseUrl: import.meta.env.DEV
|
|
76
|
+
? ''
|
|
77
|
+
: (import.meta.env.VITE_SYBILION_API_BASE_URL as string),
|
|
78
|
+
apiPrefix: '/api',
|
|
79
|
+
getToken: () =>
|
|
80
|
+
typeof localStorage !== 'undefined'
|
|
81
|
+
? (localStorage.getItem(sybilionJwtStorageKey) ?? undefined)
|
|
82
|
+
: undefined,
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Options:** `baseUrl` — API origin only (no trailing slash) **in production**; in **development** use `''` so requests stay **same-origin** (`/api/v1/...`) and the Vite dev server **proxies `/api`** to `VITE_SYBILION_API_BASE_URL` (see **Local dev: Vite API proxy** below). `apiPrefix` — default `'/api'` so calls go to `{baseUrl}/api/v1/...`. `getToken` — must read the same key you pass as `sybilionTokenStorageKey` on `SybilionAuthProvider` (§3).
|
|
20
87
|
|
|
21
|
-
|
|
88
|
+
```ts
|
|
89
|
+
import { sybilionSdk } from './libs/sybilion-sdk';
|
|
90
|
+
|
|
91
|
+
await sybilionSdk.raw.datasets.getById(datasetId);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `sybilionSdk.auth` — `loginWithAuth0Identity`, `getMe`, `updateMe` (Auth0 bootstrap + user profile).
|
|
95
|
+
- `sybilionSdk.raw` — thin `GET`/`POST`/… wrappers for `/v1/...` paths (e.g. `raw.analyses.driversMapOnce`; parsed JSON, no app-specific shaping).
|
|
96
|
+
- `sybilionSdk.resources` — higher-level helpers (datasets, drivers, subscriptions) on top of `raw`.
|
|
22
97
|
|
|
23
|
-
|
|
98
|
+
Package README: [`@sybilion/sdk`](https://www.npmjs.com/package/@sybilion/sdk) — monorepo: [`../../sdk/README.md`](../../sdk/README.md).
|
|
99
|
+
|
|
100
|
+
## Local dev: Vite API proxy
|
|
101
|
+
|
|
102
|
+
Avoid browser CORS in development by serving the SPA from Vite and proxying **`/api`** to the real API. Use **`sybilionStandaloneViteDev`** from `@sybilion/uilib/vite-standalone-dev` in **`vite.config.ts`**: it reads **`PORT`** (defaults to **3000** if unset or invalid) for **`server`** / **`preview`**, and sets **`proxy['/api']`** → **`VITE_SYBILION_API_BASE_URL`** with `changeOrigin` and `secure: true`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { defineConfig } from 'vite';
|
|
106
|
+
import react from '@vitejs/plugin-react';
|
|
107
|
+
import { sybilionStandaloneViteDev } from '@sybilion/uilib/vite-standalone-dev';
|
|
108
|
+
|
|
109
|
+
export default defineConfig(({ mode }) => ({
|
|
110
|
+
...sybilionStandaloneViteDev({ mode }),
|
|
111
|
+
plugins: [react()],
|
|
112
|
+
}));
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Combine with an **empty `baseUrl` in dev** in the SDK module (§2). In **`preview`** builds, keep **`.env`** with **`VITE_SYBILION_API_BASE_URL`** so `vite preview` can still proxy API calls locally.
|
|
116
|
+
|
|
117
|
+
## Local dev: apps with a Go server
|
|
118
|
+
|
|
119
|
+
Some templates include a **Go** server. This repo does **not** provide Go middleware. The contract: whatever serves the **same origin the browser uses for the SPA** must **reverse-proxy** path prefix **`/api`** (or your SDK `apiPrefix`) to the Sybilion API. Use **`baseUrl: ''`** in dev when those requests are same-origin. Name and document server-side env vars (e.g. API upstream URL) in the Go project.
|
|
120
|
+
|
|
121
|
+
## 3. Auth (`SybilionAuthProvider`)
|
|
122
|
+
|
|
123
|
+
Use inside `BrowserRouter` if redirects hit a callback route.
|
|
124
|
+
|
|
125
|
+
Wire the SDK module from §2 — no second `createSybilionSDK` here:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import type { ReactNode } from 'react';
|
|
129
|
+
|
|
130
|
+
import { SybilionAuthProvider } from '@sybilion/uilib';
|
|
131
|
+
|
|
132
|
+
import { sybilionJwtStorageKey, sybilionSdk } from './libs/sybilion-sdk';
|
|
133
|
+
|
|
134
|
+
export function AppProviders({ children }: { children: ReactNode }) {
|
|
135
|
+
const auth0Domain = import.meta.env.VITE_AUTH0_DOMAIN as string;
|
|
136
|
+
const auth0ClientId = import.meta.env.VITE_AUTH0_CLIENT_ID as string;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<SybilionAuthProvider
|
|
140
|
+
sdk={sybilionSdk}
|
|
141
|
+
sybilionTokenStorageKey={sybilionJwtStorageKey}
|
|
142
|
+
auth0Domain={auth0Domain}
|
|
143
|
+
auth0ClientId={auth0ClientId}
|
|
144
|
+
redirectUri={window.location.origin}
|
|
145
|
+
>
|
|
146
|
+
{children}
|
|
147
|
+
</SybilionAuthProvider>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Flow:** Auth0 SPA → `sybilionSdk.auth.loginWithAuth0Identity(<Auth0 AT>)` → Sybilion JWT (`data.token` / `token`) persisted → same `sybilionSdk` + `getToken` attach Bearer on requests; `useSybilionApiFetch()` uses `getSybilionApiOriginFromSdk(sdk)` for URLs (paths like `/api/v1/...`).
|
|
153
|
+
|
|
154
|
+
**Defaults** for `authorizationParams` match sybilion-client (Management audience + `openid profile email offline_access …`); override if your Auth0 SPA needs a Resource Server audience (backend confirms).
|
|
155
|
+
|
|
156
|
+
| Layer | Configure |
|
|
157
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| **Auth0** | Callback, logout, and web origins → your URLs (+ previews). |
|
|
159
|
+
| **Sybilion API** | CORS → your deploy `Origin`. |
|
|
160
|
+
| **App** | §2 SDK module (`baseUrl`, `apiPrefix`, `getToken`); pass `sdk` + matching `sybilionTokenStorageKey`; Auth0 `domain` / `clientId`; redirect usually `window.location.origin`. |
|
|
161
|
+
|
|
162
|
+
**Hooks:** `useSybilionAuth()`, `useSybilionApiFetch()` (or `createSybilionApiFetch` / `sybilionApiFetch` helpers).
|
|
163
|
+
|
|
164
|
+
## 4. Layout (AppShell)
|
|
165
|
+
|
|
166
|
+
With §2 `sybilionSdk` and §3 `AppProviders` / `SybilionAuthProvider` defined, compose routing + shell so Auth0 callbacks and JWT-backed hooks wrap the whole UI.
|
|
167
|
+
|
|
168
|
+
### Root wiring (`App.tsx`)
|
|
169
|
+
|
|
170
|
+
Order outside → in: `BrowserRouter` (callbacks) → `AppProviders` (§3) → `SidebarProvider` (sidebar width / open state for `Sidebar` primitives) → `AppLayout` → `Routes`.
|
|
171
|
+
|
|
172
|
+
`AppLayout` renders the persistent chrome (sidebar, header, footer) and the active route renders inside its main column via `children`. Add `HomePage` / `DatasetsPage` as your own page components; register an Auth0 callback route here if `SybilionAuthProvider` uses a dedicated path.
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
176
|
+
|
|
177
|
+
import { SidebarProvider } from '@sybilion/uilib';
|
|
178
|
+
|
|
179
|
+
import { AppLayout } from './AppLayout';
|
|
180
|
+
import { AppProviders } from './AppProviders';
|
|
181
|
+
import { DatasetsPage } from './pages/DatasetsPage';
|
|
182
|
+
import { HomePage } from './pages/HomePage';
|
|
183
|
+
|
|
184
|
+
export function App() {
|
|
185
|
+
return (
|
|
186
|
+
<BrowserRouter>
|
|
187
|
+
<AppProviders>
|
|
188
|
+
<SidebarProvider
|
|
189
|
+
sidebarWidthStorageKey="myapp.sidebarWidthPx"
|
|
190
|
+
persistSidebarWidthWithoutConsent
|
|
191
|
+
>
|
|
192
|
+
<AppLayout>
|
|
193
|
+
<Routes>
|
|
194
|
+
<Route path="/" element={<HomePage />} />
|
|
195
|
+
<Route path="/datasets" element={<DatasetsPage />} />
|
|
196
|
+
</Routes>
|
|
197
|
+
</AppLayout>
|
|
198
|
+
</SidebarProvider>
|
|
199
|
+
</AppProviders>
|
|
200
|
+
</BrowserRouter>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`SidebarProvider`: pass only the props you need. `sidebarWidthStorageKey` namespaces width in localStorage. For production apps with cookie-based consent, drop `persistSidebarWidthWithoutConsent` and pass `userId` so width persistence follows your consent rules.
|
|
206
|
+
|
|
207
|
+
### `AppLayout` (sidebar + main + `children`)
|
|
208
|
+
|
|
209
|
+
`AppHeaderHost` is the header anchor; put `SidebarTrigger` (collapses / opens rail), `NavUserHeader`, etc. inside `AppHeaderPortal`. `AppSidebar` (next subsection) is a sibling of `AppShellMainContent` inside `AppShell`. The matched route (the `<Routes>` subtree from `App.tsx`) arrives as `children` and renders inside the main column.
|
|
24
210
|
|
|
25
211
|
```tsx
|
|
26
212
|
import type { ReactNode } from 'react';
|
|
@@ -34,18 +220,23 @@ import {
|
|
|
34
220
|
NavUserHeader,
|
|
35
221
|
PageFooter,
|
|
36
222
|
PageScroll,
|
|
223
|
+
SidebarTrigger,
|
|
37
224
|
} from '@sybilion/uilib';
|
|
38
225
|
|
|
226
|
+
import { AppSidebar } from './AppSidebar';
|
|
227
|
+
|
|
39
228
|
export function AppLayout({ children }: { children: ReactNode }) {
|
|
40
229
|
return (
|
|
41
230
|
<PageScroll>
|
|
42
231
|
<AppShell>
|
|
43
|
-
|
|
232
|
+
<AppSidebar />
|
|
233
|
+
|
|
44
234
|
<AppShellMainContent
|
|
45
235
|
header={<AppHeaderHost />}
|
|
46
|
-
footer={<PageFooter versionLink="/releases" versionLabel="0.0.
|
|
236
|
+
footer={<PageFooter versionLink="/releases" versionLabel="0.0.1" />}
|
|
47
237
|
>
|
|
48
238
|
<AppHeaderPortal>
|
|
239
|
+
<SidebarTrigger />
|
|
49
240
|
<Gap />
|
|
50
241
|
<NavUserHeader
|
|
51
242
|
theme="light"
|
|
@@ -62,118 +253,118 @@ export function AppLayout({ children }: { children: ReactNode }) {
|
|
|
62
253
|
}
|
|
63
254
|
```
|
|
64
255
|
|
|
65
|
-
Wire `NavUserHeader` to real auth (`useSybilionAuth`, theme context, etc
|
|
66
|
-
|
|
67
|
-
### Full pattern
|
|
68
|
-
|
|
69
|
-
Reference: `[src/docs/DocsShell.tsx](https://github.com/Mir-Insight/uilib/blob/main/src/docs/DocsShell.tsx)` — `PageScroll` → `AppShell` → sidebar → `AppShellMainContent` with `AppHeaderHost`, `PageFooter`, and routed body (`Outlet`). Add `Theme` and optional `SidebarProvider` from `@homecode/ui`.
|
|
70
|
-
|
|
71
|
-
### Glossary (high-use pieces)
|
|
256
|
+
Wire `NavUserHeader` to real auth (`useSybilionAuth`, theme context, etc.; §3). Demo in repo: `src/docs/pages/NavUserHeaderPage.tsx` (slug `nav-user-header`).
|
|
72
257
|
|
|
73
|
-
|
|
74
|
-
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
75
|
-
| `**PageScroll**` | Page-level vertical scroll wrapper; usual outer shell for the app body. |
|
|
76
|
-
| `**AppShell**` | Layout grid container; typically holds a sidebar (optional) + main column. |
|
|
77
|
-
| `**AppShellMainContent**` | Main column with slots: `header`, scrollable body (`children`), `footer`. |
|
|
78
|
-
| `**AppHeaderHost**` | Renders the top header anchor (DOM id `page-header`); stays empty on purpose. |
|
|
79
|
-
| `**AppHeaderPortal**` | Portals children into `AppHeaderHost` — put header actions, `NavUserHeader`, theme toggle here. |
|
|
80
|
-
| `**NavUserHeader**` | Header user menu: avatar, account dropdown, theme row, logout; **not** a bare `DropdownMenu`. |
|
|
81
|
-
| `**Sidebar`\*\* | Collapsible app sidebar / nav rail (pair with `SidebarProvider` from `@homecode/ui` when needed). |
|
|
82
|
-
| `**PageFooter**` | Standard app footer (logo, links, version badge). Requires `versionLink` + `versionLabel`. |
|
|
83
|
-
| `**Gap**` | Horizontal or vertical spacing primitive between flex children (prefer over raw margins). |
|
|
84
|
-
| `**SybilionAuthProvider**` | Auth0 + Sybilion JWT bootstrap for the whole app (see §3). |
|
|
85
|
-
| `**useSybilionAuth**` | Current user, tokens, login/logout inside the provider tree. |
|
|
86
|
-
| `**useSybilionApiFetch**` | Authenticated `fetch` to the Sybilion API using the stored JWT. |
|
|
87
|
-
| `**Breadcrumb**`, `**BreadcrumbList`, …\*\* | Accessible breadcrumb trail for the page header (compose `BreadcrumbItem` / `BreadcrumbLink` / `BreadcrumbPage`). |
|
|
88
|
-
| `**Button`**, `**Card\*\*` | Primary actions and grouped content blocks—default building blocks for screens inside the layout body. |
|
|
89
|
-
|
|
90
|
-
## 3. Auth (`SybilionAuthProvider`)
|
|
91
|
-
|
|
92
|
-
Use inside `BrowserRouter` if redirects hit a callback route.
|
|
258
|
+
#### Sidebar (`AppSidebar.tsx`)
|
|
93
259
|
|
|
94
|
-
|
|
260
|
+
App-specific sidebar component — keeps the navigation surface out of `AppLayout` so the shell stays generic. Compose your nav from `@sybilion/uilib` primitives (`Sidebar` + `SidebarContent` + `SidebarGroup` + `SidebarMenu*`) and product widgets like `SidebarDatasetsItemsGrouped` (collapsible groups + nested rows for datasets — see demo `src/docs/pages/SidebarDatasetsItemsGroupedPage.tsx`, slug `sidebar-datasets-items-grouped`).
|
|
95
261
|
|
|
96
262
|
```tsx
|
|
97
|
-
import {
|
|
263
|
+
import { useEffect, useState } from 'react';
|
|
264
|
+
import { NavLink, useNavigate } from 'react-router-dom';
|
|
98
265
|
|
|
99
|
-
import {
|
|
100
|
-
|
|
266
|
+
import {
|
|
267
|
+
Sidebar,
|
|
268
|
+
SidebarContent,
|
|
269
|
+
SidebarDatasetsItemsGrouped,
|
|
270
|
+
type SidebarDatasetsItemsGroupedDataset,
|
|
271
|
+
SidebarGroup,
|
|
272
|
+
SidebarMenu,
|
|
273
|
+
SidebarMenuButton,
|
|
274
|
+
SidebarMenuItem,
|
|
275
|
+
} from '@sybilion/uilib';
|
|
101
276
|
|
|
102
|
-
|
|
277
|
+
import { sybilionSdk } from './libs/sybilion-sdk';
|
|
103
278
|
|
|
104
|
-
export function
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
279
|
+
export function AppSidebar() {
|
|
280
|
+
const navigate = useNavigate();
|
|
281
|
+
const [datasets, setDatasets] = useState<
|
|
282
|
+
SidebarDatasetsItemsGroupedDataset[]
|
|
283
|
+
>([]);
|
|
284
|
+
const [selectedDatasetId, setSelectedDatasetId] = useState<number>();
|
|
108
285
|
|
|
109
|
-
|
|
110
|
-
() =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
getToken: () =>
|
|
115
|
-
typeof localStorage !== 'undefined'
|
|
116
|
-
? localStorage.getItem(sybilionJwtKey) ?? undefined
|
|
117
|
-
: undefined,
|
|
118
|
-
}),
|
|
119
|
-
[apiBaseUrl],
|
|
120
|
-
);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
sybilionSdk.raw.datasetsIndex(1, 50).then(res => {
|
|
288
|
+
setDatasets(res?.data?.datasets ?? []);
|
|
289
|
+
});
|
|
290
|
+
}, []);
|
|
121
291
|
|
|
122
292
|
return (
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
293
|
+
<Sidebar variant="inset" collapsible="offcanvas">
|
|
294
|
+
<SidebarContent>
|
|
295
|
+
<SidebarGroup>
|
|
296
|
+
<SidebarMenu>
|
|
297
|
+
<SidebarMenuItem>
|
|
298
|
+
<SidebarMenuButton asChild>
|
|
299
|
+
<NavLink to="/" end>
|
|
300
|
+
Home
|
|
301
|
+
</NavLink>
|
|
302
|
+
</SidebarMenuButton>
|
|
303
|
+
</SidebarMenuItem>
|
|
304
|
+
<SidebarMenuItem>
|
|
305
|
+
<SidebarMenuButton asChild>
|
|
306
|
+
<NavLink to="/datasets">Datasets</NavLink>
|
|
307
|
+
</SidebarMenuButton>
|
|
308
|
+
</SidebarMenuItem>
|
|
309
|
+
</SidebarMenu>
|
|
310
|
+
</SidebarGroup>
|
|
311
|
+
|
|
312
|
+
<SidebarDatasetsItemsGrouped
|
|
313
|
+
groupBy="regions"
|
|
314
|
+
datasets={datasets}
|
|
315
|
+
selectedDatasetId={selectedDatasetId}
|
|
316
|
+
onDatasetClick={id => {
|
|
317
|
+
setSelectedDatasetId(id);
|
|
318
|
+
navigate(`/datasets/${id}`);
|
|
319
|
+
}}
|
|
320
|
+
/>
|
|
321
|
+
</SidebarContent>
|
|
322
|
+
</Sidebar>
|
|
132
323
|
);
|
|
133
324
|
}
|
|
134
325
|
```
|
|
135
326
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
**Defaults** for `authorizationParams` match sybilion-client (Management audience + `openid profile email offline_access …`); override if your Auth0 SPA needs a Resource Server audience (backend confirms).
|
|
327
|
+
Data loading uses §2 `sybilionSdk` directly — for production swap the inline `useEffect` for your data layer (React Query, SWR, context, etc.). `groupBy` accepts `'regions' | 'target_type' | 'categories'`; the widget owns its expand state and notifies on selection via `onDatasetClick`.
|
|
139
328
|
|
|
140
|
-
|
|
141
|
-
| ---------------- | ------------------------------------------------------------------------------------- |
|
|
142
|
-
| **Auth0** | Callback, logout, and web origins → your URLs (+ previews). |
|
|
143
|
-
| **Sybilion API** | CORS → your deploy `Origin`. |
|
|
144
|
-
| **App** | `createSybilionSDK` (`baseUrl`, `apiPrefix`), pass **`sdk`** to `SybilionAuthProvider`; align `sybilionTokenStorageKey` with `getToken`; Auth0 `domain` / `clientId`; redirect usually `window.location.origin`. |
|
|
145
|
-
|
|
146
|
-
**Hooks:** `useSybilionAuth()`, `useSybilionApiFetch()` (or `createSybilionApiFetch` / `sybilionApiFetch` helpers).
|
|
147
|
-
|
|
148
|
-
## 4. Data
|
|
149
|
-
|
|
150
|
-
Fetch the Sybilion API with the JWT above (e.g. via `useSybilionApiFetch`).
|
|
151
|
-
|
|
152
|
-
### `@sybilion/sdk`
|
|
153
|
-
|
|
154
|
-
Install the typed client alongside uilib:
|
|
155
|
-
|
|
156
|
-
```bash
|
|
157
|
-
yarn add @sybilion/sdk
|
|
158
|
-
```
|
|
329
|
+
### Full pattern
|
|
159
330
|
|
|
160
|
-
|
|
331
|
+
Composition: `PageScroll` → `AppShell` → `AppSidebar` → `AppShellMainContent` with `AppHeaderHost`, `PageFooter`, and the active route as `children`. `SidebarProvider` wraps `AppLayout`. Add `Theme` from `@homecode/ui` only when your product uses those primitives alongside uilib.
|
|
161
332
|
|
|
162
|
-
|
|
163
|
-
import { createSybilionSDK } from '@sybilion/sdk';
|
|
333
|
+
### Greenfield checklist (agents)
|
|
164
334
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
})
|
|
170
|
-
|
|
335
|
+
| Step | Deliverable |
|
|
336
|
+
| ---- | ----------- |
|
|
337
|
+
| Env files | **`.env.example`** (committed) + **`.env`** locally; **`PORT=3000`** recommended for Auth0 test tenant; **`VITE_*`** as in §1 table. |
|
|
338
|
+
| Scripts | **`package.json`** includes mandatory **`dev`** (e.g. `"vite"`); optional **`build`**, **`preview`**. |
|
|
339
|
+
| Vite proxy | **`vite.config.ts`** spreads **`sybilionStandaloneViteDev({ mode })`**; SDK **`baseUrl`** empty in dev (§2). |
|
|
340
|
+
| Files | `src/libs/sybilion-sdk.ts`, `AppProviders.tsx`, `AppLayout.tsx`, `AppSidebar.tsx`, `App.tsx`, `main.tsx`, route pages under e.g. `src/pages/`. |
|
|
341
|
+
| Auth0 | SPA callback / logout URLs + allowed web origins → **`http://localhost:<PORT>`** for local dev and deploy URLs (and previews). |
|
|
342
|
+
| API | **Dev:** proxy handles API traffic (no browser CORS to API). **Prod:** Sybilion backend **CORS** → your deploy `Origin`, unless API is same-origin. |
|
|
343
|
+
| Go (if applicable) | Server proxies **`/api`** to Sybilion; SPA dev **`baseUrl`** stays `''` when same-origin. |
|
|
171
344
|
|
|
172
|
-
|
|
173
|
-
- **`sdk.raw`** — thin `GET`/`POST`/… wrappers for `/v1/...` paths (e.g. `raw.analyses.driversMapOnce`; returns parsed JSON, no app-specific shaping).
|
|
174
|
-
- **`sdk.resources`** — higher-level helpers (datasets, drivers, subscriptions) that compose `raw` calls with parsing where applicable.
|
|
345
|
+
### Glossary (high-use pieces)
|
|
175
346
|
|
|
176
|
-
|
|
347
|
+
| Component / API | What it is for |
|
|
348
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
349
|
+
| `PageScroll` | Page-level vertical scroll wrapper; usual outer shell for the app body. |
|
|
350
|
+
| `AppShell` | Layout grid container; `Sidebar` + `AppShellMainContent` as siblings inside it. |
|
|
351
|
+
| `AppShellMainContent` | Main column: `header`, scrollable body (`children`), `footer`. |
|
|
352
|
+
| `AppHeaderHost` | Top header anchor (DOM id `page-header`); stays empty until `AppHeaderPortal` portals into it. |
|
|
353
|
+
| `AppHeaderPortal` | Portals into `AppHeaderHost` — `SidebarTrigger`, `NavUserHeader`, theme toggle. |
|
|
354
|
+
| `NavUserHeader` | Header user menu (avatar, account, theme, logout). |
|
|
355
|
+
| `Sidebar`, `SidebarProvider` | Collapsible rail + context (`@sybilion/uilib`). Wrap `SidebarProvider` above `AppLayout`; render `Sidebar` inside `AppShell` (usually via `AppSidebar`). |
|
|
356
|
+
| `AppSidebar` | App-specific component (`src/AppSidebar.tsx`) composing `Sidebar` + nav links + product widgets. Keeps `AppLayout` generic. |
|
|
357
|
+
| `SidebarDatasetsItemsGrouped` | Dataset list widget for the sidebar: collapsible groups (`regions` / `target_type` / `categories`) with nested rows + selection callback. |
|
|
358
|
+
| `SidebarTrigger` | Toggle sidebar visibility (especially mobile / `offcanvas`). |
|
|
359
|
+
| `PageFooter` | Standard footer; requires `versionLink` + `versionLabel`. |
|
|
360
|
+
| `Gap` | Spacing primitive between flex children. |
|
|
361
|
+
| `SybilionAuthProvider` | Auth0 + Sybilion JWT (§3). |
|
|
362
|
+
| `useSybilionAuth` | User session + login/logout under provider. |
|
|
363
|
+
| `useSybilionApiFetch` | Authenticated `fetch` using stored JWT. |
|
|
364
|
+
|
|
365
|
+
## 5. Data
|
|
366
|
+
|
|
367
|
+
Inside `SybilionAuthProvider`, use `useSybilionApiFetch()` for authenticated `fetch`, or import `sybilionSdk` from §2 for `raw` / `resources` — same JWT storage and API origin either way.
|
|
177
368
|
|
|
178
369
|
## Related
|
|
179
370
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sybilion/uilib",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Sybilion Design System — React UI components (Webpack + Stylus)",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -30,7 +30,12 @@
|
|
|
30
30
|
"default": "./src/index.ts"
|
|
31
31
|
},
|
|
32
32
|
"./src/*": "./src/*",
|
|
33
|
-
"./standalone-global.css": "./assets/standalone-global.css"
|
|
33
|
+
"./standalone-global.css": "./assets/standalone-global.css",
|
|
34
|
+
"./vite-standalone-dev": {
|
|
35
|
+
"types": "./dist/standalone/vite-sybilion-standalone-dev.d.ts",
|
|
36
|
+
"import": "./dist/standalone/vite-sybilion-standalone-dev.js",
|
|
37
|
+
"default": "./dist/standalone/vite-sybilion-standalone-dev.js"
|
|
38
|
+
}
|
|
34
39
|
},
|
|
35
40
|
"files": [
|
|
36
41
|
"assets",
|
|
@@ -109,7 +114,8 @@
|
|
|
109
114
|
"@sybilion/sdk": ">=0.0.1",
|
|
110
115
|
"react": ">=18.0.0",
|
|
111
116
|
"react-dom": ">=18.0.0",
|
|
112
|
-
"react-router-dom": ">=6.0.0"
|
|
117
|
+
"react-router-dom": ">=6.0.0",
|
|
118
|
+
"vite": "^6.0.0"
|
|
113
119
|
},
|
|
114
120
|
"peerDependenciesMeta": {
|
|
115
121
|
"@auth0/auth0-react": {
|
|
@@ -117,6 +123,9 @@
|
|
|
117
123
|
},
|
|
118
124
|
"@sybilion/sdk": {
|
|
119
125
|
"optional": true
|
|
126
|
+
},
|
|
127
|
+
"vite": {
|
|
128
|
+
"optional": true
|
|
120
129
|
}
|
|
121
130
|
},
|
|
122
131
|
"devDependencies": {
|
|
@@ -178,6 +187,7 @@
|
|
|
178
187
|
"ts-jest": "^29.2.5",
|
|
179
188
|
"ts-node": "^10.9.1",
|
|
180
189
|
"typescript": "^5.3.3",
|
|
190
|
+
"vite": "^6.0.0",
|
|
181
191
|
"webpack": "^5.75.0",
|
|
182
192
|
"webpack-cli": "^5.0.1",
|
|
183
193
|
"webpack-dev-server": "^5.2.3"
|
|
@@ -20,14 +20,18 @@ export function AppHeaderHost({
|
|
|
20
20
|
|
|
21
21
|
export type AppHeaderPortalProps = {
|
|
22
22
|
children: ReactNode;
|
|
23
|
+
pageHeaderId?: string;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
export function AppHeaderPortal({
|
|
26
|
+
export function AppHeaderPortal({
|
|
27
|
+
children,
|
|
28
|
+
pageHeaderId = PAGE_HEADER_ID,
|
|
29
|
+
}: AppHeaderPortalProps) {
|
|
26
30
|
const [container, setContainer] = useState<HTMLElement | null>(null);
|
|
27
31
|
|
|
28
32
|
useLayoutEffect(() => {
|
|
29
|
-
setContainer(document.getElementById(
|
|
30
|
-
}, []);
|
|
33
|
+
setContainer(document.getElementById(pageHeaderId));
|
|
34
|
+
}, [pageHeaderId]);
|
|
31
35
|
|
|
32
36
|
if (!container) {
|
|
33
37
|
return null;
|
|
@@ -9,9 +9,12 @@ import S from './PageScroll.styl';
|
|
|
9
9
|
|
|
10
10
|
function PageScrollInner({
|
|
11
11
|
className,
|
|
12
|
+
rootClassName,
|
|
12
13
|
children,
|
|
13
14
|
}: {
|
|
14
15
|
className?: string;
|
|
16
|
+
/** Merged onto the outer Scroll root (e.g. embed previews that must not use `100vh`). */
|
|
17
|
+
rootClassName?: string;
|
|
15
18
|
children: React.ReactNode;
|
|
16
19
|
}) {
|
|
17
20
|
// const { setIsScrolled } = useContext(PageContext);
|
|
@@ -38,7 +41,7 @@ function PageScrollInner({
|
|
|
38
41
|
y
|
|
39
42
|
// fadeSize="l"
|
|
40
43
|
offset={{ y: { before: 120, after: 130 } }}
|
|
41
|
-
className={S.root}
|
|
44
|
+
className={cn(S.root, rootClassName)}
|
|
42
45
|
autoHide
|
|
43
46
|
innerClassName={cn(S.inner, className)}
|
|
44
47
|
yScrollbarClassName={S.scrollbar}
|
|
@@ -55,15 +58,19 @@ function PageScrollInner({
|
|
|
55
58
|
export const PageScroll = ({
|
|
56
59
|
children,
|
|
57
60
|
className,
|
|
61
|
+
rootClassName,
|
|
58
62
|
}: {
|
|
59
63
|
children: React.ReactNode;
|
|
60
64
|
className?: string;
|
|
65
|
+
rootClassName?: string;
|
|
61
66
|
}) => {
|
|
62
67
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
63
68
|
|
|
64
69
|
return (
|
|
65
70
|
<PageContext.Provider value={{ isScrolled, setIsScrolled }}>
|
|
66
|
-
<PageScrollInner className={className}
|
|
71
|
+
<PageScrollInner className={className} rootClassName={rootClassName}>
|
|
72
|
+
{children}
|
|
73
|
+
</PageScrollInner>
|
|
67
74
|
</PageContext.Provider>
|
|
68
75
|
);
|
|
69
76
|
};
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
export type SidebarDatasetsItemsGroupedProps = {
|
|
23
23
|
groupBy: SidebarDatasetsItemsGroupBy;
|
|
24
24
|
datasets: SidebarDatasetsItemsGroupedDataset[];
|
|
25
|
+
preItems?: React.ReactNode;
|
|
26
|
+
postItems?: React.ReactNode;
|
|
25
27
|
selectedDatasetId?: number;
|
|
26
28
|
onDatasetClick?: (datasetId: number) => void;
|
|
27
29
|
/** When omitted, all groups start expanded. */
|
|
@@ -32,6 +34,8 @@ export type SidebarDatasetsItemsGroupedProps = {
|
|
|
32
34
|
export function SidebarDatasetsItemsGrouped({
|
|
33
35
|
groupBy,
|
|
34
36
|
datasets,
|
|
37
|
+
preItems,
|
|
38
|
+
postItems,
|
|
35
39
|
selectedDatasetId,
|
|
36
40
|
onDatasetClick,
|
|
37
41
|
defaultExpandedGroupNames,
|
|
@@ -74,6 +78,8 @@ export function SidebarDatasetsItemsGrouped({
|
|
|
74
78
|
return (
|
|
75
79
|
<SidebarGroup className={className}>
|
|
76
80
|
<SidebarMenu>
|
|
81
|
+
{preItems}
|
|
82
|
+
|
|
77
83
|
{grouped.map(([groupName, groupDatasets]) => {
|
|
78
84
|
const isExpanded = expanded.has(groupName);
|
|
79
85
|
const parentActive = groupDatasets.some(
|
|
@@ -97,6 +103,7 @@ export function SidebarDatasetsItemsGrouped({
|
|
|
97
103
|
)}
|
|
98
104
|
</div>
|
|
99
105
|
</SidebarMenuButton>
|
|
106
|
+
|
|
100
107
|
{isExpanded && (
|
|
101
108
|
<SidebarMenuSub className={S.subMenuContainer}>
|
|
102
109
|
<div className={S.subMenuBorder} />
|
|
@@ -122,6 +129,8 @@ export function SidebarDatasetsItemsGrouped({
|
|
|
122
129
|
</SidebarMenuItem>
|
|
123
130
|
);
|
|
124
131
|
})}
|
|
132
|
+
|
|
133
|
+
{postItems}
|
|
125
134
|
</SidebarMenu>
|
|
126
135
|
</SidebarGroup>
|
|
127
136
|
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
@import '../../lib/theme.styl'
|
|
2
|
+
|
|
3
|
+
// Embed preview: Sidebar uses position:fixed + viewport top/height; PageScroll uses 100vh.
|
|
4
|
+
// Transform creates a containing block for `fixed` children; overrides stop bleed into docs chrome.
|
|
5
|
+
.preview
|
|
6
|
+
position relative
|
|
7
|
+
overflow hidden
|
|
8
|
+
transform translateZ(0)
|
|
9
|
+
border-radius var(--p-4)
|
|
10
|
+
border 1px solid var(--border)
|
|
11
|
+
background-color var(--background)
|
|
12
|
+
height min(720px, 80vh)
|
|
13
|
+
min-height 0
|
|
14
|
+
display flex
|
|
15
|
+
flex-direction column
|
|
16
|
+
|
|
17
|
+
.previewScrollRoot
|
|
18
|
+
flex 1
|
|
19
|
+
min-height 0
|
|
20
|
+
height 100% !important
|
|
21
|
+
max-height 100% !important
|
|
22
|
+
|
|
23
|
+
@media (max-width MOBILE)
|
|
24
|
+
height auto !important
|
|
25
|
+
flex 1
|
|
26
|
+
|
|
27
|
+
.preview aside[data-side="left"]
|
|
28
|
+
top 0 !important
|
|
29
|
+
left 0 !important
|
|
30
|
+
height 100% !important
|
|
31
|
+
min-height 0 !important
|
|
32
|
+
|
|
33
|
+
@media (min-width MOBILE)
|
|
34
|
+
height 100% !important
|
|
35
|
+
|
|
36
|
+
.preview aside[data-side="left"] > div:first-child
|
|
37
|
+
height 100% !important
|
|
38
|
+
max-height 100% !important
|
|
39
|
+
|
|
40
|
+
@media (min-width MOBILE)
|
|
41
|
+
height 100% !important
|
|
42
|
+
|
|
43
|
+
.preview [data-slot="sidebar-resize-handle"]
|
|
44
|
+
top 0 !important
|
|
45
|
+
height 100% !important
|
|
46
|
+
max-height 100%
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AppHeaderHost, AppHeaderPortal } from '#uilib/components/ui/AppHeader';
|
|
4
|
+
import { Gap } from '#uilib/components/ui/Gap/Gap';
|
|
5
|
+
import { NavUserHeader } from '#uilib/components/ui/NavUserHeader/NavUserHeader';
|
|
6
|
+
import {
|
|
7
|
+
AppShell,
|
|
8
|
+
AppShellMainContent,
|
|
9
|
+
PageContentSection,
|
|
10
|
+
} from '#uilib/components/ui/Page';
|
|
11
|
+
import { PageFooter } from '#uilib/components/ui/Page/PageFooter/PageFooter';
|
|
12
|
+
import { PageScroll } from '#uilib/components/ui/Page/PageScroll/PageScroll';
|
|
13
|
+
import {
|
|
14
|
+
Sidebar,
|
|
15
|
+
SidebarContent,
|
|
16
|
+
SidebarGroup,
|
|
17
|
+
SidebarMenu,
|
|
18
|
+
SidebarMenuButton,
|
|
19
|
+
SidebarMenuItem,
|
|
20
|
+
SidebarProvider,
|
|
21
|
+
SidebarTrigger,
|
|
22
|
+
} from '#uilib/components/ui/Sidebar/Sidebar';
|
|
23
|
+
import {
|
|
24
|
+
SidebarDatasetsItemsGrouped,
|
|
25
|
+
type SidebarDatasetsItemsGroupedDataset,
|
|
26
|
+
} from '#uilib/components/widgets/SidebarDatasetsItemsGrouped';
|
|
27
|
+
import { House } from 'lucide-react';
|
|
28
|
+
|
|
29
|
+
import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
|
|
30
|
+
import { DocsHeaderActions } from '../docsHeaderActions';
|
|
31
|
+
import S from './StandaloneAppLayoutPage.styl';
|
|
32
|
+
|
|
33
|
+
const MOCK_DATASETS: SidebarDatasetsItemsGroupedDataset[] = [
|
|
34
|
+
{
|
|
35
|
+
id: 1,
|
|
36
|
+
name: 'Acetic Acid Price - China - Dollar/MT',
|
|
37
|
+
status: 'active',
|
|
38
|
+
created_at: '2024-01-01',
|
|
39
|
+
updated_at: '2024-01-02',
|
|
40
|
+
keywords: '',
|
|
41
|
+
category: { id: 10, name: 'Chemicals' },
|
|
42
|
+
target_type_id: 1,
|
|
43
|
+
target_type: { id: 1, name: 'Commodity price' },
|
|
44
|
+
trend: 0,
|
|
45
|
+
regular_price: '0',
|
|
46
|
+
sale_price: '0',
|
|
47
|
+
regions: [
|
|
48
|
+
{ id: 1, name: 'Asia' },
|
|
49
|
+
{ id: 2, name: 'China' },
|
|
50
|
+
],
|
|
51
|
+
unit: { id: 1, name: 'USD/MT' },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 2,
|
|
55
|
+
name: 'Freight index — spot volume (China)',
|
|
56
|
+
status: 'active',
|
|
57
|
+
created_at: '2024-01-01',
|
|
58
|
+
updated_at: '2024-01-02',
|
|
59
|
+
keywords: '',
|
|
60
|
+
category: { id: 10, name: 'Chemicals' },
|
|
61
|
+
target_type_id: 2,
|
|
62
|
+
target_type: { id: 2, name: 'Freight index' },
|
|
63
|
+
trend: 0,
|
|
64
|
+
regular_price: '0',
|
|
65
|
+
sale_price: '0',
|
|
66
|
+
regions: [
|
|
67
|
+
{ id: 1, name: 'Asia' },
|
|
68
|
+
{ id: 2, name: 'China' },
|
|
69
|
+
],
|
|
70
|
+
unit: { id: 1, name: 'Index' },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 3,
|
|
74
|
+
name: 'Ethanol Price Europe Per Kg In USD',
|
|
75
|
+
status: 'active',
|
|
76
|
+
created_at: '2024-01-01',
|
|
77
|
+
updated_at: '2024-01-02',
|
|
78
|
+
keywords: '',
|
|
79
|
+
category: { id: 11, name: 'Energy' },
|
|
80
|
+
target_type_id: 1,
|
|
81
|
+
target_type: { id: 1, name: 'Commodity price' },
|
|
82
|
+
trend: 0,
|
|
83
|
+
regular_price: '0',
|
|
84
|
+
sale_price: '0',
|
|
85
|
+
regions: [{ id: 5, name: 'Europe' }],
|
|
86
|
+
unit: { id: 1, name: 'USD/kg' },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
type PreviewPanel = 'home' | 'datasets';
|
|
91
|
+
|
|
92
|
+
const TEST_HEADER_ID = 'test-header-id';
|
|
93
|
+
|
|
94
|
+
function DemoAppSidebar({
|
|
95
|
+
panel,
|
|
96
|
+
onSelectPanel,
|
|
97
|
+
selectedDatasetId,
|
|
98
|
+
onSelectDatasetId,
|
|
99
|
+
}: {
|
|
100
|
+
panel: PreviewPanel;
|
|
101
|
+
onSelectPanel: (p: PreviewPanel) => void;
|
|
102
|
+
selectedDatasetId: number | undefined;
|
|
103
|
+
onSelectDatasetId: (id: number | undefined) => void;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<Sidebar
|
|
107
|
+
variant="inset"
|
|
108
|
+
collapsible="offcanvas"
|
|
109
|
+
style={{ maxHeight: '100%', height: '100%' }}
|
|
110
|
+
>
|
|
111
|
+
<SidebarContent>
|
|
112
|
+
<SidebarDatasetsItemsGrouped
|
|
113
|
+
preItems={
|
|
114
|
+
<SidebarMenuItem>
|
|
115
|
+
<SidebarMenuButton
|
|
116
|
+
type="button"
|
|
117
|
+
isActive={panel === 'home'}
|
|
118
|
+
onClick={() => onSelectPanel('home')}
|
|
119
|
+
>
|
|
120
|
+
<House size={16} strokeWidth={1.75} aria-hidden />
|
|
121
|
+
Home
|
|
122
|
+
</SidebarMenuButton>
|
|
123
|
+
</SidebarMenuItem>
|
|
124
|
+
}
|
|
125
|
+
groupBy="regions"
|
|
126
|
+
datasets={MOCK_DATASETS}
|
|
127
|
+
selectedDatasetId={selectedDatasetId}
|
|
128
|
+
onDatasetClick={id => {
|
|
129
|
+
onSelectDatasetId(id);
|
|
130
|
+
onSelectPanel('datasets');
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</SidebarContent>
|
|
134
|
+
</Sidebar>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function DemoMainBody({ panel }: { panel: PreviewPanel }) {
|
|
139
|
+
if (panel === 'home') {
|
|
140
|
+
return (
|
|
141
|
+
<div style={{ padding: 'var(--p-6)' }}>
|
|
142
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.125rem' }}>Home</h2>
|
|
143
|
+
<p style={{ margin: 0, color: 'var(--muted-foreground)' }}>
|
|
144
|
+
Preview: greenfield shell layout only (no SDK or auth). Real SPA uses
|
|
145
|
+
one top-level Router; this embed cannot nest MemoryRouter under docs
|
|
146
|
+
BrowserRouter — sidebar uses local state instead of{' '}
|
|
147
|
+
<code>NavLink</code> / <code>Routes</code>.
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div style={{ padding: 'var(--p-6)' }}>
|
|
155
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.125rem' }}>Datasets</h2>
|
|
156
|
+
<p style={{ margin: 0, color: 'var(--muted-foreground)' }}>
|
|
157
|
+
Use sidebar links and dataset rows; panel state stays inside this box.
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function StandaloneLayoutPreview() {
|
|
164
|
+
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
|
165
|
+
const [panel, setPanel] = useState<PreviewPanel>('home');
|
|
166
|
+
const [selectedDatasetId, setSelectedDatasetId] = useState<
|
|
167
|
+
number | undefined
|
|
168
|
+
>();
|
|
169
|
+
|
|
170
|
+
const onThemeToggle = useCallback(() => {
|
|
171
|
+
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className={S.preview}>
|
|
176
|
+
<PageScroll rootClassName={S.previewScrollRoot}>
|
|
177
|
+
<AppShell>
|
|
178
|
+
<DemoAppSidebar
|
|
179
|
+
panel={panel}
|
|
180
|
+
onSelectPanel={setPanel}
|
|
181
|
+
selectedDatasetId={selectedDatasetId}
|
|
182
|
+
onSelectDatasetId={setSelectedDatasetId}
|
|
183
|
+
/>
|
|
184
|
+
|
|
185
|
+
<AppShellMainContent
|
|
186
|
+
header={<AppHeaderHost anchorId={TEST_HEADER_ID} />}
|
|
187
|
+
footer={
|
|
188
|
+
<PageFooter
|
|
189
|
+
versionLink="#standalone-layout-preview"
|
|
190
|
+
versionLabel="0.0.0-preview"
|
|
191
|
+
/>
|
|
192
|
+
}
|
|
193
|
+
>
|
|
194
|
+
<AppHeaderPortal pageHeaderId={TEST_HEADER_ID}>
|
|
195
|
+
<SidebarTrigger />
|
|
196
|
+
<Gap />
|
|
197
|
+
<NavUserHeader
|
|
198
|
+
user={{
|
|
199
|
+
name: 'Preview User',
|
|
200
|
+
email: 'preview@example.com',
|
|
201
|
+
avatar: '',
|
|
202
|
+
}}
|
|
203
|
+
theme={theme}
|
|
204
|
+
onThemeToggle={onThemeToggle}
|
|
205
|
+
onLogout={() => undefined}
|
|
206
|
+
/>
|
|
207
|
+
</AppHeaderPortal>
|
|
208
|
+
<DemoMainBody panel={panel} />
|
|
209
|
+
</AppShellMainContent>
|
|
210
|
+
</AppShell>
|
|
211
|
+
</PageScroll>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default function StandaloneAppLayoutPage() {
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<AppPageHeader
|
|
220
|
+
breadcrumbs={[{ label: 'Standalone app layout' }]}
|
|
221
|
+
title="Standalone app layout"
|
|
222
|
+
subheader={
|
|
223
|
+
<>
|
|
224
|
+
Live preview of AppShell + Sidebar + main column from
|
|
225
|
+
docs/standalone-apps.md §4. <br />
|
|
226
|
+
Full greenfield setup (deps, global CSS, SybilionAuthProvider, SDK)
|
|
227
|
+
lives in that doc — this page is layout only.
|
|
228
|
+
</>
|
|
229
|
+
}
|
|
230
|
+
actions={<DocsHeaderActions />}
|
|
231
|
+
/>
|
|
232
|
+
<PageContentSection title="Embedded mini-app (layout preview)">
|
|
233
|
+
<SidebarProvider
|
|
234
|
+
sidebarWidthStorageKey="uilib.docs.standaloneLayout.sidebarWidthPx"
|
|
235
|
+
persistSidebarWidthWithoutConsent
|
|
236
|
+
>
|
|
237
|
+
<StandaloneLayoutPreview />
|
|
238
|
+
</SidebarProvider>
|
|
239
|
+
</PageContentSection>
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|
package/src/docs/registry.ts
CHANGED
|
@@ -216,6 +216,12 @@ export const DOC_REGISTRY: DocEntry[] = [
|
|
|
216
216
|
section: 'Layout',
|
|
217
217
|
load: () => import('./pages/PagePage'),
|
|
218
218
|
},
|
|
219
|
+
{
|
|
220
|
+
slug: 'standalone-app-layout',
|
|
221
|
+
title: 'Standalone app layout',
|
|
222
|
+
section: 'Layout',
|
|
223
|
+
load: () => import('./pages/StandaloneAppLayoutPage'),
|
|
224
|
+
},
|
|
219
225
|
{
|
|
220
226
|
slug: 'progress',
|
|
221
227
|
title: 'Progress',
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { UserConfig } from 'vite';
|
|
2
|
+
import { loadEnv } from 'vite';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PORT = 3000;
|
|
5
|
+
const SYBILION_API_ENV = 'VITE_SYBILION_API_BASE_URL';
|
|
6
|
+
|
|
7
|
+
export type SybilionStandaloneViteDevOptions = {
|
|
8
|
+
mode: string;
|
|
9
|
+
/** Directory containing `.env*` files. @default process.cwd() */
|
|
10
|
+
envDir?: string;
|
|
11
|
+
/** Prefix proxied to Sybilion API (SDK `apiPrefix`). @default `/api` */
|
|
12
|
+
apiPrefix?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let warnedMissingApiUrl = false;
|
|
16
|
+
|
|
17
|
+
function parsePort(raw: string | undefined): number {
|
|
18
|
+
if (raw == null || raw === '') return DEFAULT_PORT;
|
|
19
|
+
const n = Number.parseInt(raw, 10);
|
|
20
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65_535) return DEFAULT_PORT;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeApiPrefix(apiPrefix: string): string {
|
|
25
|
+
return apiPrefix.startsWith('/') ? apiPrefix : `/${apiPrefix}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Vite `server` + `preview` fragment for standalone Sybilion SPAs: same-origin `/api` in dev,
|
|
30
|
+
* proxied to `VITE_SYBILION_API_BASE_URL`. Uses `PORT` from env (default `3000`).
|
|
31
|
+
*/
|
|
32
|
+
export function sybilionStandaloneViteDev(
|
|
33
|
+
options: SybilionStandaloneViteDevOptions,
|
|
34
|
+
): Pick<UserConfig, 'server' | 'preview'> {
|
|
35
|
+
const envDir = options.envDir ?? process.cwd();
|
|
36
|
+
const apiPrefix = normalizeApiPrefix(options.apiPrefix ?? '/api');
|
|
37
|
+
const env = loadEnv(options.mode, envDir, '');
|
|
38
|
+
const port = parsePort(env.PORT);
|
|
39
|
+
const target = (env[SYBILION_API_ENV] ?? '').replace(/\/$/, '');
|
|
40
|
+
|
|
41
|
+
const proxy: NonNullable<UserConfig['server']>['proxy'] = {};
|
|
42
|
+
|
|
43
|
+
if (target) {
|
|
44
|
+
proxy[apiPrefix] = {
|
|
45
|
+
target,
|
|
46
|
+
changeOrigin: true,
|
|
47
|
+
secure: true,
|
|
48
|
+
};
|
|
49
|
+
} else if (options.mode === 'development' && !warnedMissingApiUrl) {
|
|
50
|
+
warnedMissingApiUrl = true;
|
|
51
|
+
console.warn(
|
|
52
|
+
`[@sybilion/uilib] ${SYBILION_API_ENV} is not set; API dev proxy disabled.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const serverPreview = {
|
|
57
|
+
port,
|
|
58
|
+
proxy,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
server: serverPreview,
|
|
63
|
+
preview: serverPreview,
|
|
64
|
+
};
|
|
65
|
+
}
|