@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.
@@ -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(PAGE_HEADER_ID));
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 };
@@ -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: jsx(SidebarMenu, { children: 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
- }) }) }));
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;
@@ -1,4 +1,5 @@
1
- export declare const PageScroll: ({ children, className, }: {
1
+ export declare const PageScroll: ({ children, className, rootClassName, }: {
2
2
  children: React.ReactNode;
3
3
  className?: string;
4
+ rootClassName?: string;
4
5
  }) => import("react/jsx-runtime").JSX.Element;
@@ -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 };
@@ -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, **SybilionAuthProvider** + Sybilion API for data—no iframe in the main client.
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:** Use `AppShell` + `AppShellMainContent`; stick to uilib spacing primitives (e.g. `Gap`) instead of ad hoc root horizontal gutters.
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
- ## 2. Layout (AppShell)
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
- ### Minimal example
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
- `AppHeaderHost` is the empty header bar; put triggers and `NavUserHeader` (a.k.a. the user / account menu) inside `AppHeaderPortal` so they render in that bar.
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
- {/* Optional: sidebar as a sibling before main, e.g. <Sidebar /> */}
232
+ <AppSidebar />
233
+
44
234
  <AppShellMainContent
45
235
  header={<AppHeaderHost />}
46
- footer={<PageFooter versionLink="/releases" versionLabel="0.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.). Demo page in repo: `src/docs/pages/NavUserHeaderPage.tsx` (slug `nav-user-header`).
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
- | Component / API | What it is for |
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
- Env vars depend on bundlerfor **Vite** only `import.meta.env.VITE_` is exposed client-side:
260
+ App-specific sidebar componentkeeps 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 { useMemo, type ReactNode } from 'react';
263
+ import { useEffect, useState } from 'react';
264
+ import { NavLink, useNavigate } from 'react-router-dom';
98
265
 
99
- import { SybilionAuthProvider } from '@sybilion/uilib';
100
- import { createSybilionSDK } from '@sybilion/sdk';
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
- const sybilionJwtKey = 'sybilion.standalone.jwt';
277
+ import { sybilionSdk } from './libs/sybilion-sdk';
103
278
 
104
- export function AppProviders({ children }: { children: ReactNode }) {
105
- const apiBaseUrl = import.meta.env.VITE_SYBILION_API_BASE_URL as string;
106
- const auth0Domain = import.meta.env.VITE_AUTH0_DOMAIN as string;
107
- const auth0ClientId = import.meta.env.VITE_AUTH0_CLIENT_ID as string;
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
- const sdk = useMemo(
110
- () =>
111
- createSybilionSDK({
112
- baseUrl: apiBaseUrl,
113
- apiPrefix: '/api',
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
- <SybilionAuthProvider
124
- sdk={sdk}
125
- sybilionTokenStorageKey={sybilionJwtKey}
126
- auth0Domain={auth0Domain}
127
- auth0ClientId={auth0ClientId}
128
- redirectUri={window.location.origin}
129
- >
130
- {children}
131
- </SybilionAuthProvider>
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
- **Flow:** Auth0 SPA `sdk.auth.loginWithAuth0Identity(<Auth0 AT>)` Sybilion JWT (`data.token` / `token`) persisted same `sdk` + `getToken` supply Bearer on API calls; `useSybilionApiFetch()` uses `getSybilionApiOriginFromSdk(sdk)` for URLs (paths like `/api/v1/...`).
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
- | Layer | Configure |
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
- Configure **`createSybilionSDK`** with the same instance you pass to **`SybilionAuthProvider`** (`sdk` prop). Use **`baseUrl`**: API origin only (no trailing slash), default **`apiPrefix: '/api'`** so requests hit `{baseUrl}/api/v1/...`. **`getToken`** should read the same storage key as **`sybilionTokenStorageKey`** on the provider (default `sybilion.standalone.jwt`).
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
- ```ts
163
- import { createSybilionSDK } from '@sybilion/sdk';
333
+ ### Greenfield checklist (agents)
164
334
 
165
- const sdk = createSybilionSDK({
166
- baseUrl: apiBaseUrl,
167
- apiPrefix: '/api',
168
- getToken: () => localStorage.getItem('sybilion.standalone.jwt') ?? undefined,
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
- - **`sdk.auth`** — `loginWithAuth0Identity`, `getMe`, `updateMe` (Auth0 bootstrap + user profile).
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
- See the package README: [`@sybilion/sdk`](https://www.npmjs.com/package/@sybilion/sdk) in this monorepo, [`../../sdk/README.md`](../../sdk/README.md).
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.1",
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({ children }: AppHeaderPortalProps) {
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(PAGE_HEADER_ID));
30
- }, []);
33
+ setContainer(document.getElementById(pageHeaderId));
34
+ }, [pageHeaderId]);
31
35
 
32
36
  if (!container) {
33
37
  return null;
@@ -5,6 +5,7 @@
5
5
  flex 1
6
6
  min-height 0
7
7
  width 100%
8
+ min-height 100%
8
9
  grid-template-columns 1fr
9
10
  grid-template-areas "main"
10
11
 
@@ -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}>{children}</PageScrollInner>
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,8 @@
1
+ // This file is automatically generated.
2
+ // Please do not change this file!
3
+ interface CssExports {
4
+ 'preview': string;
5
+ 'previewScrollRoot': string;
6
+ }
7
+ export const cssExports: CssExports;
8
+ export default cssExports;
@@ -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
+ }
@@ -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
+ }