Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node test runner persisted localStorage (see npm test script)
|
||||||
|
.node-localstorage
|
||||||
|
|
||||||
|
# Logs and debug
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Vite / tooling cache
|
||||||
|
.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Tamagui (generated by build / vite plugin)
|
||||||
|
.tamagui/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# @reliancy/bface
|
||||||
|
|
||||||
|
Base UI and platform library for building Progressive Web Applications (PWAs) with React and Tamagui: shells, routing, env/config, storage, menus, security primitives, and data helpers.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @reliancy/bface
|
||||||
|
```
|
||||||
|
|
||||||
|
If your organization hosts this package on a private registry, configure npm for that registry (see `publishConfig` in `package.json`) before installing.
|
||||||
|
|
||||||
|
## Peer dependencies
|
||||||
|
|
||||||
|
Install React in the consuming app (versions should match `peerDependencies` in `package.json`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react react-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
Tamagui packages are **dependencies** of `@reliancy/bface`, so you do not need to add `@tamagui/*` or `tamagui` separately unless you want a single shared version across packages (then align versions with this library).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Basic App Setup
|
||||||
|
|
||||||
|
The library provides an `App` component that manages the application shell, routing, theming, and platform services.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { App } from '@reliancy/bface/ui/App';
|
||||||
|
import { CONFIG_KEYS } from '@reliancy/bface/platform/env';
|
||||||
|
import { registerServiceWorker } from '@reliancy/bface/platform/sw-register';
|
||||||
|
|
||||||
|
async function handleInit(services, { initialProfile } = {}) {
|
||||||
|
// Application profile (camelCase or snake_case field names are accepted where noted in env mapping)
|
||||||
|
const profile = {
|
||||||
|
name: 'MyApp',
|
||||||
|
displayName: 'My Application',
|
||||||
|
brandLogo: '/logo.svg',
|
||||||
|
ui_shell: 'DashboardShell',
|
||||||
|
modules: ['core'],
|
||||||
|
storage: { backend: 'localStorage' },
|
||||||
|
api: { baseURL: '/api' }
|
||||||
|
};
|
||||||
|
|
||||||
|
services.env.initEnv(profile);
|
||||||
|
|
||||||
|
for (const moduleName of profile.modules) {
|
||||||
|
// await loadModule(moduleName, services);
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerServiceWorker();
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MyApp() {
|
||||||
|
return <App onInit={handleInit} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using platform services
|
||||||
|
|
||||||
|
`App` calls `onInit(services, { initialProfile })` once platform services exist. The `services` object includes:
|
||||||
|
|
||||||
|
- **`services.api_client`** — HTTP client (`get`, `post`, …)
|
||||||
|
- **`services.storage`** — storage module (`getProvider`, …)
|
||||||
|
- **`services.api_router`** — placeholder for service-worker API routing (when available)
|
||||||
|
- **`services.ui_router`** — UI routing helpers
|
||||||
|
- **`services.menu`** — menu registration and queries
|
||||||
|
- **`services.env`** — `initEnv`, `getConfig`, `setConfig`, `CONFIG_KEYS`, tracing helpers, etc.
|
||||||
|
|
||||||
|
Service worker registration is **not** on `services`; import `registerServiceWorker` from `@reliancy/bface/platform/sw-register` (or the package root) and call it from `onInit` when you are ready.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
async function handleInit(services) {
|
||||||
|
// Example: Get configuration
|
||||||
|
const appName = await services.env.getConfig(CONFIG_KEYS.APP_NAME);
|
||||||
|
|
||||||
|
// Example: Make API call
|
||||||
|
const data = await services.api_client.get('/users');
|
||||||
|
|
||||||
|
// Example: Store data (KeyValueStore via getProvider)
|
||||||
|
const kv = services.storage.getProvider('kv', 'myStore');
|
||||||
|
await kv.set('user', { id: 1, name: 'John' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Using UI Components
|
||||||
|
|
||||||
|
Import and use UI components directly:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Page, Panel, MenuItemButton } from '@reliancy/bface/ui/components';
|
||||||
|
import { SideBar, TopBar } from '@reliancy/bface/ui/components';
|
||||||
|
|
||||||
|
function MyPage() {
|
||||||
|
return (
|
||||||
|
<Page title="Dashboard" icon="dashboard">
|
||||||
|
<Panel>
|
||||||
|
<Text>Welcome to your dashboard</Text>
|
||||||
|
</Panel>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Using Platform Modules
|
||||||
|
|
||||||
|
Import platform utilities:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { getConfig, setConfig, CONFIG_KEYS } from '@reliancy/bface/platform/env';
|
||||||
|
import { api } from '@reliancy/bface/platform/api';
|
||||||
|
import { getProvider } from '@reliancy/bface/platform/storage';
|
||||||
|
import { queryMenuItems } from '@reliancy/bface/platform/menu';
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
const theme = await getConfig('theme.mode', 'system');
|
||||||
|
|
||||||
|
// Set configuration
|
||||||
|
await setConfig('theme.mode', 'dark');
|
||||||
|
|
||||||
|
// Use storage
|
||||||
|
const storage = getProvider('kv', 'myStore');
|
||||||
|
await storage.set('key', 'value');
|
||||||
|
const value = await storage.get('key');
|
||||||
|
|
||||||
|
// HTTP (singleton; base URL comes from env when wired in App)
|
||||||
|
await api.get('/status');
|
||||||
|
|
||||||
|
// Query menu items
|
||||||
|
const menuItems = queryMenuItems('/primary');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library structure
|
||||||
|
|
||||||
|
### Exports (`package.json` → `exports`)
|
||||||
|
|
||||||
|
- **`@reliancy/bface`** — main entry: platform, `App`, UI components index, general settings, security, and data helpers
|
||||||
|
- **`@reliancy/bface/platform/*`** — platform modules (`env`, `api`, `storage`, `menu`, `sw-register`, `compat`, `host`, …)
|
||||||
|
- **`@reliancy/bface/ui/*`** — UI entry points such as `App` and `components`
|
||||||
|
|
||||||
|
Security and data types are re-exported from the root entry; there are no separate `exports` subpaths for `./security/*` or `./data/*` today—import them from `@reliancy/bface` or add deep links if your bundler resolves source.
|
||||||
|
|
||||||
|
### Platform modules
|
||||||
|
|
||||||
|
- **`platform/env.js`** — profile → config dictionary, `getConfig` / `setConfig`, logging and tracing helpers
|
||||||
|
- **`platform/api.js`** — API client
|
||||||
|
- **`platform/storage.js`** — storage abstraction (localStorage, IndexedDB, OPFS)
|
||||||
|
- **`platform/menu.js`** — menu model and queries
|
||||||
|
- **`platform/sw-register.js`** — service worker registration and cache helpers
|
||||||
|
- **`platform/compat.js`** — environment detection and compatibility
|
||||||
|
- **`platform/host.js`** — host detection (e.g. Electron)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- **`ui/App.jsx`** — Tamagui provider, theme controller, security bootstrap, shell selection, `onInit`
|
||||||
|
- **`ui/components/`** — shells (`EmptyShell`, `LandingShell`, `DashboardShell`, …), layout, grid/DirView, forms, router
|
||||||
|
- **`ui/styles/`** — Tamagui style themes (`material`, `minimal`, `colorful`)
|
||||||
|
|
||||||
|
### Other areas
|
||||||
|
|
||||||
|
- **`security/`** — policies, models, login and account pages, route guards, `securityService`
|
||||||
|
- **`data/`** — `DataModel`, `InMemoryDataModel`
|
||||||
|
|
||||||
|
### Application profile and `initEnv`
|
||||||
|
|
||||||
|
`initEnv` maps the profile object onto internal config keys. For convenience, several fields accept **either** camelCase **or** snake_case:
|
||||||
|
|
||||||
|
| Concept | Accepted profile fields | Internal key |
|
||||||
|
|--------|-------------------------|--------------|
|
||||||
|
| Display title | `displayName`, then `short_name`, then `name` | `APP_DISPLAY_NAME` |
|
||||||
|
| Stable app id | `id`, then `name` | `APP_NAME` |
|
||||||
|
| Logo | `brandLogo` or `brand_logo`, or PWA manifest-style `icons[0].src` | `BRAND_LOGO` |
|
||||||
|
| API base | `api.baseURL` or `api.base_url` | `API_BASE_URL` |
|
||||||
|
| Shell | `uiShell` or `ui_shell` | `UI_SHELL` |
|
||||||
|
|
||||||
|
### Shell names (`ui_shell` / `UI_SHELL`)
|
||||||
|
|
||||||
|
Resolved case-insensitively in `App`:
|
||||||
|
|
||||||
|
| Profile value | Component |
|
||||||
|
|---------------|-----------|
|
||||||
|
| `EmptyShell` (default) | `EmptyShell` |
|
||||||
|
| `LandingShell` | `LandingShell` |
|
||||||
|
| `TopBarShell` | Same layout as `LandingShell` (top bar shell) |
|
||||||
|
| `DashboardShell` | `DashboardShell` |
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a complete example of using the library in a project:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app.jsx
|
||||||
|
import { App } from '@reliancy/bface/ui/App';
|
||||||
|
import { CONFIG_KEYS } from '@reliancy/bface/platform/env';
|
||||||
|
import { registerServiceWorker } from '@reliancy/bface/platform/sw-register';
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
// Load your app profile (from JSON, API, etc.)
|
||||||
|
const response = await fetch('/profile.json');
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModule(moduleName, services) {
|
||||||
|
// Dynamically import and initialize your modules
|
||||||
|
const module = await import(`./modules/${moduleName}/index.js`);
|
||||||
|
if (module.publishModule) {
|
||||||
|
module.publishModule(services);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInit(services, { initialProfile } = {}) {
|
||||||
|
// 1. Load profile (use embedded profile from App when provided)
|
||||||
|
const profile = initialProfile ?? await loadProfile();
|
||||||
|
|
||||||
|
// 2. Initialize environment
|
||||||
|
services.env.initEnv(profile);
|
||||||
|
|
||||||
|
// 3. Load modules
|
||||||
|
const modules = await services.env.getConfig(CONFIG_KEYS.MODULES, []);
|
||||||
|
for (const moduleName of modules) {
|
||||||
|
await loadModule(moduleName, services);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Register service worker
|
||||||
|
await registerServiceWorker();
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyApp() {
|
||||||
|
return <App onInit={handleInit} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
The library supports multiple themes. Configure the theme in your profile:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyApp",
|
||||||
|
"ui_shell": "DashboardShell",
|
||||||
|
"theme": {
|
||||||
|
"name": "material",
|
||||||
|
"mode": "system"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Available themes:
|
||||||
|
- `material` - Material Design theme
|
||||||
|
- `minimal` - Minimal theme
|
||||||
|
- `colorful` - Colorful theme
|
||||||
|
|
||||||
|
## Menu System
|
||||||
|
|
||||||
|
Register menu items in your modules:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// In your module
|
||||||
|
import { publishMenuItem, MENU_DIRS } from '@reliancy/bface/platform/menu';
|
||||||
|
|
||||||
|
export function publishModule(platform) {
|
||||||
|
publishMenuItem(MENU_DIRS.PRIMARY('dashboard'), {
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: 'dashboard',
|
||||||
|
invoke: () => {
|
||||||
|
platform.ui_router.navigate('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
The library provides a unified storage API:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { getProvider } from '@reliancy/bface/platform/storage';
|
||||||
|
|
||||||
|
// Get storage provider
|
||||||
|
const storage = getProvider('kv', 'myStore');
|
||||||
|
|
||||||
|
// Use storage
|
||||||
|
await storage.set('key', { data: 'value' });
|
||||||
|
const value = await storage.get('key');
|
||||||
|
const exists = await storage.hasKey('key');
|
||||||
|
await storage.remove('key');
|
||||||
|
await storage.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported backends:
|
||||||
|
- `localStorage` - Browser localStorage
|
||||||
|
- `indexedDB` - IndexedDB
|
||||||
|
- `opfs` - Origin Private File System
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
|
||||||
|
Make HTTP requests using the API client:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// In your onInit callback — paths are relative to the API base URL from the profile (`api.baseURL` / `api.base_url`)
|
||||||
|
const data = await services.api_client.get('/users');
|
||||||
|
const user = await services.api_client.post('/users', { name: 'John' });
|
||||||
|
await services.api_client.put('/users/1', { name: 'Jane' });
|
||||||
|
await services.api_client.delete('/users/1');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Clone the repository, install dependencies (`npm install`), then:
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces ESM under `dist/` with **`.d.ts` declaration files** (via `vite-plugin-dts`) for the published `exports` map. The `dist/` folder is gitignored in this repo; the npm package tarball is built from `files` in `package.json`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses Node’s built-in test runner. The npm script passes `--localstorage-file=.node-localstorage` so `getConfig` / storage-backed paths work under Node without noisy `SecurityError` warnings.
|
||||||
|
|
||||||
|
### Watch mode (library)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `vite build --watch` for iterative work on the package.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright and licensing terms are defined by the organization that publishes this package.
|
||||||
|
|
||||||
Generated
+4412
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "@reliancy/bface",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./platform/*": {
|
||||||
|
"import": "./dist/platform/*.js",
|
||||||
|
"types": "./dist/platform/*.d.ts"
|
||||||
|
},
|
||||||
|
"./ui/*": {
|
||||||
|
"import": "./dist/ui/*.js",
|
||||||
|
"types": "./dist/ui/*.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://repo.reliancy.com/repository/npm-private/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite build --watch",
|
||||||
|
"test": "node --localstorage-file=.node-localstorage --test test/**/*.test.js",
|
||||||
|
"test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tamagui/config": "^1.144.2",
|
||||||
|
"@tamagui/core": "^1.144.2",
|
||||||
|
"@tamagui/lucide-icons": "^1.144.2",
|
||||||
|
"tamagui": "^1.144.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tamagui/vite-plugin": "^1.144.2",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"vite": "^5.0.8",
|
||||||
|
"vite-plugin-dts": "^4.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export class DataModel {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.idField = options.idField || 'id';
|
||||||
|
this.listeners = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyChange(payload = {}) {
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[DataModel] Listener failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdField() {
|
||||||
|
return this.idField;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeQuery(query = {}) {
|
||||||
|
return {
|
||||||
|
page: Math.max(1, Number(query.page) || 1),
|
||||||
|
pageSize: Math.max(1, Number(query.pageSize) || 10),
|
||||||
|
search: typeof query.search === 'string' ? query.search.trim() : '',
|
||||||
|
orderBy: query.orderBy || '',
|
||||||
|
order: query.order === 'desc' ? 'desc' : 'asc',
|
||||||
|
filters: query.filters && typeof query.filters === 'object' ? query.filters : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryRecords(_query = {}) {
|
||||||
|
throw new Error('queryRecords() not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySummary(_query = {}, _summaryDefinitions = []) {
|
||||||
|
throw new Error('querySummary() not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecord(_id) {
|
||||||
|
throw new Error('getRecord() not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecord(_values) {
|
||||||
|
throw new Error('createRecord() not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRecord(_id, _patch) {
|
||||||
|
throw new Error('updateRecord() not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecord(_id) {
|
||||||
|
throw new Error('deleteRecord() not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataModel;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { DataModel } from './DataModel.js';
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueByPath(record, path) {
|
||||||
|
if (!path) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.split('.').reduce((current, key) => current?.[key], record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareValues(left, right) {
|
||||||
|
if (left === right) return 0;
|
||||||
|
if (left === null || left === undefined) return -1;
|
||||||
|
if (right === null || right === undefined) return 1;
|
||||||
|
if (typeof left === 'number' && typeof right === 'number') {
|
||||||
|
return left - right;
|
||||||
|
}
|
||||||
|
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryDataModel extends DataModel {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super(options);
|
||||||
|
this.records = Array.isArray(options.records) ? clone(options.records) : [];
|
||||||
|
this.searchFields = Array.isArray(options.searchFields) ? options.searchFields : [];
|
||||||
|
this.defaultSummaryDefinitions = Array.isArray(options.summaryDefinitions) ? options.summaryDefinitions : [];
|
||||||
|
this.nextId = options.nextId || this.computeNextId();
|
||||||
|
}
|
||||||
|
|
||||||
|
computeNextId() {
|
||||||
|
const idField = this.getIdField();
|
||||||
|
const numericIds = this.records
|
||||||
|
.map((record) => Number(record?.[idField]))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
|
||||||
|
if (numericIds.length === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(...numericIds) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecords(records = []) {
|
||||||
|
this.records = Array.isArray(records) ? clone(records) : [];
|
||||||
|
this.nextId = this.computeNextId();
|
||||||
|
this.notifyChange({ type: 'reset' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllRecords() {
|
||||||
|
return clone(this.records);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters(records, filters = {}) {
|
||||||
|
const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.filter((record) => {
|
||||||
|
return entries.every(([field, expected]) => {
|
||||||
|
const actual = getValueByPath(record, field);
|
||||||
|
if (Array.isArray(expected)) {
|
||||||
|
return expected.includes(actual);
|
||||||
|
}
|
||||||
|
return actual === expected;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applySearch(records, search = '') {
|
||||||
|
const normalizedSearch = normalizeString(search).trim();
|
||||||
|
if (!normalizedSearch) {
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = this.searchFields.length > 0
|
||||||
|
? this.searchFields
|
||||||
|
: Array.from(new Set(records.flatMap((record) => Object.keys(record || {}))));
|
||||||
|
|
||||||
|
return records.filter((record) => {
|
||||||
|
return fields.some((field) => normalizeString(getValueByPath(record, field)).includes(normalizedSearch));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applySort(records, orderBy = '', order = 'asc') {
|
||||||
|
if (!orderBy) {
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...records].sort((left, right) => compareValues(getValueByPath(left, orderBy), getValueByPath(right, orderBy)));
|
||||||
|
return order === 'desc' ? sorted.reverse() : sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
paginate(records, page = 1, pageSize = 10) {
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
return records.slice(startIndex, startIndex + pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize(records, definitions = []) {
|
||||||
|
const summaryDefinitions = definitions.length > 0 ? definitions : this.defaultSummaryDefinitions;
|
||||||
|
const items = summaryDefinitions.map((definition) => {
|
||||||
|
const type = definition.type || 'count';
|
||||||
|
const field = definition.field || '';
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
if (type === 'sum') {
|
||||||
|
value = records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0);
|
||||||
|
} else if (type === 'avg') {
|
||||||
|
value = records.length > 0
|
||||||
|
? records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0) / records.length
|
||||||
|
: 0;
|
||||||
|
} else if (type === 'countBy') {
|
||||||
|
value = records.reduce((accumulator, record) => {
|
||||||
|
const key = getValueByPath(record, field) ?? 'unknown';
|
||||||
|
accumulator[key] = (accumulator[key] || 0) + 1;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
} else {
|
||||||
|
value = records.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRecords: records.length,
|
||||||
|
items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFilteredRecords(query = {}) {
|
||||||
|
const normalized = this.normalizeQuery(query);
|
||||||
|
const filtered = this.applySearch(this.applyFilters([...this.records], normalized.filters), normalized.search);
|
||||||
|
const sorted = this.applySort(filtered, normalized.orderBy, normalized.order);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: normalized,
|
||||||
|
filteredRecords: sorted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryRecords(query = {}) {
|
||||||
|
const { query: normalized, filteredRecords } = this.buildFilteredRecords(query);
|
||||||
|
return {
|
||||||
|
records: clone(this.paginate(filteredRecords, normalized.page, normalized.pageSize)),
|
||||||
|
totalRecords: filteredRecords.length,
|
||||||
|
page: normalized.page,
|
||||||
|
pageSize: normalized.pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySummary(query = {}, summaryDefinitions = []) {
|
||||||
|
const { filteredRecords, query: normalized } = this.buildFilteredRecords(query);
|
||||||
|
return {
|
||||||
|
query: normalized,
|
||||||
|
...this.summarize(filteredRecords, summaryDefinitions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecord(id) {
|
||||||
|
const idField = this.getIdField();
|
||||||
|
const record = this.records.find((item) => String(item?.[idField]) === String(id));
|
||||||
|
return record ? clone(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecord(values = {}) {
|
||||||
|
const idField = this.getIdField();
|
||||||
|
const record = {
|
||||||
|
...clone(values),
|
||||||
|
[idField]: values?.[idField] ?? this.nextId++
|
||||||
|
};
|
||||||
|
this.records.unshift(record);
|
||||||
|
this.notifyChange({ type: 'create', record: clone(record) });
|
||||||
|
return clone(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRecord(id, patch = {}) {
|
||||||
|
const idField = this.getIdField();
|
||||||
|
const recordIndex = this.records.findIndex((item) => String(item?.[idField]) === String(id));
|
||||||
|
if (recordIndex < 0) {
|
||||||
|
throw new Error(`Record not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.records[recordIndex] = {
|
||||||
|
...this.records[recordIndex],
|
||||||
|
...clone(patch),
|
||||||
|
[idField]: this.records[recordIndex][idField]
|
||||||
|
};
|
||||||
|
this.notifyChange({ type: 'update', record: clone(this.records[recordIndex]) });
|
||||||
|
return clone(this.records[recordIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecord(id) {
|
||||||
|
const idField = this.getIdField();
|
||||||
|
const beforeLength = this.records.length;
|
||||||
|
this.records = this.records.filter((item) => String(item?.[idField]) !== String(id));
|
||||||
|
if (this.records.length === beforeLength) {
|
||||||
|
throw new Error(`Record not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyChange({ type: 'delete', id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InMemoryDataModel;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { DataModel, default as DataModelDefault } from './DataModel.js';
|
||||||
|
export { InMemoryDataModel, default as InMemoryDataModelDefault } from './InMemoryDataModel.js';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* BUI Library Entry Point
|
||||||
|
* Base UI and Platform library
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export platform modules
|
||||||
|
export * from './platform/api.js';
|
||||||
|
export * from './platform/compat.js';
|
||||||
|
export * from './platform/env.js';
|
||||||
|
export * from './platform/menu.js';
|
||||||
|
export * from './platform/storage.js';
|
||||||
|
export * from './platform/sw-register.js';
|
||||||
|
export * from './data/index.js';
|
||||||
|
|
||||||
|
// Re-export UI components
|
||||||
|
export * from './ui/App.jsx';
|
||||||
|
export * from './ui/components/index.js';
|
||||||
|
export * from './ui/runtime/general-settings.js';
|
||||||
|
|
||||||
|
// Re-export security modules
|
||||||
|
export * from './security/index.js';
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* API Client
|
||||||
|
* Fetch wrappers for /api/* endpoints with offline support
|
||||||
|
*/
|
||||||
|
|
||||||
|
class APIClient {
|
||||||
|
constructor(baseURL = '/api') {
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
this.interceptors = {
|
||||||
|
request: [],
|
||||||
|
response: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add request interceptor
|
||||||
|
* @param {Function} interceptor - (config) => config
|
||||||
|
*/
|
||||||
|
addRequestInterceptor(interceptor) {
|
||||||
|
this.interceptors.request.push(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add response interceptor
|
||||||
|
* @param {Function} interceptor - (response) => response
|
||||||
|
*/
|
||||||
|
addResponseInterceptor(interceptor) {
|
||||||
|
this.interceptors.response.push(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute request interceptors
|
||||||
|
* @param {RequestInit} config
|
||||||
|
* @returns {RequestInit}
|
||||||
|
*/
|
||||||
|
_applyRequestInterceptors(config) {
|
||||||
|
return this.interceptors.request.reduce(
|
||||||
|
(acc, interceptor) => interceptor(acc),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute response interceptors
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
_applyResponseInterceptors(response) {
|
||||||
|
return this.interceptors.response.reduce(
|
||||||
|
(acc, interceptor) => interceptor(acc),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {RequestInit} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
const config = this._applyRequestInterceptors({
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
return this._applyResponseInterceptors(response);
|
||||||
|
} catch (error) {
|
||||||
|
// TODO: Implement offline queue management
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET request
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {RequestInit} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async get(endpoint, options = {}) {
|
||||||
|
return this.request(endpoint, { ...options, method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {any} data
|
||||||
|
* @param {RequestInit} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async post(endpoint, data, options = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT request
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {any} data
|
||||||
|
* @param {RequestInit} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async put(endpoint, data, options = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE request
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {RequestInit} options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async delete(endpoint, options = {}) {
|
||||||
|
return this.request(endpoint, { ...options, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const api = new APIClient();
|
||||||
|
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
/**
|
||||||
|
* Platform Compatibility Layer
|
||||||
|
* Abstracts web-only operations that will need React Native equivalents later
|
||||||
|
*
|
||||||
|
* This module provides a compatibility layer for platform-specific operations.
|
||||||
|
* Web implementations are provided here; React Native implementations will be
|
||||||
|
* added when needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfig, setConfig } from './env.js';
|
||||||
|
import { getDesktopBridgeSafe, getHostKind, isElectronHost } from './host.js';
|
||||||
|
|
||||||
|
// Config key for last visited route path
|
||||||
|
const LAST_PATH_CONFIG_KEY = 'router.lastPath';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Detection (System Color Scheme)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system theme mode preference
|
||||||
|
* Web: Uses window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
* React Native: Will use Appearance API or useColorScheme hook
|
||||||
|
*
|
||||||
|
* @returns {'light' | 'dark'} System theme mode preference
|
||||||
|
*/
|
||||||
|
export function getSystemThemeMode() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to system theme mode changes
|
||||||
|
* Web: Uses MediaQueryList.addEventListener
|
||||||
|
* React Native: Will use Appearance.addChangeListener
|
||||||
|
*
|
||||||
|
* @param {Function} listener - Callback function called with ('light' | 'dark') when theme mode changes
|
||||||
|
* @returns {Function} Unsubscribe function
|
||||||
|
*/
|
||||||
|
export function subscribeToSystemThemeMode(listener) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Return no-op unsubscribe for SSR/non-browser
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const scheme = e.matches ? 'dark' : 'light';
|
||||||
|
try {
|
||||||
|
listener(scheme);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Compat] System color scheme listener error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modern browsers
|
||||||
|
if (mediaQuery.addEventListener) {
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback for older browsers
|
||||||
|
else if (mediaQuery.addListener) {
|
||||||
|
mediaQuery.addListener(handleChange);
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeListener(handleChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: no-op unsubscribe
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scheduling (timers & animation frames)
|
||||||
|
// Web uses globalThis today; React Native can swap implementations here later.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function schedulerGlobal() {
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
return globalThis;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeating timer. Returns a cancel function (clears interval).
|
||||||
|
* @param {() => void} callback
|
||||||
|
* @param {number} delayMs
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
export function scheduleInterval(callback, delayMs) {
|
||||||
|
const g = schedulerGlobal();
|
||||||
|
if (!g || typeof g.setInterval !== 'function') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const id = g.setInterval(callback, delayMs);
|
||||||
|
return () => {
|
||||||
|
if (typeof g.clearInterval === 'function') {
|
||||||
|
g.clearInterval(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot delayed execution. Returns a cancel function (clears timeout).
|
||||||
|
* @param {() => void} callback
|
||||||
|
* @param {number} delayMs
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
export function scheduleTimeout(callback, delayMs) {
|
||||||
|
const g = schedulerGlobal();
|
||||||
|
if (!g || typeof g.setTimeout !== 'function') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const id = g.setTimeout(callback, delayMs);
|
||||||
|
return () => {
|
||||||
|
if (typeof g.clearTimeout === 'function') {
|
||||||
|
g.clearTimeout(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run once on the next display frame (or a short timeout fallback).
|
||||||
|
* @param {(time: number) => void} callback
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
export function runOnNextFrame(callback) {
|
||||||
|
if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') {
|
||||||
|
const id = requestAnimationFrame(callback);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
return scheduleTimeout(() => {
|
||||||
|
const t = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
callback(t);
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke callback on each animation frame until the returned unsubscribe runs.
|
||||||
|
* Prefer this over {@link scheduleInterval} when driving layout or motion from JS.
|
||||||
|
* @param {(time: number) => void} callback
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
export function subscribeAnimationFrames(callback) {
|
||||||
|
if (typeof requestAnimationFrame !== 'function' || typeof cancelAnimationFrame !== 'function') {
|
||||||
|
return scheduleInterval(() => {
|
||||||
|
const t = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
callback(t);
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id;
|
||||||
|
let stopped = false;
|
||||||
|
const step = (time) => {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(time);
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
id = requestAnimationFrame(step);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (id != null) {
|
||||||
|
cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Element Bounds & Positioning
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get element's or viewport's bounding rectangle
|
||||||
|
* If element is null/undefined, returns viewport bounds (web: window.innerWidth/innerHeight)
|
||||||
|
* Otherwise returns element's bounding rectangle (web: getBoundingClientRect)
|
||||||
|
* @param {HTMLElement|React.RefObject|null|undefined} element - DOM element, ref object, or null for viewport
|
||||||
|
* @returns {{top: number, left: number, right: number, bottom: number, width: number, height: number}|null}
|
||||||
|
*/
|
||||||
|
export function getBounds(element) {
|
||||||
|
// If element is null/undefined, return viewport bounds
|
||||||
|
if (!element) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: window.innerWidth,
|
||||||
|
bottom: window.innerHeight,
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback for SSR or non-browser environments
|
||||||
|
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get element bounds
|
||||||
|
const el = element.current || element;
|
||||||
|
if (!el || typeof el.getBoundingClientRect !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listener to a target object (web: target.addEventListener)
|
||||||
|
* Generic function for adding event listeners to any EventTarget
|
||||||
|
* @param {EventTarget} target - Target object (e.g., document, window)
|
||||||
|
* @param {string} eventType - Event type (e.g., 'mousedown', 'resize')
|
||||||
|
* @param {Function} handler - Event handler function
|
||||||
|
* @returns {Function} Cleanup function to remove the listener
|
||||||
|
*/
|
||||||
|
function addEventListener(target, eventType, handler) {
|
||||||
|
if (!target || typeof target.addEventListener !== 'function') {
|
||||||
|
// Return no-op cleanup for non-browser environments or invalid targets
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
target.addEventListener(eventType, handler);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
target.removeEventListener(eventType, handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add document-level event listener (web: document.addEventListener)
|
||||||
|
* @param {string} eventType - Event type (e.g., 'mousedown', 'resize')
|
||||||
|
* @param {Function} handler - Event handler function
|
||||||
|
* @returns {Function} Cleanup function to remove the listener
|
||||||
|
*/
|
||||||
|
export function addDocumentEventListener(eventType, handler) {
|
||||||
|
return addEventListener(typeof document !== 'undefined' ? document : null, eventType, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add window-level event listener (web: window.addEventListener)
|
||||||
|
* @param {string} eventType - Event type (e.g., 'resize')
|
||||||
|
* @param {Function} handler - Event handler function
|
||||||
|
* @returns {Function} Cleanup function to remove the listener
|
||||||
|
*/
|
||||||
|
export function addWindowEventListener(eventType, handler) {
|
||||||
|
return addEventListener(typeof window !== 'undefined' ? window : null, eventType, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element contains another element or target (web: element.contains)
|
||||||
|
* @param {HTMLElement|React.RefObject} element - Parent element or ref
|
||||||
|
* @param {EventTarget|Node} target - Child element or event target
|
||||||
|
* @returns {boolean} True if element contains target
|
||||||
|
*/
|
||||||
|
export function elementContains(element, target) {
|
||||||
|
if (!element || !target) return false;
|
||||||
|
|
||||||
|
const el = element.current || element;
|
||||||
|
if (!el || typeof el.contains !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return el.contains(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if child bounds are fully contained within parent bounds (with margin)
|
||||||
|
* @param {{top: number, left: number, right: number, bottom: number}} parent - Parent bounds
|
||||||
|
* @param {{top: number, left: number, right: number, bottom: number}} child - Child bounds
|
||||||
|
* @param {number} margin - Margin from parent edges
|
||||||
|
* @returns {boolean} True if child is fully contained within parent
|
||||||
|
*/
|
||||||
|
function boundsContained(parent, child, margin = 0) {
|
||||||
|
return (
|
||||||
|
child.top >= parent.top + margin &&
|
||||||
|
child.left >= parent.left + margin &&
|
||||||
|
child.right <= parent.right - margin &&
|
||||||
|
child.bottom <= parent.bottom - margin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate popup bounds by trying placement options in priority order
|
||||||
|
* Returns the first option that fits within the viewport
|
||||||
|
* @param {HTMLElement|React.RefObject} buttonElement - Button element
|
||||||
|
* @param {number} popupWidth - Popup width
|
||||||
|
* @param {number} popupHeight - Popup height
|
||||||
|
* @param {number} margin - Margin from screen edges
|
||||||
|
* @returns {{top: number, left: number, right: number, bottom: number, width: number, height: number, alignRight: boolean, alignRightSide: boolean, alignBottom: boolean}|null}
|
||||||
|
*/
|
||||||
|
export function getPopupBounds(buttonElement, popupWidth = 200, popupHeight = 300, margin = 8) {
|
||||||
|
const buttonBounds = getBounds(buttonElement);
|
||||||
|
if (!buttonBounds) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewport = getBounds(null);
|
||||||
|
const spacing = 4; // Space between button and popup
|
||||||
|
|
||||||
|
// Calculate 4 placement options in priority order:
|
||||||
|
// 1. Below button, left-aligned
|
||||||
|
// 2. Below button, right-aligned
|
||||||
|
// 3. Right of button, bottom-aligned
|
||||||
|
// 4. Left of button, bottom-aligned
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
// Option 1: Below button, left-aligned
|
||||||
|
{
|
||||||
|
top: buttonBounds.bottom + spacing,
|
||||||
|
left: buttonBounds.left,
|
||||||
|
right: buttonBounds.left + popupWidth,
|
||||||
|
bottom: buttonBounds.bottom + spacing + popupHeight,
|
||||||
|
width: popupWidth,
|
||||||
|
height: popupHeight,
|
||||||
|
alignRight: false,
|
||||||
|
alignRightSide: false,
|
||||||
|
alignBottom: false
|
||||||
|
},
|
||||||
|
// Option 2: Below button, right-aligned
|
||||||
|
{
|
||||||
|
top: buttonBounds.bottom + spacing,
|
||||||
|
left: buttonBounds.right - popupWidth,
|
||||||
|
right: buttonBounds.right,
|
||||||
|
bottom: buttonBounds.bottom + spacing + popupHeight,
|
||||||
|
width: popupWidth,
|
||||||
|
height: popupHeight,
|
||||||
|
alignRight: true,
|
||||||
|
alignRightSide: false,
|
||||||
|
alignBottom: false
|
||||||
|
},
|
||||||
|
// Option 3: Right of button, bottom-aligned
|
||||||
|
{
|
||||||
|
top: buttonBounds.bottom - popupHeight,
|
||||||
|
left: buttonBounds.right + spacing,
|
||||||
|
right: buttonBounds.right + spacing + popupWidth,
|
||||||
|
bottom: buttonBounds.bottom,
|
||||||
|
width: popupWidth,
|
||||||
|
height: popupHeight,
|
||||||
|
alignRight: false,
|
||||||
|
alignRightSide: true,
|
||||||
|
alignBottom: true
|
||||||
|
},
|
||||||
|
// Option 4: Left of button, bottom-aligned
|
||||||
|
{
|
||||||
|
top: buttonBounds.bottom - popupHeight,
|
||||||
|
left: buttonBounds.left - popupWidth - spacing,
|
||||||
|
right: buttonBounds.left - spacing,
|
||||||
|
bottom: buttonBounds.bottom,
|
||||||
|
width: popupWidth,
|
||||||
|
height: popupHeight,
|
||||||
|
alignRight: false,
|
||||||
|
alignRightSide: false,
|
||||||
|
alignBottom: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find first option that fits within viewport
|
||||||
|
for (const option of options) {
|
||||||
|
if (boundsContained(viewport, option, 0)) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none fit, return the first option (below, left-aligned) as fallback
|
||||||
|
// It will be clamped by getPopupPositionStyle
|
||||||
|
return options[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get popup positioning style for fixed positioning (web: position: fixed)
|
||||||
|
* In React Native, this will need to use absolute positioning with proper parent container
|
||||||
|
* Clamps popup to viewport if it doesn't fit
|
||||||
|
* @param {{top: number, left: number, right: number, bottom: number, width: number, height: number, alignRight: boolean, alignRightSide: boolean, alignBottom: boolean}|null} popupBounds - Popup bounds from getPopupBounds
|
||||||
|
* @param {HTMLElement|React.RefObject} buttonElement - Button element for fallback calculations
|
||||||
|
* @returns {Object} Style object for popup positioning
|
||||||
|
*/
|
||||||
|
export function getPopupPositionStyle(popupBounds, buttonElement = null) {
|
||||||
|
const viewport = getBounds(null);
|
||||||
|
const buttonBounds = buttonElement ? getBounds(buttonElement) : null;
|
||||||
|
|
||||||
|
const style = {};
|
||||||
|
|
||||||
|
if (popupBounds) {
|
||||||
|
// Use provided popup bounds
|
||||||
|
// Handle top/bottom positioning
|
||||||
|
if (popupBounds.alignBottom && buttonBounds) {
|
||||||
|
style.bottom = `${viewport.height - buttonBounds.bottom}px`;
|
||||||
|
style.top = 'auto';
|
||||||
|
} else {
|
||||||
|
style.top = `${popupBounds.top}px`;
|
||||||
|
style.bottom = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle left/right positioning
|
||||||
|
if (popupBounds.alignRight && buttonBounds) {
|
||||||
|
style.right = `${viewport.width - buttonBounds.right}px`;
|
||||||
|
style.left = 'auto';
|
||||||
|
} else if (popupBounds.alignRightSide) {
|
||||||
|
style.left = `${popupBounds.left}px`;
|
||||||
|
style.right = 'auto';
|
||||||
|
} else {
|
||||||
|
style.left = `${popupBounds.left}px`;
|
||||||
|
style.right = 'auto';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to old behavior if bounds not provided
|
||||||
|
if (buttonBounds) {
|
||||||
|
style.top = `${buttonBounds.bottom + 4}px`;
|
||||||
|
style.left = `${buttonBounds.left}px`;
|
||||||
|
style.bottom = 'auto';
|
||||||
|
style.right = 'auto';
|
||||||
|
} else {
|
||||||
|
style.top = 'auto';
|
||||||
|
style.left = 'auto';
|
||||||
|
style.bottom = 'auto';
|
||||||
|
style.right = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add position: fixed for web (will be handled differently in React Native)
|
||||||
|
style.position = 'fixed';
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// External Navigation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an external URL
|
||||||
|
* Platform-agnostic wrapper for opening external links
|
||||||
|
* Web: Uses window.open()
|
||||||
|
* React Native: Will use Linking.openURL()
|
||||||
|
*
|
||||||
|
* @param {string} url - URL to open
|
||||||
|
* @param {string} [target='_blank'] - Target window (e.g., '_blank', '_self')
|
||||||
|
* @param {string} [features] - Window features (e.g., 'noopener,noreferrer')
|
||||||
|
* @returns {Window|null} Opened window reference, or null if not supported
|
||||||
|
*/
|
||||||
|
export function openExternalURL(url, target = '_blank', features = 'noopener,noreferrer') {
|
||||||
|
const desktopBridge = getDesktopBridgeSafe();
|
||||||
|
if (desktopBridge?.openExternal && isElectronHost()) {
|
||||||
|
desktopBridge.openExternal(url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('[Compat] openExternalURL: window is not available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.warn('[Compat] openExternalURL: URL is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.open(url, target, features);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Compat] openExternalURL error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// File Picking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a file from the local device.
|
||||||
|
* Web: Uses a temporary hidden file input and optional FileReader conversion.
|
||||||
|
* React Native: Will need a native document/image picker equivalent.
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.accept] - File accept filter, defaults to any file type
|
||||||
|
* @param {boolean} [options.multiple=false] - Allow multiple selection
|
||||||
|
* @param {'text'|'dataURL'|'arrayBuffer'|null} [options.readAs='dataURL'] - Optional file read mode
|
||||||
|
* @returns {Promise<{file: File, result: any}|null>}
|
||||||
|
*/
|
||||||
|
export function pickFile(options = {}) {
|
||||||
|
const desktopBridge = getDesktopBridgeSafe();
|
||||||
|
if (desktopBridge?.pickFile && isElectronHost()) {
|
||||||
|
return desktopBridge.pickFile(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
accept = '*/*',
|
||||||
|
multiple = false,
|
||||||
|
readAs = 'dataURL'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
console.warn('[Compat] pickFile: document is not available');
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = accept;
|
||||||
|
input.multiple = multiple === true;
|
||||||
|
input.style.display = 'none';
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
input.onchange = null;
|
||||||
|
if (input.parentNode) {
|
||||||
|
input.parentNode.removeChild(input);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readAs) {
|
||||||
|
cleanup();
|
||||||
|
resolve({ file, result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
cleanup();
|
||||||
|
resolve({ file, result });
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.warn('[Compat] pickFile: failed to read selected file');
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (readAs === 'text') {
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else if (readAs === 'arrayBuffer') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an image from the local device.
|
||||||
|
* Web: Delegates to pickFile() with image accept types and reads as data URL.
|
||||||
|
* React Native: Will need a native media picker equivalent.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{file: File, result: string}|null>}
|
||||||
|
*/
|
||||||
|
export async function pickImage() {
|
||||||
|
const desktopBridge = getDesktopBridgeSafe();
|
||||||
|
if (desktopBridge?.pickImage && isElectronHost()) {
|
||||||
|
return desktopBridge.pickImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await pickFile({
|
||||||
|
accept: 'image/*',
|
||||||
|
readAs: 'dataURL'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selection?.file) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.file.type?.startsWith('image/')) {
|
||||||
|
console.warn('[Compat] pickImage: selected file is not an image');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: selection.file,
|
||||||
|
result: typeof selection.result === 'string' ? selection.result : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostInfo() {
|
||||||
|
const desktopBridge = getDesktopBridgeSafe();
|
||||||
|
if (desktopBridge?.getHostInfo && isElectronHost()) {
|
||||||
|
return desktopBridge.getHostInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostKind: getHostKind(),
|
||||||
|
isPackaged: false,
|
||||||
|
platform: typeof navigator !== 'undefined' ? navigator.platform : 'unknown',
|
||||||
|
appVersion: null,
|
||||||
|
userDataPath: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// URL/Path Management (Browser URL synchronization)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get router path (current path the router should use)
|
||||||
|
* Checks browser URL first, then localStorage for last visited path, then falls back to default
|
||||||
|
*
|
||||||
|
* @param {string} defaultPath - Default path to use if no URL or stored path exists
|
||||||
|
* @returns {Promise<string>} Router path to use
|
||||||
|
*/
|
||||||
|
export async function getRouterPath(defaultPath = '/') {
|
||||||
|
// First, check browser URL (web only)
|
||||||
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
|
const urlPath = window.location.pathname;
|
||||||
|
// If URL has a meaningful path (not just '/'), use it
|
||||||
|
if (urlPath && urlPath !== '/') {
|
||||||
|
return urlPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL is empty or '/', check config for last visited path
|
||||||
|
try {
|
||||||
|
const lastPath = await getConfig(LAST_PATH_CONFIG_KEY, null);
|
||||||
|
if (lastPath && typeof lastPath === 'string' && lastPath !== '/') {
|
||||||
|
return lastPath;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Compat] Failed to get last path from config:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default path
|
||||||
|
return defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update router path (browser URL and storage)
|
||||||
|
* Updates browser URL without page reload and saves path to localStorage for persistence
|
||||||
|
* Web: Uses history.pushState or history.replaceState
|
||||||
|
* React Native: Only saves to storage (no URL to update)
|
||||||
|
*
|
||||||
|
* @param {string} path - Path to navigate to (e.g., '/dashboard')
|
||||||
|
* @param {boolean} [replace=false] - Whether to replace current history entry
|
||||||
|
*/
|
||||||
|
export async function setRouterPath(path, replace = false, options = {}) {
|
||||||
|
const { notify = true, state = null } = options;
|
||||||
|
const fullPath = path.startsWith('/') ? path : '/' + path;
|
||||||
|
|
||||||
|
// Update browser URL (web only)
|
||||||
|
if (typeof window !== 'undefined' && window.history) {
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(state, '', fullPath);
|
||||||
|
} else {
|
||||||
|
window.history.pushState(state, '', fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate', { state }));
|
||||||
|
} catch (error) {
|
||||||
|
window.dispatchEvent(new Event('popstate'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to config for persistence (works on all platforms)
|
||||||
|
try {
|
||||||
|
await setConfig(LAST_PATH_CONFIG_KEY, fullPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Compat] Failed to save path to config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to browser URL changes (back/forward buttons)
|
||||||
|
* Web: Listens to popstate event
|
||||||
|
* React Native: Returns no-op unsubscribe (no URL changes)
|
||||||
|
*
|
||||||
|
* @param {Function} listener - Callback function called with (path: string, state: any) when URL changes
|
||||||
|
* @returns {Function} Unsubscribe function
|
||||||
|
*/
|
||||||
|
export function subscribeToPathChanges(listener) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Return no-op unsubscribe for non-browser
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopState = (event) => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
try {
|
||||||
|
listener(path, event.state);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Compat] Path change listener error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handlePopState);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/**
|
||||||
|
* Environment Configuration & Utilities
|
||||||
|
* Central config dictionary and environment discovery
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProvider } from './storage.js';
|
||||||
|
|
||||||
|
// Private storage instance for config
|
||||||
|
const storage = getProvider('kv', 'config');
|
||||||
|
|
||||||
|
// Config dictionary - populated by loader
|
||||||
|
let config = {};
|
||||||
|
let consoleLoggerInstalled = false;
|
||||||
|
const lockedKeys = new Set();
|
||||||
|
|
||||||
|
const LOGGER_ICONS = {
|
||||||
|
debug: '.',
|
||||||
|
info: 'i',
|
||||||
|
log: '>',
|
||||||
|
warn: '!',
|
||||||
|
error: 'x'
|
||||||
|
};
|
||||||
|
|
||||||
|
const NATIVE_CONSOLE = {
|
||||||
|
log: console.log.bind(console),
|
||||||
|
info: (console.info || console.log).bind(console),
|
||||||
|
warn: console.warn.bind(console),
|
||||||
|
error: console.error.bind(console),
|
||||||
|
debug: (console.debug || console.log).bind(console)
|
||||||
|
};
|
||||||
|
const perfNow = () => {
|
||||||
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||||
|
return performance.now();
|
||||||
|
}
|
||||||
|
return Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLogLevel(level = 'log') {
|
||||||
|
if (level === 'info') return 'info';
|
||||||
|
if (level === 'warn') return 'warn';
|
||||||
|
if (level === 'error') return 'error';
|
||||||
|
if (level === 'debug') return 'debug';
|
||||||
|
return 'log';
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSourceAndMessage(args = []) {
|
||||||
|
if (args.length === 0) {
|
||||||
|
return {
|
||||||
|
source: 'App',
|
||||||
|
messageParts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstArg, ...restArgs] = args;
|
||||||
|
if (typeof firstArg !== 'string') {
|
||||||
|
return {
|
||||||
|
source: 'App',
|
||||||
|
messageParts: args
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = firstArg.match(/^\[([^\]]+)\]\s*(.*)$/s);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
source: 'App',
|
||||||
|
messageParts: args
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, source, firstMessage] = match;
|
||||||
|
const messageParts = firstMessage ? [firstMessage, ...restArgs] : restArgs;
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
messageParts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special config keys
|
||||||
|
export const CONFIG_KEYS = {
|
||||||
|
APP_NAME: 'APP_NAME',
|
||||||
|
APP_DISPLAY_NAME: 'APP_DISPLAY_NAME',
|
||||||
|
APP_DESCRIPTION: 'APP_DESCRIPTION',
|
||||||
|
BRAND_LOGO: 'BRAND_LOGO',
|
||||||
|
THEME_COLOR: 'THEME_COLOR',
|
||||||
|
UI_SHELL: 'UI_SHELL',
|
||||||
|
STORAGE_BACKEND: 'STORAGE_BACKEND',
|
||||||
|
API_BASE_URL: 'API_BASE_URL',
|
||||||
|
MODULES: 'MODULES',
|
||||||
|
SECURITY_CONFIG: 'SECURITY_CONFIG',
|
||||||
|
LOCALE: 'LOCALE',
|
||||||
|
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
|
||||||
|
DEV_HOST: 'DEV_HOST'
|
||||||
|
};
|
||||||
|
|
||||||
|
// do not allow edits on these keys via setConfig - they are meant to be set from profile or env vars and not overridden at runtime
|
||||||
|
[
|
||||||
|
CONFIG_KEYS.API_BASE_URL,
|
||||||
|
CONFIG_KEYS.MODULES,
|
||||||
|
CONFIG_KEYS.SECURITY_CONFIG
|
||||||
|
].forEach((key) => lockedKeys.add(key));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve dev-host flag from profile (explicit) or bundler hints.
|
||||||
|
* Profile wins: `dev_host: true|false` or `runtime.dev: true|false`.
|
||||||
|
* Otherwise Vite `import.meta.env.DEV`, else false.
|
||||||
|
* @param {Object|null|undefined} appConfig
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function resolveDevHostFlag(appConfig) {
|
||||||
|
if (appConfig && typeof appConfig.dev_host === 'boolean') {
|
||||||
|
return appConfig.dev_host;
|
||||||
|
}
|
||||||
|
if (appConfig?.runtime && typeof appConfig.runtime.dev === 'boolean') {
|
||||||
|
return appConfig.runtime.dev;
|
||||||
|
}
|
||||||
|
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||||
|
return Boolean(import.meta.env.DEV);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize environment config
|
||||||
|
* @param {Object} appConfig - Configuration from profile
|
||||||
|
*/
|
||||||
|
export function initEnv(appConfig) {
|
||||||
|
const api = appConfig.api || {};
|
||||||
|
config = {
|
||||||
|
APP_NAME: appConfig.id || appConfig.name,
|
||||||
|
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
|
||||||
|
APP_DESCRIPTION: appConfig.description || '',
|
||||||
|
BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
||||||
|
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||||
|
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
||||||
|
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
||||||
|
API_BASE_URL: api.base_url || api.baseURL || '/api',
|
||||||
|
MODULES: appConfig.modules || [],
|
||||||
|
SECURITY_CONFIG: appConfig.security || {},
|
||||||
|
LOCALE: appConfig.locale || null,
|
||||||
|
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
||||||
|
// Store full profile for advanced access
|
||||||
|
_profile: appConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSystemLocale() {
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.language === 'string' && navigator.language.trim()) {
|
||||||
|
return navigator.language.trim();
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap environment as early as possible from the selected profile.
|
||||||
|
* This gives the app a single source of truth before full boot runs.
|
||||||
|
* @param {Object} appConfig
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {boolean} [options.installLoggerFirst=true]
|
||||||
|
*/
|
||||||
|
export function bootstrapEnv(appConfig, options = {}) {
|
||||||
|
const { installLoggerFirst = true } = options;
|
||||||
|
if (installLoggerFirst) {
|
||||||
|
installConsoleLogger();
|
||||||
|
}
|
||||||
|
if (appConfig && typeof appConfig === 'object') {
|
||||||
|
initEnv(appConfig);
|
||||||
|
}
|
||||||
|
return getConfigDict();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(source = 'App', level = 'log', ...messageParts) {
|
||||||
|
const normalizedLevel = normalizeLogLevel(level);
|
||||||
|
const sink = NATIVE_CONSOLE[normalizedLevel] || NATIVE_CONSOLE.log;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const icon = LOGGER_ICONS[normalizedLevel] || LOGGER_ICONS.log;
|
||||||
|
sink(`${timestamp} [${source}] ${icon}`, ...messageParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(source = 'App') {
|
||||||
|
return {
|
||||||
|
debug: (...messageParts) => log(source, 'debug', ...messageParts),
|
||||||
|
info: (...messageParts) => log(source, 'info', ...messageParts),
|
||||||
|
log: (...messageParts) => log(source, 'log', ...messageParts),
|
||||||
|
warn: (...messageParts) => log(source, 'warn', ...messageParts),
|
||||||
|
error: (...messageParts) => log(source, 'error', ...messageParts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTrace(source = 'App', label = 'operation', metadata = null) {
|
||||||
|
const startedAt = perfNow();
|
||||||
|
if (metadata !== null && metadata !== undefined) {
|
||||||
|
log(source, 'debug', `${label} started`, metadata);
|
||||||
|
} else {
|
||||||
|
log(source, 'debug', `${label} started`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
end(extra = null, level = 'log') {
|
||||||
|
const durationMs = Math.round((perfNow() - startedAt) * 100) / 100;
|
||||||
|
if (extra !== null && extra !== undefined) {
|
||||||
|
log(source, level, `${label} finished in ${durationMs}ms`, extra);
|
||||||
|
} else {
|
||||||
|
log(source, level, `${label} finished in ${durationMs}ms`);
|
||||||
|
}
|
||||||
|
return durationMs;
|
||||||
|
},
|
||||||
|
fail(error = null) {
|
||||||
|
const durationMs = Math.round((perfNow() - startedAt) * 100) / 100;
|
||||||
|
log(source, 'error', `${label} failed in ${durationMs}ms`, error);
|
||||||
|
return durationMs;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function traceAsync(source, label, fn, metadata = null) {
|
||||||
|
const trace = startTrace(source, label, metadata);
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
trace.end();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
trace.fail(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installConsoleLogger() {
|
||||||
|
if (consoleLoggerInstalled || typeof console === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
consoleLoggerInstalled = true;
|
||||||
|
|
||||||
|
console.log = (...args) => {
|
||||||
|
const { source, messageParts } = splitSourceAndMessage(args);
|
||||||
|
log(source, 'log', ...messageParts);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info = (...args) => {
|
||||||
|
const { source, messageParts } = splitSourceAndMessage(args);
|
||||||
|
log(source, 'info', ...messageParts);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args) => {
|
||||||
|
const { source, messageParts } = splitSourceAndMessage(args);
|
||||||
|
log(source, 'warn', ...messageParts);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
const { source, messageParts } = splitSourceAndMessage(args);
|
||||||
|
log(source, 'error', ...messageParts);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug = (...args) => {
|
||||||
|
const { source, messageParts } = splitSourceAndMessage(args);
|
||||||
|
log(source, 'debug', ...messageParts);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockConfigKey(key) {
|
||||||
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lockedKeys.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockConfigKey(key) {
|
||||||
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return lockedKeys.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfigKeyLocked(key) {
|
||||||
|
return lockedKeys.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLockedConfigKeys() {
|
||||||
|
return Array.from(lockedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMetaTag(name, content) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let element = document.querySelector(`meta[name="${name}"]`);
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('meta');
|
||||||
|
element.setAttribute('name', name);
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.setAttribute('content', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFavicon(href) {
|
||||||
|
if (typeof document === 'undefined' || !href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let element = document.querySelector('link[rel="icon"]');
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('link');
|
||||||
|
element.setAttribute('rel', 'icon');
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.setAttribute('href', href);
|
||||||
|
if (!element.getAttribute('type')) {
|
||||||
|
element.setAttribute('type', 'image/svg+xml');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDocumentHeadFromConfig(options = {}) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
titleFallback = 'PWA Template',
|
||||||
|
descriptionFallback = '',
|
||||||
|
themeColorFallback = '#000000'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [title, description, themeColor, brandLogo] = await Promise.all([
|
||||||
|
getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback),
|
||||||
|
getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback),
|
||||||
|
getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback),
|
||||||
|
getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg')
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.title = title || titleFallback;
|
||||||
|
setMetaTag('description', description || descriptionFallback);
|
||||||
|
setMetaTag('theme-color', themeColor || themeColorFallback);
|
||||||
|
setFavicon(brandLogo || '/favicon.svg');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || titleFallback,
|
||||||
|
description: description || descriptionFallback,
|
||||||
|
themeColor: themeColor || themeColorFallback,
|
||||||
|
brandLogo: brandLogo || '/favicon.svg'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get config value by key
|
||||||
|
* Checks in order: storage (if key exists) → config dictionary → environment variables → altValue
|
||||||
|
* Uses storage.hasKey() as a guard to skip unnecessary storage.get() calls for profile config keys
|
||||||
|
* @param {string} key - Config key
|
||||||
|
* @param {any} altValue - Alternative value if not found
|
||||||
|
* @returns {Promise<any>} Config value or altValue
|
||||||
|
*/
|
||||||
|
export async function getConfig(key, altValue = null) {
|
||||||
|
// 1. Check if key exists in storage first (quick check to avoid unnecessary get)
|
||||||
|
try {
|
||||||
|
const exists = await storage.hasKey(key);
|
||||||
|
if (exists) {
|
||||||
|
// Only do full storage.get() if key exists
|
||||||
|
const storedValue = await storage.get(key, null);
|
||||||
|
if (storedValue !== null && storedValue !== undefined) {
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If key doesn't exist in storage, skip to config dictionary (faster path)
|
||||||
|
} catch (error) {
|
||||||
|
// Storage might not be available, continue to next check
|
||||||
|
console.warn(`[Env] Failed to check storage for "${key}":`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check config dictionary (in-memory config from profile)
|
||||||
|
if (config.hasOwnProperty(key)) {
|
||||||
|
return config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fall back to environment variables (Vite env vars)
|
||||||
|
// Check if import.meta.env exists (available in Vite, not in Node)
|
||||||
|
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||||
|
const envKey = `VITE_${key}`;
|
||||||
|
const envValue = import.meta.env[envKey];
|
||||||
|
|
||||||
|
if (envValue !== undefined) {
|
||||||
|
return envValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Return alternative value
|
||||||
|
return altValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set config value
|
||||||
|
* If key exists in config dictionary, update it there (in-memory).
|
||||||
|
* Otherwise, save to storage (for persistence).
|
||||||
|
* @param {string} key - Config key
|
||||||
|
* @param {any} value - Value to set
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function setConfig(key, value) {
|
||||||
|
if (isConfigKeyLocked(key)) {
|
||||||
|
console.warn(`[Env] Refusing to set locked config key "${key}". Unlock it explicitly first if this override is intentional.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If key exists in config dictionary, update it there (in-memory config)
|
||||||
|
if (config.hasOwnProperty(key)) {
|
||||||
|
config[key] = value;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Otherwise, save to storage (for state/settings persistence)
|
||||||
|
try {
|
||||||
|
await storage.set(key, value);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Env] Failed to set "${key}" in storage:`, error);
|
||||||
|
// Fallback: store in config dictionary if storage fails
|
||||||
|
config[key] = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full config object (read-only copy)
|
||||||
|
* @returns {Object} Config dictionary
|
||||||
|
*/
|
||||||
|
export function getConfigDict() {
|
||||||
|
return { ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocale(altValue = null) {
|
||||||
|
const savedLocale = await getConfig(CONFIG_KEYS.LOCALE, null);
|
||||||
|
if (savedLocale && typeof savedLocale === 'string') {
|
||||||
|
config[CONFIG_KEYS.LOCALE] = savedLocale;
|
||||||
|
return savedLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return altValue || resolveSystemLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleSync(altValue = null) {
|
||||||
|
return config[CONFIG_KEYS.LOCALE] || altValue || resolveSystemLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLocale(locale) {
|
||||||
|
const normalizedLocale = typeof locale === 'string' && locale.trim() ? locale.trim() : null;
|
||||||
|
config[CONFIG_KEYS.LOCALE] = normalizedLocale;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (normalizedLocale) {
|
||||||
|
await storage.set(CONFIG_KEYS.LOCALE, normalizedLocale);
|
||||||
|
} else {
|
||||||
|
await storage.remove(CONFIG_KEYS.LOCALE);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Env] Failed to persist locale:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedLocale || resolveSystemLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is treated as a development host (dev tooling, SW policy, etc.).
|
||||||
|
* Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}.
|
||||||
|
* For persisted overrides, use {@link getConfig} with {@link CONFIG_KEYS.DEV_HOST} in async code.
|
||||||
|
*/
|
||||||
|
export function isDevelopment() {
|
||||||
|
if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.DEV_HOST)) {
|
||||||
|
return Boolean(config[CONFIG_KEYS.DEV_HOST]);
|
||||||
|
}
|
||||||
|
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||||
|
return Boolean(import.meta.env.DEV);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of {@link isDevelopment} when no explicit production flag exists.
|
||||||
|
*/
|
||||||
|
export function isProduction() {
|
||||||
|
if (typeof import.meta !== 'undefined' && import.meta.env && typeof import.meta.env.PROD === 'boolean') {
|
||||||
|
return import.meta.env.PROD;
|
||||||
|
}
|
||||||
|
return !isDevelopment();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
function getDesktopBridge() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.__bfaceDesktop || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostKind() {
|
||||||
|
const bridge = getDesktopBridge();
|
||||||
|
if (bridge?.hostKind) {
|
||||||
|
return bridge.hostKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridge?.isElectron) {
|
||||||
|
return 'electron';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && (window.__TAURI__ || window.__TAURI_INTERNALS__)) {
|
||||||
|
return 'tauri';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isElectronHost() {
|
||||||
|
return getHostKind() === 'electron';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTauriHost() {
|
||||||
|
return getHostKind() === 'tauri';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopBridgeSafe() {
|
||||||
|
return getDesktopBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getHostKind,
|
||||||
|
isElectronHost,
|
||||||
|
isTauriHost,
|
||||||
|
getDesktopBridgeSafe
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Storage Provider System
|
||||||
|
* Provides abstraction over different storage types (KeyValueStore, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Private providers map: type.uri -> provider instance
|
||||||
|
const providers = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KeyValueStore Class
|
||||||
|
* Provides key-value storage abstraction over localStorage, IndexedDB, and OPFS
|
||||||
|
*/
|
||||||
|
class KeyValueStore {
|
||||||
|
constructor(name, backend = 'localStorage') {
|
||||||
|
this.name = name;
|
||||||
|
this.backend = backend; // 'localStorage' | 'indexedDB' | 'opfs'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item from storage
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} altValue - Alternative value to return if key not found (default: null)
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async get(key, altValue = null) {
|
||||||
|
let value;
|
||||||
|
switch (this.backend) {
|
||||||
|
case 'localStorage':
|
||||||
|
value = await this._getLocalStorage(key);
|
||||||
|
break;
|
||||||
|
case 'indexedDB':
|
||||||
|
value = await this._getIndexedDB(key);
|
||||||
|
break;
|
||||||
|
case 'opfs':
|
||||||
|
value = await this._getOPFS(key);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage backend: ${this.backend}`);
|
||||||
|
}
|
||||||
|
// Return altValue if key not found (value is null or undefined)
|
||||||
|
return value !== null && value !== undefined ? value : altValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set item in storage
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} value
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async set(key, value) {
|
||||||
|
switch (this.backend) {
|
||||||
|
case 'localStorage':
|
||||||
|
return this._setLocalStorage(key, value);
|
||||||
|
case 'indexedDB':
|
||||||
|
return this._setIndexedDB(key, value);
|
||||||
|
case 'opfs':
|
||||||
|
return this._setOPFS(key, value);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage backend: ${this.backend}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove item from storage
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async remove(key) {
|
||||||
|
switch (this.backend) {
|
||||||
|
case 'localStorage':
|
||||||
|
return this._removeLocalStorage(key);
|
||||||
|
case 'indexedDB':
|
||||||
|
return this._removeIndexedDB(key);
|
||||||
|
case 'opfs':
|
||||||
|
return this._removeOPFS(key);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage backend: ${this.backend}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all storage
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async clear() {
|
||||||
|
switch (this.backend) {
|
||||||
|
case 'localStorage':
|
||||||
|
return this._clearLocalStorage();
|
||||||
|
case 'indexedDB':
|
||||||
|
return this._clearIndexedDB();
|
||||||
|
case 'opfs':
|
||||||
|
return this._clearOPFS();
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage backend: ${this.backend}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists in storage
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async hasKey(key) {
|
||||||
|
switch (this.backend) {
|
||||||
|
case 'localStorage':
|
||||||
|
return this._hasKeyLocalStorage(key);
|
||||||
|
case 'indexedDB':
|
||||||
|
return this._hasKeyIndexedDB(key);
|
||||||
|
case 'opfs':
|
||||||
|
return this._hasKeyOPFS(key);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage backend: ${this.backend}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorage implementations
|
||||||
|
_getLocalStorage(key) {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
return Promise.resolve(value ? JSON.parse(value) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasKeyLocalStorage(key) {
|
||||||
|
return Promise.resolve(localStorage.getItem(key) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setLocalStorage(key, value) {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeLocalStorage(key) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearLocalStorage() {
|
||||||
|
localStorage.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB implementations (placeholder - to be expanded)
|
||||||
|
async _getIndexedDB(key) {
|
||||||
|
// TODO: Implement IndexedDB access
|
||||||
|
throw new Error('IndexedDB not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _hasKeyIndexedDB(key) {
|
||||||
|
// TODO: Implement IndexedDB access
|
||||||
|
throw new Error('IndexedDB not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _setIndexedDB(key, value) {
|
||||||
|
// TODO: Implement IndexedDB access
|
||||||
|
throw new Error('IndexedDB not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _removeIndexedDB(key) {
|
||||||
|
// TODO: Implement IndexedDB access
|
||||||
|
throw new Error('IndexedDB not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _clearIndexedDB() {
|
||||||
|
// TODO: Implement IndexedDB access
|
||||||
|
throw new Error('IndexedDB not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPFS implementations (placeholder - to be expanded)
|
||||||
|
async _getOPFS(key) {
|
||||||
|
// TODO: Implement OPFS access
|
||||||
|
throw new Error('OPFS not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _hasKeyOPFS(key) {
|
||||||
|
// TODO: Implement OPFS access
|
||||||
|
throw new Error('OPFS not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _setOPFS(key, value) {
|
||||||
|
// TODO: Implement OPFS access
|
||||||
|
throw new Error('OPFS not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _removeOPFS(key) {
|
||||||
|
// TODO: Implement OPFS access
|
||||||
|
throw new Error('OPFS not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _clearOPFS() {
|
||||||
|
// TODO: Implement OPFS access
|
||||||
|
throw new Error('OPFS not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a storage provider
|
||||||
|
* @param {string} type - Provider type (e.g., "kv" for KeyValueStore)
|
||||||
|
* @param {string} uri - Provider URI/identifier (e.g., "config", "cache")
|
||||||
|
* @returns {KeyValueStore|null} Provider instance or null if type not supported
|
||||||
|
*/
|
||||||
|
export function getProvider(type, uri) {
|
||||||
|
const key = `${type}.${uri}`;
|
||||||
|
|
||||||
|
// Check if provider already exists
|
||||||
|
if (providers.has(key)) {
|
||||||
|
return providers.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new provider based on type
|
||||||
|
if (type === 'kv') {
|
||||||
|
const provider = new KeyValueStore(uri, 'localStorage');
|
||||||
|
providers.set(key, provider);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type not supported
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export KeyValueStore class for direct instantiation if needed
|
||||||
|
export { KeyValueStore };
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Service Worker Registration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isElectronHost } from './host.js';
|
||||||
|
import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js';
|
||||||
|
|
||||||
|
const SW_PATH = '/sw.js';
|
||||||
|
const SW_SCOPE = '/';
|
||||||
|
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all caches
|
||||||
|
*/
|
||||||
|
export async function clearAllCaches() {
|
||||||
|
if ('caches' in window) {
|
||||||
|
try {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map(name => {
|
||||||
|
console.log('[SW] Clearing cache:', name);
|
||||||
|
return caches.delete(name);
|
||||||
|
}));
|
||||||
|
console.log('[SW] All caches cleared');
|
||||||
|
return cacheNames.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Failed to clear caches:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all service workers
|
||||||
|
*/
|
||||||
|
export async function unregisterAllServiceWorkers() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(registrations.map(reg => {
|
||||||
|
console.log('[SW] Unregistering service worker:', reg.scope);
|
||||||
|
return reg.unregister();
|
||||||
|
}));
|
||||||
|
console.log(`[SW] Unregistered ${registrations.length} service worker(s)`);
|
||||||
|
return registrations.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Failed to unregister service workers:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||||||
|
*/
|
||||||
|
export async function clearAllStorage() {
|
||||||
|
try {
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.clear();
|
||||||
|
console.log('[Storage] localStorage cleared');
|
||||||
|
|
||||||
|
// Clear sessionStorage
|
||||||
|
sessionStorage.clear();
|
||||||
|
console.log('[Storage] sessionStorage cleared');
|
||||||
|
|
||||||
|
// Clear IndexedDB databases
|
||||||
|
if ('indexedDB' in window) {
|
||||||
|
const databases = await indexedDB.databases();
|
||||||
|
await Promise.all(databases.map(db => {
|
||||||
|
if (db.name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deleteReq = indexedDB.deleteDatabase(db.name);
|
||||||
|
deleteReq.onsuccess = () => {
|
||||||
|
console.log(`[Storage] IndexedDB database "${db.name}" deleted`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
deleteReq.onerror = () => {
|
||||||
|
console.warn(`[Storage] Failed to delete IndexedDB database "${db.name}"`);
|
||||||
|
resolve(); // Continue even if one fails
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Storage] All storage cleared');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Storage] Failed to clear storage:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear everything: caches, service workers, and storage
|
||||||
|
* This is the main utility function for clearing all PWA data
|
||||||
|
*/
|
||||||
|
export async function clearPWACache() {
|
||||||
|
console.log('🧹 Clearing all PWA caches and storage...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Unregister service workers
|
||||||
|
const swCount = await unregisterAllServiceWorkers();
|
||||||
|
|
||||||
|
// 2. Clear all caches
|
||||||
|
const cacheCount = await clearAllCaches();
|
||||||
|
|
||||||
|
// 3. Clear all storage
|
||||||
|
await clearAllStorage();
|
||||||
|
|
||||||
|
console.log(`✅ PWA cache cleared: ${swCount} service worker(s), ${cacheCount} cache(s), and all storage`);
|
||||||
|
console.log('💡 Reload the page to re-register service workers');
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceWorkers: swCount,
|
||||||
|
caches: cacheCount,
|
||||||
|
storage: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to clear PWA cache:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register service worker
|
||||||
|
*/
|
||||||
|
export async function registerServiceWorker() {
|
||||||
|
if (isElectronHost()) {
|
||||||
|
console.log('[SW] Skipping service worker registration in Electron host');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||||
|
if (devHost) {
|
||||||
|
console.log('[SW] Skipping service worker registration in development');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Register or get existing service worker
|
||||||
|
const registration = await navigator.serviceWorker.register(SW_PATH, {
|
||||||
|
scope: SW_SCOPE,
|
||||||
|
updateViaCache: 'none' // Always fetch fresh service worker
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Service Worker registered:', registration);
|
||||||
|
|
||||||
|
// Force immediate update check to get latest version
|
||||||
|
await registration.update();
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
console.log('[SW] New service worker available, reloading...');
|
||||||
|
// Force reload to activate new worker
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.log('[SW] Service worker installed for the first time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return registration;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Service Workers are not supported');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In development, stale service workers can break Vite module loading by
|
||||||
|
* intercepting /@fs and related requests. Clear them once and reload cleanly.
|
||||||
|
*/
|
||||||
|
export async function ensureDevelopmentServiceWorkerState() {
|
||||||
|
if (isElectronHost()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||||
|
if (!devHost || typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasController = Boolean(navigator.serviceWorker.controller);
|
||||||
|
const alreadyReset = sessionStorage.getItem(DEV_SW_RESET_KEY) === '1';
|
||||||
|
|
||||||
|
if (!hasController || alreadyReset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unregisterAllServiceWorkers();
|
||||||
|
await clearAllCaches();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SW] Failed to reset dev service workers:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(DEV_SW_RESET_KEY, '1');
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister service worker
|
||||||
|
*/
|
||||||
|
export async function unregisterServiceWorker() {
|
||||||
|
if (isElectronHost()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (registration) {
|
||||||
|
await registration.unregister();
|
||||||
|
console.log('Service Worker unregistered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './model/index.js';
|
||||||
|
export * from './policy/index.js';
|
||||||
|
export * from './runtime/security-service.js';
|
||||||
|
export * from './runtime/route-guards.js';
|
||||||
|
export * from './runtime/account-tabs.js';
|
||||||
|
export * from './pages/index.js';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class AccountProfile {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.user_id = data.user_id || null;
|
||||||
|
this.display_name = data.display_name || '';
|
||||||
|
this.email = data.email || '';
|
||||||
|
this.image_url = data.image_url || '';
|
||||||
|
this.preferences = data.preferences || {};
|
||||||
|
this.updated_on = data.updated_on || new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { normalizeRightsInput } from './rights.js';
|
||||||
|
|
||||||
|
export class Permit {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || null;
|
||||||
|
this.principal_type = data.principal_type || 'role';
|
||||||
|
this.principal_id = data.principal_id || '';
|
||||||
|
this.resource_path = data.resource_path || '*';
|
||||||
|
this.rights = normalizeRightsInput(data.rights || 0);
|
||||||
|
this.effect = data.effect || 'allow';
|
||||||
|
this.created_on = data.created_on || new Date().toISOString();
|
||||||
|
this.updated_on = data.updated_on || this.created_on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class Realm {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || null;
|
||||||
|
this.name = data.name || '';
|
||||||
|
this.description = data.description || '';
|
||||||
|
this.created_on = data.created_on || new Date().toISOString();
|
||||||
|
this.updated_on = data.updated_on || this.created_on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class Resource {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.path = data.path || '/';
|
||||||
|
this.realm_id = data.realm_id || 'local';
|
||||||
|
this.type = data.type || 'ui-route';
|
||||||
|
this.metadata = data.metadata || {};
|
||||||
|
this.created_on = data.created_on || new Date().toISOString();
|
||||||
|
this.updated_on = data.updated_on || this.created_on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class Role {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || null;
|
||||||
|
this.name = data.name || '';
|
||||||
|
this.realm_id = data.realm_id || 'local';
|
||||||
|
this.description = data.description || '';
|
||||||
|
this.created_on = data.created_on || new Date().toISOString();
|
||||||
|
this.updated_on = data.updated_on || this.created_on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export class Session {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.user_id = data.user_id || null;
|
||||||
|
this.realm_id = data.realm_id || 'local';
|
||||||
|
this.jwt_token = data.jwt_token || '';
|
||||||
|
this.refresh_token = data.refresh_token || '';
|
||||||
|
this.issued_on = data.issued_on || new Date().toISOString();
|
||||||
|
this.expires_on = data.expires_on || null;
|
||||||
|
this.auth_provider = data.auth_provider || 'basic';
|
||||||
|
this.status = data.status || 'active';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export class User {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || null;
|
||||||
|
this.username = data.username || '';
|
||||||
|
this.display_name = data.display_name || data.displayName || '';
|
||||||
|
this.email = data.email || '';
|
||||||
|
this.image_url = data.image_url || '';
|
||||||
|
this.realm_id = data.realm_id || 'local';
|
||||||
|
this.role_ids = Array.isArray(data.role_ids) ? [...data.role_ids] : [];
|
||||||
|
this.status = data.status || 'active';
|
||||||
|
this.password_hash = data.password_hash || '';
|
||||||
|
this.created_on = data.created_on || new Date().toISOString();
|
||||||
|
this.updated_on = data.updated_on || this.created_on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export { AccountProfile } from './AccountProfile.js';
|
||||||
|
export { Permit } from './Permit.js';
|
||||||
|
export { Realm } from './Realm.js';
|
||||||
|
export { Resource } from './Resource.js';
|
||||||
|
export { Role } from './Role.js';
|
||||||
|
export { Session } from './Session.js';
|
||||||
|
export { User } from './User.js';
|
||||||
|
export {
|
||||||
|
SECURITY_RIGHTS,
|
||||||
|
SECURITY_RIGHT_NAMES,
|
||||||
|
normalizeRightsInput,
|
||||||
|
rightsToArray,
|
||||||
|
rightsToObject,
|
||||||
|
hasRequiredRights
|
||||||
|
} from './rights.js';
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export const SECURITY_RIGHTS = {
|
||||||
|
read: 1 << 0,
|
||||||
|
write: 1 << 1,
|
||||||
|
delete: 1 << 2,
|
||||||
|
execute: 1 << 3,
|
||||||
|
secure: 1 << 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECURITY_RIGHT_NAMES = Object.keys(SECURITY_RIGHTS);
|
||||||
|
|
||||||
|
export function normalizeRightsInput(rights = 0) {
|
||||||
|
if (typeof rights === 'number' && Number.isFinite(rights)) {
|
||||||
|
return rights;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rights === 'string') {
|
||||||
|
return rights
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(rights)) {
|
||||||
|
return rights.reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rights && typeof rights === 'object') {
|
||||||
|
return SECURITY_RIGHT_NAMES.reduce((mask, right) => {
|
||||||
|
return rights[right] ? mask | SECURITY_RIGHTS[right] : mask;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rightsToObject(rights = 0) {
|
||||||
|
const mask = normalizeRightsInput(rights);
|
||||||
|
return SECURITY_RIGHT_NAMES.reduce((result, right) => {
|
||||||
|
result[right] = (mask & SECURITY_RIGHTS[right]) !== 0;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rightsToArray(rights = 0) {
|
||||||
|
const mask = normalizeRightsInput(rights);
|
||||||
|
return SECURITY_RIGHT_NAMES.filter((right) => (mask & SECURITY_RIGHTS[right]) !== 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRequiredRights(grantedRights = 0, requestedRights = 0) {
|
||||||
|
const grantedMask = normalizeRightsInput(grantedRights);
|
||||||
|
const requestedMask = normalizeRightsInput(requestedRights);
|
||||||
|
if (requestedMask === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (grantedMask & requestedMask) === requestedMask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Page } from '../../ui/components/Page.jsx';
|
||||||
|
import { AccountProfilePage } from './AccountProfilePage.jsx';
|
||||||
|
|
||||||
|
export function AccountHomePage({ title = 'Account', icon = 'account' }) {
|
||||||
|
return (
|
||||||
|
<Page icon={icon} title={title}>
|
||||||
|
<AccountProfilePage />
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileHomePage() {
|
||||||
|
return <AccountHomePage title="Profile" icon="account" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountHomePage;
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Button, Input, Label, Paragraph, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { useApp } from '../../ui/App.jsx';
|
||||||
|
import { Panel } from '../../ui/components/Panel.jsx';
|
||||||
|
import { pickImage } from '../../platform/compat.js';
|
||||||
|
import { useAccountTabs } from '../runtime/account-tabs.js';
|
||||||
|
|
||||||
|
function AccountExtensionTabs() {
|
||||||
|
const tabs = useAccountTabs();
|
||||||
|
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
No additional account extensions are registered yet.
|
||||||
|
</Paragraph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack gap="$3">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const TabComponent = tab.component;
|
||||||
|
return (
|
||||||
|
<Panel key={tab.id} icon={tab.icon || 'folder'} title={tab.label} headerSize={3}>
|
||||||
|
<TabComponent />
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountProfilePage() {
|
||||||
|
const { security } = useApp();
|
||||||
|
const profile = security.profile || {};
|
||||||
|
const user = security.user || {};
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
image_url: ''
|
||||||
|
});
|
||||||
|
const [passwords, setPasswords] = useState({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: ''
|
||||||
|
});
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [uploadMessage, setUploadMessage] = useState('');
|
||||||
|
const initials = useMemo(() => {
|
||||||
|
const source = form.display_name || user.username || 'U';
|
||||||
|
return source
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() || '')
|
||||||
|
.join('');
|
||||||
|
}, [form.display_name, user.username]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
display_name: profile.display_name || user.display_name || '',
|
||||||
|
email: profile.email || user.email || '',
|
||||||
|
image_url: profile.image_url || user.image_url || ''
|
||||||
|
});
|
||||||
|
}, [profile, user]);
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await security.updateAccountProfile(form);
|
||||||
|
setMessage('Profile updated');
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await security.changePassword(passwords);
|
||||||
|
setPasswords({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: ''
|
||||||
|
});
|
||||||
|
setMessage('Password updated');
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message || 'Failed to update password');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = (event) => {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
savePassword();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickProfileImage = async () => {
|
||||||
|
const selection = await pickImage();
|
||||||
|
if (!selection?.file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm((state) => ({ ...state, image_url: selection.result || '' }));
|
||||||
|
setUploadMessage(`Selected ${selection.file.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearProfileImage = () => {
|
||||||
|
setUploadMessage('');
|
||||||
|
setForm((state) => ({ ...state, image_url: '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack gap="$4">
|
||||||
|
<Panel icon="account" title="Account Profile">
|
||||||
|
<YStack gap="$4">
|
||||||
|
<XStack gap="$5" flexWrap="wrap" alignItems="flex-start">
|
||||||
|
<YStack flex={1} minWidth={260} gap="$4">
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Label htmlFor="profile-display-name" color="$color" fontWeight="600">
|
||||||
|
Display Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-display-name"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={form.display_name}
|
||||||
|
onChangeText={(value) => setForm((state) => ({ ...state, display_name: value }))}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Label htmlFor="profile-email" color="$color" fontWeight="600">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={form.email}
|
||||||
|
onChangeText={(value) => setForm((state) => ({ ...state, email: value }))}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<Button themeInverse backgroundColor="$accentColor" color="white" onPress={saveProfile} alignSelf="flex-start">
|
||||||
|
Save Profile
|
||||||
|
</Button>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<YStack
|
||||||
|
width={220}
|
||||||
|
minWidth={220}
|
||||||
|
gap="$3"
|
||||||
|
padding="$4"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderRadius="$5"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$accentBorder"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
cursor="pointer"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
onPress={pickProfileImage}
|
||||||
|
>
|
||||||
|
<Avatar circular size="$8" backgroundColor="$accentColor">
|
||||||
|
{form.image_url ? <Avatar.Image src={form.image_url} /> : null}
|
||||||
|
<Avatar.Fallback backgroundColor="$accentColor">
|
||||||
|
<Text color="white" fontWeight="700">{initials}</Text>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar>
|
||||||
|
<Text color="$accentColor" fontSize="$3" fontWeight="600">
|
||||||
|
Change photo
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<YStack gap="$1.5" alignItems="center">
|
||||||
|
<Text fontSize="$6" fontWeight="700" color="$accentColor" textAlign="center">
|
||||||
|
{form.display_name || user.username || 'Anonymous'}
|
||||||
|
</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.75} textAlign="center">
|
||||||
|
{user.username ? `Username: ${user.username}` : 'Not authenticated'}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph color="$color" opacity={0.65} textAlign="center">
|
||||||
|
Realm: {security.realm?.name || security.realm?.id || 'local'}
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
|
||||||
|
<Button size="$3" onPress={pickProfileImage}>
|
||||||
|
Select Image
|
||||||
|
</Button>
|
||||||
|
{form.image_url ? (
|
||||||
|
<Button size="$3" chromeless onPress={clearProfileImage}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<Paragraph color="$color" opacity={0.6} fontSize="$3" textAlign="center">
|
||||||
|
JPG, PNG, GIF, or WebP. The selected image is stored with your account profile.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{uploadMessage ? (
|
||||||
|
<Text color="$accentColor" fontSize="$3" textAlign="center">
|
||||||
|
{uploadMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel icon="lock" title="Password">
|
||||||
|
<YStack tag="form" gap="$4" onSubmit={handlePasswordSubmit} autoComplete="on">
|
||||||
|
<Input
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
value={user.username || form.email || ''}
|
||||||
|
readOnly
|
||||||
|
autoComplete="username"
|
||||||
|
textContentType="username"
|
||||||
|
opacity={0}
|
||||||
|
position="absolute"
|
||||||
|
pointerEvents="none"
|
||||||
|
height={1}
|
||||||
|
width={1}
|
||||||
|
overflow="hidden"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Current password"
|
||||||
|
value={passwords.currentPassword}
|
||||||
|
onChangeText={(value) => setPasswords((state) => ({ ...state, currentPassword: value }))}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="current-password"
|
||||||
|
textContentType="password"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
value={passwords.newPassword}
|
||||||
|
onChangeText={(value) => setPasswords((state) => ({ ...state, newPassword: value }))}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="new-password"
|
||||||
|
textContentType="newPassword"
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={savePassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Button onPress={savePassword} type="submit">
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
{message ? (
|
||||||
|
<Text color="$accentColor" fontSize="$4">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel icon="library" title="Connected Account Panels">
|
||||||
|
<ScrollView maxHeight={320}>
|
||||||
|
<AccountExtensionTabs />
|
||||||
|
</ScrollView>
|
||||||
|
</Panel>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountProfilePage;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, Text, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from '../../ui/components/IconMapper.jsx';
|
||||||
|
|
||||||
|
export function ErrorPage({
|
||||||
|
title = 'Something went wrong',
|
||||||
|
message = 'The application could not complete that request.',
|
||||||
|
icon = 'error',
|
||||||
|
error = null,
|
||||||
|
debug = false
|
||||||
|
}) {
|
||||||
|
const IconComponent = getIcon(icon) || getIcon('error');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
flex={1}
|
||||||
|
minHeight="100vh"
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$6"
|
||||||
|
backgroundColor="$background"
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
width="100%"
|
||||||
|
maxWidth={680}
|
||||||
|
padding="$6"
|
||||||
|
gap="$4"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$accentBorder"
|
||||||
|
borderRadius="$6"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
shadowOpacity={0.18}
|
||||||
|
shadowRadius={18}
|
||||||
|
>
|
||||||
|
<YStack gap="$3" alignItems="center">
|
||||||
|
{IconComponent ? <IconComponent size={36} color="$accentColor" /> : null}
|
||||||
|
<Text fontSize="$8" fontWeight="700" color="$accentColor" textAlign="center">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$5" color="$color" textAlign="center" opacity={0.88}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
{debug && error ? (
|
||||||
|
<ScrollView
|
||||||
|
maxHeight={260}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
borderRadius="$4"
|
||||||
|
backgroundColor="$background"
|
||||||
|
padding="$4"
|
||||||
|
>
|
||||||
|
<Text fontFamily="$mono" fontSize="$3" color="$color">
|
||||||
|
{error.stack || error.message || String(error)}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { forwardRef, useRef, useState } from 'react';
|
||||||
|
import { Button, Input, Label, Paragraph, Text, YStack } from 'tamagui';
|
||||||
|
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||||
|
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
||||||
|
import { Panel } from '../../ui/components/Panel.jsx';
|
||||||
|
|
||||||
|
const LoginField = forwardRef(function LoginField({ id, label, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Label htmlFor={id} size="$3" color="$color" fontWeight="600">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input ref={ref} id={id} {...props} />
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign in to continue' }) {
|
||||||
|
const security = useSecurityState();
|
||||||
|
const passwordInputRef = useRef(null);
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setErrorMessage('');
|
||||||
|
if (!security.enabled) {
|
||||||
|
setErrorMessage('Identity is disabled in the app profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!security.initialized || !security.policy) {
|
||||||
|
setErrorMessage('Security is still initializing. Reload once if this persists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await securityService.login({
|
||||||
|
username: identifier,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
const currentPath = await getRouterPath('/home');
|
||||||
|
if (currentPath === (security.config.login_route || '/login')) {
|
||||||
|
await setRouterPath('/home', true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message || 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordKeyDown = (event) => {
|
||||||
|
if (event?.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault?.();
|
||||||
|
handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (event) => {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Panel
|
||||||
|
icon="login"
|
||||||
|
title={title}
|
||||||
|
width="100%"
|
||||||
|
headerFront={{ color: '$accentColor' }}
|
||||||
|
headerBack={{ backgroundColor: '$accentSurface' }}
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
tag="form"
|
||||||
|
gap="$4"
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
autoComplete="on"
|
||||||
|
>
|
||||||
|
<Paragraph color="$color" opacity={0.78}>
|
||||||
|
{subtitle}
|
||||||
|
</Paragraph>
|
||||||
|
<LoginField
|
||||||
|
id="login-identifier"
|
||||||
|
label="Username or email"
|
||||||
|
placeholder="Username or email"
|
||||||
|
value={identifier}
|
||||||
|
onChangeText={setIdentifier}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="username"
|
||||||
|
textContentType="username"
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => passwordInputRef.current?.focus?.()}
|
||||||
|
/>
|
||||||
|
<LoginField
|
||||||
|
id="login-password"
|
||||||
|
label="Password"
|
||||||
|
ref={passwordInputRef}
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="current-password"
|
||||||
|
textContentType="password"
|
||||||
|
secureTextEntry
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
onKeyDown={handlePasswordKeyDown}
|
||||||
|
/>
|
||||||
|
{errorMessage ? (
|
||||||
|
<Text color="#ef4444" fontSize="$4">
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
themeInverse
|
||||||
|
backgroundColor="$accentColor"
|
||||||
|
color="white"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={security.loading || !security.enabled || !security.initialized}
|
||||||
|
>
|
||||||
|
{security.loading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
{!security.enabled ? (
|
||||||
|
<Paragraph fontSize="$3" color="#8b5e3c">
|
||||||
|
Identity is currently disabled in the active app profile.
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
{security.enabled && !security.initialized ? (
|
||||||
|
<Paragraph fontSize="$3" color="#8b5e3c">
|
||||||
|
Security is still initializing.
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Paragraph fontSize="$3" color="$color" opacity={0.65}>
|
||||||
|
Demo credentials: admin / admin or demo / demo
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
flex={1}
|
||||||
|
minHeight="100vh"
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$6"
|
||||||
|
backgroundColor="$background"
|
||||||
|
>
|
||||||
|
<YStack width="100%" maxWidth={520}>
|
||||||
|
{content}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Paragraph, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { SettingsPanel } from '../../ui/components/SettingsPanel.jsx';
|
||||||
|
import { rightsToArray } from '../model/rights.js';
|
||||||
|
import { useApp } from '../../ui/App.jsx';
|
||||||
|
|
||||||
|
function renderSectionBody(items, renderItem, emptyText) {
|
||||||
|
return (
|
||||||
|
<YStack gap="$3">
|
||||||
|
{items.length > 0 ? items.map(renderItem) : (
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
{emptyText}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityAdminPage() {
|
||||||
|
const { security } = useApp();
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [realms, setRealms] = useState([]);
|
||||||
|
const [resources, setResources] = useState([]);
|
||||||
|
const [permits, setPermits] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function loadSecurityData() {
|
||||||
|
try {
|
||||||
|
const [nextUsers, nextRoles, nextRealms, nextResources, nextPermits] = await Promise.all([
|
||||||
|
security.listUsers(),
|
||||||
|
security.listRoles(),
|
||||||
|
security.listRealms(),
|
||||||
|
security.listResources(),
|
||||||
|
security.listPermits()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setRoles(nextRoles);
|
||||||
|
setRealms(nextRealms);
|
||||||
|
setResources(nextResources);
|
||||||
|
setPermits(nextPermits);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SecurityAdminPage] Failed to load security data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (security.enabled && security.isAuthenticated) {
|
||||||
|
loadSecurityData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [security.enabled, security.isAuthenticated, security.user?.id]);
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
label: 'Users',
|
||||||
|
icon: 'group',
|
||||||
|
content: renderSectionBody(users, (user) => (
|
||||||
|
<XStack key={user.id} justifyContent="space-between" flexWrap="wrap" gap="$2">
|
||||||
|
<YStack>
|
||||||
|
<Text fontWeight="700">{user.display_name || user.username}</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.7}>{user.email}</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
<Text color="$accentColor">{(user.role_ids || []).join(', ') || 'no roles'}</Text>
|
||||||
|
</XStack>
|
||||||
|
), 'No users available.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roles',
|
||||||
|
label: 'Roles',
|
||||||
|
icon: 'lock',
|
||||||
|
content: renderSectionBody(roles, (role) => (
|
||||||
|
<YStack key={role.id} gap="$1">
|
||||||
|
<Text fontWeight="700">{role.name}</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.7}>{role.description || role.id}</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
), 'No roles available.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realms',
|
||||||
|
label: 'Realms',
|
||||||
|
icon: 'network',
|
||||||
|
content: renderSectionBody(realms, (realm) => (
|
||||||
|
<YStack key={realm.id} gap="$1">
|
||||||
|
<Text fontWeight="700">{realm.name}</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.7}>{realm.description || realm.id}</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
), 'No realms available.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resources',
|
||||||
|
label: 'Resources',
|
||||||
|
icon: 'library',
|
||||||
|
content: renderSectionBody(resources, (resource) => (
|
||||||
|
<YStack key={resource.path} gap="$1">
|
||||||
|
<Text fontWeight="700">{resource.path}</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.7}>{resource.type} in realm {resource.realm_id}</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
), 'No resources registered.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'permits',
|
||||||
|
label: 'Permits',
|
||||||
|
icon: 'lock',
|
||||||
|
content: renderSectionBody(permits, (permit) => (
|
||||||
|
<YStack key={permit.id} gap="$1">
|
||||||
|
<Text fontWeight="700">
|
||||||
|
{permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id}
|
||||||
|
</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
{permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'}
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
), 'No permits registered.')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPanel
|
||||||
|
icon="lock"
|
||||||
|
title="Security"
|
||||||
|
description="Security provider details, authenticated state, and the currently loaded policy dataset."
|
||||||
|
defaultExpanded={false}
|
||||||
|
persistenceKey="settings.security"
|
||||||
|
content={content}
|
||||||
|
contentStyle="tabs"
|
||||||
|
>
|
||||||
|
<YStack gap="$4">
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text color="$accentColor" fontWeight="700">
|
||||||
|
Provider: {security.config.provider}
|
||||||
|
</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.75}>
|
||||||
|
Security is {security.enabled ? 'enabled' : 'disabled'}.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph color="$color" opacity={0.75}>
|
||||||
|
Authenticated user: {security.user?.display_name || security.user?.username || 'none'}
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
</SettingsPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SecurityAdminPage;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { LoginPage } from './LoginPage.jsx';
|
||||||
|
export { AccountProfilePage } from './AccountProfilePage.jsx';
|
||||||
|
export { AccountHomePage, ProfileHomePage } from './AccountHomePage.jsx';
|
||||||
|
export { SecurityAdminPage } from './SecurityAdminPage.jsx';
|
||||||
|
export { ErrorPage } from './ErrorPage.jsx';
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { getProvider } from '../../platform/storage.js';
|
||||||
|
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||||
|
import { AccountProfile, Permit, Realm, Resource, Role, Session, User, SECURITY_RIGHTS, hasRequiredRights, normalizeRightsInput } from '../model/index.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'security.basic.dataset';
|
||||||
|
const SESSION_KEY = 'security.basic.session';
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowISO() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId(prefix) {
|
||||||
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatches(resourcePath, targetPath) {
|
||||||
|
if (!resourcePath || resourcePath === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcePath.endsWith('*')) {
|
||||||
|
const prefix = resourcePath.slice(0, -1);
|
||||||
|
return targetPath.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||||
|
return `plain:${password}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = new TextEncoder().encode(password);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
||||||
|
const hashArray = Array.from(new Uint8Array(digest));
|
||||||
|
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `sha256:${hashHex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPassword(password, storedHash) {
|
||||||
|
if (!storedHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedHash.startsWith('plain:')) {
|
||||||
|
return storedHash === `plain:${password}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await hashPassword(password)) === storedHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateAgainstDataset(dataset, userId, rights, resourcePath, context = {}) {
|
||||||
|
const targetRights = normalizeRightsInput(rights);
|
||||||
|
const targetPath = resourcePath || context.resource_path || '/';
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: true,
|
||||||
|
reason: 'Authentication required',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: true,
|
||||||
|
reason: 'Session user not found',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const principals = [
|
||||||
|
{ principal_type: 'user', principal_id: user.id }
|
||||||
|
];
|
||||||
|
user.role_ids.forEach((roleId) => principals.push({ principal_type: 'role', principal_id: roleId }));
|
||||||
|
|
||||||
|
const matchingPermits = dataset.permits.filter((permit) => {
|
||||||
|
const principalMatch = principals.some((principal) => {
|
||||||
|
return principal.principal_type === permit.principal_type && principal.principal_id === permit.principal_id;
|
||||||
|
});
|
||||||
|
return principalMatch && pathMatches(permit.resource_path, targetPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, targetRights));
|
||||||
|
if (denyMatch) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Denied by explicit policy',
|
||||||
|
matched_permits: clone([denyMatch])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowMatches = matchingPermits.filter((permit) => permit.effect === 'allow' && hasRequiredRights(permit.rights, targetRights));
|
||||||
|
if (allowMatches.length > 0 || targetRights === 0) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: allowMatches.length > 0 ? 'Permit granted' : 'No specific rights requested',
|
||||||
|
matched_permits: clone(allowMatches)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: false,
|
||||||
|
reason: `Missing required rights on ${targetPath}`,
|
||||||
|
matched_permits: clone(matchingPermits)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BasicSecurityPolicy extends SecurityPolicy {
|
||||||
|
constructor(config = {}) {
|
||||||
|
super(config);
|
||||||
|
this.storage = getProvider('kv', 'security.basic');
|
||||||
|
this.dataset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const dataset = await this.storage.get(STORAGE_KEY, null);
|
||||||
|
|
||||||
|
if (dataset) {
|
||||||
|
this.dataset = dataset;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRole = new Role({
|
||||||
|
id: 'role_admin',
|
||||||
|
name: 'Administrators',
|
||||||
|
description: 'Full access to all registered resources'
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberRole = new Role({
|
||||||
|
id: 'role_member',
|
||||||
|
name: 'Members',
|
||||||
|
description: 'Standard authenticated user role'
|
||||||
|
});
|
||||||
|
|
||||||
|
const realm = new Realm({
|
||||||
|
id: 'local',
|
||||||
|
name: 'Local Realm',
|
||||||
|
description: 'Local skeleton application realm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminUser = new User({
|
||||||
|
id: 'user_admin',
|
||||||
|
username: 'admin',
|
||||||
|
display_name: 'Local Administrator',
|
||||||
|
email: 'admin@local.realm',
|
||||||
|
realm_id: realm.id,
|
||||||
|
role_ids: [adminRole.id],
|
||||||
|
password_hash: await hashPassword('admin')
|
||||||
|
});
|
||||||
|
|
||||||
|
const demoUser = new User({
|
||||||
|
id: 'user_demo',
|
||||||
|
username: 'demo',
|
||||||
|
display_name: 'Demo User',
|
||||||
|
email: 'demo@local.realm',
|
||||||
|
realm_id: realm.id,
|
||||||
|
role_ids: [memberRole.id],
|
||||||
|
password_hash: await hashPassword('demo')
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminProfile = new AccountProfile({
|
||||||
|
user_id: adminUser.id,
|
||||||
|
display_name: adminUser.display_name,
|
||||||
|
email: adminUser.email
|
||||||
|
});
|
||||||
|
|
||||||
|
const demoProfile = new AccountProfile({
|
||||||
|
user_id: demoUser.id,
|
||||||
|
display_name: demoUser.display_name,
|
||||||
|
email: demoUser.email
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataset = {
|
||||||
|
version: 1,
|
||||||
|
realms: [realm],
|
||||||
|
roles: [adminRole, memberRole],
|
||||||
|
users: [adminUser, demoUser],
|
||||||
|
resources: [
|
||||||
|
new Resource({ path: '/login', realm_id: realm.id, type: 'ui-route' }),
|
||||||
|
new Resource({ path: '/settings/account', realm_id: realm.id, type: 'ui-route' }),
|
||||||
|
new Resource({ path: '/settings/security', realm_id: realm.id, type: 'ui-route' })
|
||||||
|
],
|
||||||
|
permits: [
|
||||||
|
new Permit({
|
||||||
|
id: 'permit_admin_all',
|
||||||
|
principal_type: 'role',
|
||||||
|
principal_id: adminRole.id,
|
||||||
|
resource_path: '*',
|
||||||
|
rights: Object.values(SECURITY_RIGHTS).reduce((mask, value) => mask | value, 0),
|
||||||
|
effect: 'allow'
|
||||||
|
}),
|
||||||
|
new Permit({
|
||||||
|
id: 'permit_member_account',
|
||||||
|
principal_type: 'role',
|
||||||
|
principal_id: memberRole.id,
|
||||||
|
resource_path: '/settings/account',
|
||||||
|
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.write,
|
||||||
|
effect: 'allow'
|
||||||
|
}),
|
||||||
|
new Permit({
|
||||||
|
id: 'permit_member_home',
|
||||||
|
principal_type: 'role',
|
||||||
|
principal_id: memberRole.id,
|
||||||
|
resource_path: '/home',
|
||||||
|
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.execute,
|
||||||
|
effect: 'allow'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
profiles: [adminProfile, demoProfile]
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._persistDataset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _persistDataset() {
|
||||||
|
await this.storage.set(STORAGE_KEY, clone(this.dataset));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadDataset() {
|
||||||
|
if (!this.dataset) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
return this.dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(credentials = {}) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const identifier = (credentials.username || credentials.email || '').trim().toLowerCase();
|
||||||
|
const password = credentials.password || '';
|
||||||
|
|
||||||
|
const user = dataset.users.find((candidate) => {
|
||||||
|
return candidate.username.toLowerCase() === identifier || candidate.email.toLowerCase() === identifier;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Unknown user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error('Invalid password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new Session({
|
||||||
|
user_id: user.id,
|
||||||
|
realm_id: user.realm_id,
|
||||||
|
jwt_token: `local.${user.id}.${Date.now()}`,
|
||||||
|
issued_on: nowISO(),
|
||||||
|
auth_provider: 'basic',
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.saveSession(session);
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user: clone(user),
|
||||||
|
profile: await this.getAccountProfile(user.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await this.clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentSession() {
|
||||||
|
const session = await this.storage.get(SESSION_KEY, null);
|
||||||
|
return session ? new Session(session) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSession(session) {
|
||||||
|
await this.storage.set(SESSION_KEY, clone(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSession() {
|
||||||
|
await this.storage.remove(SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers() {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
return clone(dataset.users);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(userId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
|
return user ? clone(user) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(userData) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const createdOn = nowISO();
|
||||||
|
const user = new User({
|
||||||
|
...userData,
|
||||||
|
id: userData.id || createId('user'),
|
||||||
|
created_on: createdOn,
|
||||||
|
updated_on: createdOn,
|
||||||
|
password_hash: await hashPassword(userData.password || 'changeme')
|
||||||
|
});
|
||||||
|
dataset.users.push(user);
|
||||||
|
dataset.profiles.push(new AccountProfile({
|
||||||
|
user_id: user.id,
|
||||||
|
display_name: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
image_url: user.image_url || ''
|
||||||
|
}));
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(userId, patch) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found: ${userId}`);
|
||||||
|
}
|
||||||
|
Object.assign(user, patch, { updated_on: nowISO() });
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(userId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
dataset.users = dataset.users.filter((item) => item.id !== userId);
|
||||||
|
dataset.profiles = dataset.profiles.filter((item) => item.user_id !== userId);
|
||||||
|
await this._persistDataset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoles(realmId = null) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
return clone(realmId ? dataset.roles.filter((role) => role.realm_id === realmId) : dataset.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRole(roleId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const role = dataset.roles.find((item) => item.id === roleId);
|
||||||
|
return role ? clone(role) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(roleData) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const role = new Role({
|
||||||
|
...roleData,
|
||||||
|
id: roleData.id || createId('role'),
|
||||||
|
created_on: nowISO(),
|
||||||
|
updated_on: nowISO()
|
||||||
|
});
|
||||||
|
dataset.roles.push(role);
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(roleId, patch) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const role = dataset.roles.find((item) => item.id === roleId);
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(`Role not found: ${roleId}`);
|
||||||
|
}
|
||||||
|
Object.assign(role, patch, { updated_on: nowISO() });
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRole(roleId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
dataset.roles = dataset.roles.filter((item) => item.id !== roleId);
|
||||||
|
dataset.users.forEach((user) => {
|
||||||
|
user.role_ids = user.role_ids.filter((item) => item !== roleId);
|
||||||
|
});
|
||||||
|
dataset.permits = dataset.permits.filter((permit) => !(permit.principal_type === 'role' && permit.principal_id === roleId));
|
||||||
|
await this._persistDataset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRealms() {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
return clone(dataset.realms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealm(realmId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const realm = dataset.realms.find((item) => item.id === realmId);
|
||||||
|
return realm ? clone(realm) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRealm(realmData) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const realm = new Realm({
|
||||||
|
...realmData,
|
||||||
|
id: realmData.id || createId('realm'),
|
||||||
|
created_on: nowISO(),
|
||||||
|
updated_on: nowISO()
|
||||||
|
});
|
||||||
|
dataset.realms.push(realm);
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(realm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRealm(realmId, patch) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const realm = dataset.realms.find((item) => item.id === realmId);
|
||||||
|
if (!realm) {
|
||||||
|
throw new Error(`Realm not found: ${realmId}`);
|
||||||
|
}
|
||||||
|
Object.assign(realm, patch, { updated_on: nowISO() });
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(realm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerResource(resourceData) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const existing = dataset.resources.find((item) => item.path === resourceData.path);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, resourceData, { updated_on: nowISO() });
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(existing);
|
||||||
|
}
|
||||||
|
const resource = new Resource({
|
||||||
|
...resourceData,
|
||||||
|
created_on: nowISO(),
|
||||||
|
updated_on: nowISO()
|
||||||
|
});
|
||||||
|
dataset.resources.push(resource);
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(realmId = null) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
return clone(realmId ? dataset.resources.filter((item) => item.realm_id === realmId) : dataset.resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPermits(filters = {}) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
let items = dataset.permits;
|
||||||
|
if (filters.principal_type) {
|
||||||
|
items = items.filter((permit) => permit.principal_type === filters.principal_type);
|
||||||
|
}
|
||||||
|
if (filters.principal_id) {
|
||||||
|
items = items.filter((permit) => permit.principal_id === filters.principal_id);
|
||||||
|
}
|
||||||
|
return clone(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async grantPermit(permitData) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const permit = new Permit({
|
||||||
|
...permitData,
|
||||||
|
id: permitData.id || createId('permit'),
|
||||||
|
created_on: nowISO(),
|
||||||
|
updated_on: nowISO()
|
||||||
|
});
|
||||||
|
dataset.permits.push(permit);
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(permit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokePermit(permitId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
dataset.permits = dataset.permits.filter((item) => item.id !== permitId);
|
||||||
|
await this._persistDataset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountProfile(userId) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const profile = dataset.profiles.find((item) => item.user_id === userId);
|
||||||
|
return profile ? clone(profile) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountProfile(userId, patch) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const profile = dataset.profiles.find((item) => item.user_id === userId);
|
||||||
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
|
if (!profile || !user) {
|
||||||
|
throw new Error(`Profile not found for user: ${userId}`);
|
||||||
|
}
|
||||||
|
Object.assign(profile, patch, { updated_on: nowISO() });
|
||||||
|
if (patch.display_name !== undefined) user.display_name = patch.display_name;
|
||||||
|
if (patch.email !== undefined) user.email = patch.email;
|
||||||
|
if (patch.image_url !== undefined) user.image_url = patch.image_url;
|
||||||
|
user.updated_on = nowISO();
|
||||||
|
await this._persistDataset();
|
||||||
|
return clone(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userId, passwordInput = {}) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found: ${userId}`);
|
||||||
|
}
|
||||||
|
if (passwordInput.currentPassword) {
|
||||||
|
const valid = await verifyPassword(passwordInput.currentPassword, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error('Current password is invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!passwordInput.newPassword) {
|
||||||
|
throw new Error('New password is required');
|
||||||
|
}
|
||||||
|
user.password_hash = await hashPassword(passwordInput.newPassword);
|
||||||
|
user.updated_on = nowISO();
|
||||||
|
await this._persistDataset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(userId, rights, resourcePath, context = {}) {
|
||||||
|
const dataset = await this._loadDataset();
|
||||||
|
return evaluateAgainstDataset(dataset, userId, rights, resourcePath, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateSync(userId, rights, resourcePath, context = {}) {
|
||||||
|
if (!this.dataset) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Security dataset is not initialized',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluateAgainstDataset(this.dataset, userId, rights, resourcePath, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||||
|
|
||||||
|
export class BstoreSecurityPolicy extends SecurityPolicy {
|
||||||
|
async init() {
|
||||||
|
throw new Error('BstoreSecurityPolicy is not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateSync() {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'BstoreSecurityPolicy sync evaluation is not implemented',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export class SecurityPolicy {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {}
|
||||||
|
async authenticate(_credentials) { throw new Error('authenticate() not implemented'); }
|
||||||
|
async logout(_session) {}
|
||||||
|
async getCurrentSession() { return null; }
|
||||||
|
async saveSession(_session) {}
|
||||||
|
async clearSession() {}
|
||||||
|
async listUsers(_filters = {}) { return []; }
|
||||||
|
async getUser(_userId) { return null; }
|
||||||
|
async createUser(_userData) { throw new Error('createUser() not implemented'); }
|
||||||
|
async updateUser(_userId, _patch) { throw new Error('updateUser() not implemented'); }
|
||||||
|
async deleteUser(_userId) { throw new Error('deleteUser() not implemented'); }
|
||||||
|
async listRoles(_realmId = null) { return []; }
|
||||||
|
async getRole(_roleId) { return null; }
|
||||||
|
async createRole(_roleData) { throw new Error('createRole() not implemented'); }
|
||||||
|
async updateRole(_roleId, _patch) { throw new Error('updateRole() not implemented'); }
|
||||||
|
async deleteRole(_roleId) { throw new Error('deleteRole() not implemented'); }
|
||||||
|
async listRealms() { return []; }
|
||||||
|
async getRealm(_realmId) { return null; }
|
||||||
|
async createRealm(_realmData) { throw new Error('createRealm() not implemented'); }
|
||||||
|
async updateRealm(_realmId, _patch) { throw new Error('updateRealm() not implemented'); }
|
||||||
|
async registerResource(_resource) { throw new Error('registerResource() not implemented'); }
|
||||||
|
async listResources(_realmId = null) { return []; }
|
||||||
|
async listPermits(_filters = {}) { return []; }
|
||||||
|
async grantPermit(_permit) { throw new Error('grantPermit() not implemented'); }
|
||||||
|
async revokePermit(_permitId) { throw new Error('revokePermit() not implemented'); }
|
||||||
|
async getAccountProfile(_userId) { return null; }
|
||||||
|
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
|
||||||
|
async changePassword(_userId, _passwordInput) { throw new Error('changePassword() not implemented'); }
|
||||||
|
async evaluate(_userId, _rights, _resourcePath, _context = {}) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Security policy not enforced',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateSync(_userId, _rights, _resourcePath, _context = {}) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Security policy not enforced',
|
||||||
|
matched_permits: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { SecurityPolicy } from './SecurityPolicy.js';
|
||||||
|
export { BasicSecurityPolicy } from './BasicSecurityPolicy.js';
|
||||||
|
export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
const accountTabs = [];
|
||||||
|
const listeners = new Set();
|
||||||
|
let cachedTabs = [];
|
||||||
|
|
||||||
|
function rebuildSnapshot() {
|
||||||
|
cachedTabs = [...accountTabs].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Security] Account tab listener failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishAccountTab(tab) {
|
||||||
|
if (!tab || !tab.id || !tab.label || !tab.component) {
|
||||||
|
console.warn('[Security] publishAccountTab() requires id, label, and component');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = accountTabs.findIndex((item) => item.id === tab.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
accountTabs[existingIndex] = tab;
|
||||||
|
} else {
|
||||||
|
accountTabs.push(tab);
|
||||||
|
}
|
||||||
|
rebuildSnapshot();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retractAccountTab(tabId) {
|
||||||
|
const nextTabs = accountTabs.filter((tab) => tab.id !== tabId);
|
||||||
|
if (nextTabs.length !== accountTabs.length) {
|
||||||
|
accountTabs.length = 0;
|
||||||
|
accountTabs.push(...nextTabs);
|
||||||
|
rebuildSnapshot();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountTabs() {
|
||||||
|
return cachedTabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToAccountTabs(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountTabs() {
|
||||||
|
return useSyncExternalStore(subscribeToAccountTabs, getAccountTabs, getAccountTabs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function createSecurityRequestInterceptor(securityService) {
|
||||||
|
return (config = {}) => securityService.injectRequestConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSecurityResponseInterceptor(securityService) {
|
||||||
|
return (response) => {
|
||||||
|
if (response && response.status === 401) {
|
||||||
|
securityService.handleUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { normalizeRightsInput } from '../model/rights.js';
|
||||||
|
|
||||||
|
export async function evaluateRouteAccess(route = {}, securityService) {
|
||||||
|
const securityState = securityService.getState();
|
||||||
|
const options = route?.options || {};
|
||||||
|
const resourcePath = options.resource_path || route?.path || '/';
|
||||||
|
const requestedRights = normalizeRightsInput(options.required_rights || 0);
|
||||||
|
|
||||||
|
if (!securityState.enabled) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Security disabled'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.require_user && requestedRights === 0) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Route has no security requirements'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.require_user && !securityState.isAuthenticated) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
requires_login: true,
|
||||||
|
reason: 'Login required for route'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedRights !== 0) {
|
||||||
|
return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
requires_login: false,
|
||||||
|
reason: 'Authenticated route'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { setRouterPath } from '../../platform/compat.js';
|
||||||
|
import { normalizeRightsInput } from '../model/rights.js';
|
||||||
|
import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||||
|
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
|
||||||
|
|
||||||
|
const DEFAULT_SECURITY_CONFIG = {
|
||||||
|
enabled: false,
|
||||||
|
provider: 'basic',
|
||||||
|
require_login: false,
|
||||||
|
login_route: '/login',
|
||||||
|
logout_route: '/login',
|
||||||
|
default_realm: 'local',
|
||||||
|
debug_errors: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSecurityConfig(config = {}) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SECURITY_CONFIG,
|
||||||
|
...(config || {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialState() {
|
||||||
|
return {
|
||||||
|
initialized: false,
|
||||||
|
loading: false,
|
||||||
|
enabled: false,
|
||||||
|
provider: 'basic',
|
||||||
|
requireLogin: false,
|
||||||
|
config: normalizeSecurityConfig(),
|
||||||
|
policy: null,
|
||||||
|
session: null,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
realm: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecurityService {
|
||||||
|
constructor() {
|
||||||
|
this.state = createInitialState();
|
||||||
|
this.listeners = new Set();
|
||||||
|
this.apiHooksInstalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Security] Subscriber failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot = () => this.state;
|
||||||
|
getState() { return this.state; }
|
||||||
|
|
||||||
|
setState(patch) {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
...patch
|
||||||
|
};
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolvePolicy(config) {
|
||||||
|
if (config.provider === 'bstore') {
|
||||||
|
return new BstoreSecurityPolicy(config);
|
||||||
|
}
|
||||||
|
return new BasicSecurityPolicy(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(config = {}) {
|
||||||
|
const normalizedConfig = normalizeSecurityConfig(config);
|
||||||
|
|
||||||
|
if (!normalizedConfig.enabled) {
|
||||||
|
this.setState({
|
||||||
|
...createInitialState(),
|
||||||
|
initialized: true,
|
||||||
|
config: normalizedConfig,
|
||||||
|
enabled: false,
|
||||||
|
provider: normalizedConfig.provider,
|
||||||
|
requireLogin: normalizedConfig.require_login
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
enabled: true,
|
||||||
|
provider: normalizedConfig.provider,
|
||||||
|
requireLogin: normalizedConfig.require_login,
|
||||||
|
config: normalizedConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const policy = this._resolvePolicy(normalizedConfig);
|
||||||
|
await policy.init();
|
||||||
|
const session = await policy.getCurrentSession();
|
||||||
|
const user = session?.user_id ? await policy.getUser(session.user_id) : null;
|
||||||
|
const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null;
|
||||||
|
const profile = user ? await policy.getAccountProfile(user.id) : null;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
enabled: true,
|
||||||
|
provider: normalizedConfig.provider,
|
||||||
|
requireLogin: normalizedConfig.require_login,
|
||||||
|
config: normalizedConfig,
|
||||||
|
policy,
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
realm,
|
||||||
|
isAuthenticated: Boolean(session && user),
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Security] Failed to initialize security service:', error);
|
||||||
|
this.setState({
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
enabled: normalizedConfig.enabled,
|
||||||
|
provider: normalizedConfig.provider,
|
||||||
|
requireLogin: normalizedConfig.require_login,
|
||||||
|
config: normalizedConfig,
|
||||||
|
policy: null,
|
||||||
|
session: null,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
realm: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
installAPIClient(apiClient) {
|
||||||
|
if (!apiClient || this.apiHooksInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiClient.addRequestInterceptor(createSecurityRequestInterceptor(this));
|
||||||
|
apiClient.addResponseInterceptor(createSecurityResponseInterceptor(this));
|
||||||
|
this.apiHooksInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectRequestConfig(config = {}) {
|
||||||
|
if (!this.state.session?.jwt_token) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...(config.headers || {}),
|
||||||
|
Authorization: `Bearer ${this.state.session.jwt_token}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async injectAuthHeaders(headers = {}) {
|
||||||
|
return this.injectRequestConfig({ headers }).headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUnauthorizedResponse() {
|
||||||
|
if (this.state.isAuthenticated) {
|
||||||
|
await this.logout({ redirect: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(credentials = {}) {
|
||||||
|
if (!this.state.policy) {
|
||||||
|
throw new Error('Security policy is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await this.state.policy.authenticate(credentials);
|
||||||
|
const realm = result.user?.realm_id ? await this.state.policy.getRealm(result.user.realm_id) : null;
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
session: result.session,
|
||||||
|
user: result.user,
|
||||||
|
profile: result.profile || null,
|
||||||
|
realm,
|
||||||
|
isAuthenticated: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(options = {}) {
|
||||||
|
const { redirect = true } = options;
|
||||||
|
|
||||||
|
if (this.state.policy && this.state.session) {
|
||||||
|
try {
|
||||||
|
await this.state.policy.logout(this.state.session);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Security] Policy logout failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.policy) {
|
||||||
|
await this.state.policy.clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
session: null,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
realm: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession() {
|
||||||
|
if (!this.state.policy) {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.state.policy.getCurrentSession();
|
||||||
|
const user = session?.user_id ? await this.state.policy.getUser(session.user_id) : null;
|
||||||
|
const realm = user?.realm_id ? await this.state.policy.getRealm(user.realm_id) : null;
|
||||||
|
const profile = user ? await this.state.policy.getAccountProfile(user.id) : null;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
realm,
|
||||||
|
isAuthenticated: Boolean(session && user)
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerResource(resource) {
|
||||||
|
if (!this.state.policy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.state.policy.registerResource(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async userRequired(options = {}) {
|
||||||
|
if (!this.state.enabled) {
|
||||||
|
return { allowed: true, requires_login: false, reason: 'Security disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.isAuthenticated) {
|
||||||
|
return { allowed: true, requires_login: false, reason: 'User authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.redirect !== false) {
|
||||||
|
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: false, requires_login: true, reason: 'User login required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async userPermitted(rights, resourcePath, options = {}) {
|
||||||
|
if (!this.state.enabled || !this.state.policy) {
|
||||||
|
return { allowed: true, requires_login: false, reason: 'Security disabled', matched_permits: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.isAuthenticated) {
|
||||||
|
const response = { allowed: false, requires_login: true, reason: 'User login required', matched_permits: [] };
|
||||||
|
if (options.redirectOnFail) {
|
||||||
|
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.state.policy.evaluate(
|
||||||
|
this.state.user.id,
|
||||||
|
normalizeRightsInput(rights),
|
||||||
|
resourcePath,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.allowed && result.requires_login && options.redirectOnFail) {
|
||||||
|
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPermitted(rights, resourcePath, options = {}) {
|
||||||
|
if (!this.state.enabled || !this.state.policy) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.isAuthenticated || !this.state.user?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.state.policy.evaluateSync !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.state.policy.evaluateSync(
|
||||||
|
this.state.user.id,
|
||||||
|
normalizeRightsInput(rights),
|
||||||
|
resourcePath,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.allowed === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountProfile(patch) {
|
||||||
|
if (!this.state.policy || !this.state.user) {
|
||||||
|
throw new Error('No authenticated user to update');
|
||||||
|
}
|
||||||
|
const profile = await this.state.policy.updateAccountProfile(this.state.user.id, patch);
|
||||||
|
const user = await this.state.policy.getUser(this.state.user.id);
|
||||||
|
this.setState({
|
||||||
|
profile,
|
||||||
|
user
|
||||||
|
});
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(passwordInput) {
|
||||||
|
if (!this.state.policy || !this.state.user) {
|
||||||
|
throw new Error('No authenticated user to update');
|
||||||
|
}
|
||||||
|
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; }
|
||||||
|
async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; }
|
||||||
|
async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; }
|
||||||
|
async listResources() { return this.state.policy ? this.state.policy.listResources() : []; }
|
||||||
|
async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const securityService = new SecurityService();
|
||||||
|
|
||||||
|
export function useSecurityState() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => securityService.subscribe(listener),
|
||||||
|
securityService.getSnapshot,
|
||||||
|
securityService.getSnapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { normalizeSecurityConfig, DEFAULT_SECURITY_CONFIG };
|
||||||
+532
@@ -0,0 +1,532 @@
|
|||||||
|
/**
|
||||||
|
* App Component
|
||||||
|
* Main application component that manages all contexts and app state
|
||||||
|
* Provides extensible structure for multiple context managers (Theme, Auth, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui';
|
||||||
|
import { registerServiceWorker, clearPWACache } from '../platform/sw-register.js';
|
||||||
|
import { getProvider } from '../platform/storage.js';
|
||||||
|
import * as apiClient from '../platform/api.js';
|
||||||
|
import * as storageModuleRef from '../platform/storage.js';
|
||||||
|
import * as menuRef from '../platform/menu.js';
|
||||||
|
import * as envModuleRef from '../platform/env.js';
|
||||||
|
import { getConfig, setConfig, CONFIG_KEYS, createLogger, startTrace, isDevelopment } from '../platform/env.js';
|
||||||
|
import { EmptyShell, LandingShell, DashboardShell, AppInfo, Router } from './components/index.js';
|
||||||
|
import { LoginPage } from '../security/pages/LoginPage.jsx';
|
||||||
|
import { getStyleTheme, DEFAULT_STYLE_THEME, normalizeStyleThemeName } from './styles/index.js';
|
||||||
|
import { THEME_MODE_CONFIG_KEY, THEME_NAME_CONFIG_KEY, THEME_MODES, themeManager } from './theme-controller.js';
|
||||||
|
import { securityService, useSecurityState } from '../security/runtime/security-service.js';
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Manager
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Import platform-agnostic theme detection
|
||||||
|
import { getSystemThemeMode, subscribeToSystemThemeMode } from '../platform/compat.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Context
|
||||||
|
* Consolidated context providing access to all app managers (theme, etc.)
|
||||||
|
*/
|
||||||
|
const AppContext = createContext(null);
|
||||||
|
const appLogger = createLogger('App');
|
||||||
|
|
||||||
|
function resolveShellComponent(shellName = 'EmptyShell') {
|
||||||
|
const key = String(shellName ?? 'EmptyShell').trim().toLowerCase();
|
||||||
|
switch (key) {
|
||||||
|
case 'landingshell':
|
||||||
|
return LandingShell;
|
||||||
|
// Same layout as LandingShell: TopBar in shell header with primary/secondary/personal menus.
|
||||||
|
case 'topbarshell':
|
||||||
|
return LandingShell;
|
||||||
|
case 'dashboardshell':
|
||||||
|
return DashboardShell;
|
||||||
|
case 'emptyshell':
|
||||||
|
default:
|
||||||
|
return EmptyShell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, initialProfile = null }) {
|
||||||
|
// App state
|
||||||
|
const [swStatus, setSwStatus] = useState('Checking...');
|
||||||
|
const [storageBackend, setStorageBackend] = useState('localStorage');
|
||||||
|
const [appName, setAppName] = useState(initialProfile?.id ?? '');
|
||||||
|
const [menuItems, setMenuItems] = useState([]);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell'));
|
||||||
|
const [bootResult, setBootResult] = useState(null);
|
||||||
|
const [bootModeOverride, setBootModeOverride] = useState(null);
|
||||||
|
|
||||||
|
// Theme state
|
||||||
|
const [themeMode, setThemeModeState] = useState(THEME_MODES.SYSTEM);
|
||||||
|
const [systemScheme, setSystemScheme] = useState(getSystemThemeMode());
|
||||||
|
const [styleThemeName, setStyleThemeName] = useState(DEFAULT_STYLE_THEME);
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
|
||||||
|
// Initialize theme manager
|
||||||
|
useEffect(() => {
|
||||||
|
themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get style theme configuration
|
||||||
|
const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]);
|
||||||
|
|
||||||
|
// Create Tamagui config from style theme
|
||||||
|
const tamaguiConfig = useMemo(() => {
|
||||||
|
return createTamagui(styleTheme);
|
||||||
|
}, [styleTheme]);
|
||||||
|
|
||||||
|
// Calculate active theme (light/dark variant)
|
||||||
|
const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode;
|
||||||
|
|
||||||
|
// Update theme manager state
|
||||||
|
useEffect(() => {
|
||||||
|
themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName);
|
||||||
|
}, [themeMode, systemScheme, activeTheme, styleThemeName]);
|
||||||
|
|
||||||
|
// Load theme preferences from storage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadThemePreferences() {
|
||||||
|
try {
|
||||||
|
// Load theme mode (light/dark/system)
|
||||||
|
const savedMode = await getConfig(THEME_MODE_CONFIG_KEY, null);
|
||||||
|
if (savedMode && Object.values(THEME_MODES).includes(savedMode)) {
|
||||||
|
setThemeModeState(savedMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load style theme (material/minimal/colorful)
|
||||||
|
const savedStyleTheme = await getConfig(THEME_NAME_CONFIG_KEY, null);
|
||||||
|
if (savedStyleTheme) {
|
||||||
|
setStyleThemeName(normalizeStyleThemeName(savedStyleTheme));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[App] Failed to load theme preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemePreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for system theme changes using platform-agnostic compat layer
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeToSystemThemeMode((scheme) => {
|
||||||
|
setSystemScheme(scheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save theme mode preference to storage when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
async function saveThemePreference() {
|
||||||
|
try {
|
||||||
|
await setConfig(THEME_MODE_CONFIG_KEY, themeMode);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[App] Failed to save theme preference:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeMode !== THEME_MODES.SYSTEM || systemScheme) {
|
||||||
|
saveThemePreference();
|
||||||
|
}
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
// Save style theme preference to storage when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
async function saveStyleThemePreference() {
|
||||||
|
try {
|
||||||
|
await setConfig(THEME_NAME_CONFIG_KEY, styleThemeName);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[App] Failed to save style theme preference:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStyleThemePreference();
|
||||||
|
}, [styleThemeName]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform services for module injection
|
||||||
|
* Returns services with renamed keys and includes env
|
||||||
|
*/
|
||||||
|
async function getPlatformServices() {
|
||||||
|
// Get UI Router (for route declarations - Router will collect on mount)
|
||||||
|
let ui_router = null;
|
||||||
|
try {
|
||||||
|
if (Router && Router.publishRoutes) {
|
||||||
|
ui_router = {
|
||||||
|
publishRoutes: Router.publishRoutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[App] Failed to import Router module:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API router from service worker (if available)
|
||||||
|
let api_router = null;
|
||||||
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
|
||||||
|
// API Router is in service worker, modules will register via message passing
|
||||||
|
// or we expose a registration function
|
||||||
|
api_router = {
|
||||||
|
register: (path, handler) => {
|
||||||
|
// Register endpoint in SW router
|
||||||
|
// Note: For now, we'll need to handle this differently since
|
||||||
|
// functions can't be serialized. Modules should register routes
|
||||||
|
// in their routes.js files which get loaded into the SW.
|
||||||
|
console.log(`[API Router] Register endpoint: ${path}`);
|
||||||
|
// TODO: Implement proper SW router registration
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
api_client: apiClient.api, // Renamed from api
|
||||||
|
storage: storageModuleRef,
|
||||||
|
api_router,
|
||||||
|
ui_router,
|
||||||
|
menu: menuRef,
|
||||||
|
env: envModuleRef // New service
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeApp = useCallback(async () => {
|
||||||
|
const initTrace = startTrace('App', 'initializeApp');
|
||||||
|
appLogger.log('Initializing PWA...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init.
|
||||||
|
setBootModeOverride(null);
|
||||||
|
|
||||||
|
// Get platform services
|
||||||
|
const servicesTrace = startTrace('App', 'getPlatformServices');
|
||||||
|
const services = await getPlatformServices();
|
||||||
|
servicesTrace.end();
|
||||||
|
const initialSecurityConfig = initialProfile?.security || {};
|
||||||
|
const securityTrace = startTrace('Security', 'init', { provider: initialSecurityConfig.provider ?? 'basic' });
|
||||||
|
await securityService.init(initialSecurityConfig);
|
||||||
|
securityTrace.end({ enabled: initialSecurityConfig.enabled === true });
|
||||||
|
securityService.installAPIClient(services.api_client);
|
||||||
|
|
||||||
|
// Call onInit callback if provided (handles profile, env, modules, SW)
|
||||||
|
let selectedProfile = null;
|
||||||
|
if (onInit) {
|
||||||
|
const onInitTrace = startTrace('App', 'onInit');
|
||||||
|
selectedProfile = await onInit(services, { initialProfile });
|
||||||
|
onInitTrace.end({ profile: selectedProfile?.id ?? initialProfile?.id ?? 'default' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSecurityConfig = selectedProfile?.security || initialSecurityConfig;
|
||||||
|
if (JSON.stringify(selectedSecurityConfig) !== JSON.stringify(initialSecurityConfig)) {
|
||||||
|
const selectedSecurityTrace = startTrace('Security', 're-init from selected profile', { provider: selectedSecurityConfig.provider ?? 'basic' });
|
||||||
|
await securityService.init(selectedSecurityConfig);
|
||||||
|
selectedSecurityTrace.end({ enabled: selectedSecurityConfig.enabled === true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProfile?.__boot) {
|
||||||
|
setBootResult(selectedProfile.__boot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.menu?.restoreMenuPreferences) {
|
||||||
|
await services.menu.restoreMenuPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use services to set up app state
|
||||||
|
const name = await services.env.getConfig(CONFIG_KEYS.APP_NAME, 'default');
|
||||||
|
setAppName(name);
|
||||||
|
|
||||||
|
const backend = await services.env.getConfig(CONFIG_KEYS.STORAGE_BACKEND, 'localStorage');
|
||||||
|
setStorageBackend(backend);
|
||||||
|
|
||||||
|
// Load UI shell from config
|
||||||
|
const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell');
|
||||||
|
const Shell = resolveShellComponent(shellName);
|
||||||
|
setShellComponent(() => Shell);
|
||||||
|
appLogger.log(`Using shell: ${shellName}`);
|
||||||
|
|
||||||
|
// Get menu items from primary menu
|
||||||
|
if (services.menu) {
|
||||||
|
// Query primary menu (returns root + all nested items)
|
||||||
|
const allPrimaryItems = services.menu.queryMenuItems('/primary');
|
||||||
|
appLogger.debug('Primary menu items found:', allPrimaryItems);
|
||||||
|
|
||||||
|
// Filter out the root "Primary" item, keep only actual menu items
|
||||||
|
const items = allPrimaryItems.filter(item => item.path !== '/primary');
|
||||||
|
appLogger.debug('Filtered menu items (excluding root):', items);
|
||||||
|
setMenuItems(items);
|
||||||
|
} else {
|
||||||
|
appLogger.warn('Menu service not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update service worker status if available
|
||||||
|
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
if (devHost) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
setSwStatus(registration ? 'Active' : 'Development Disabled');
|
||||||
|
if (registration) {
|
||||||
|
registration.update();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
setSwStatus('Active');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSwStatus('Not Supported');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSwStatus('Not Supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialized(true);
|
||||||
|
initTrace.end({
|
||||||
|
shell: shellName,
|
||||||
|
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
initTrace.fail(error);
|
||||||
|
appLogger.error('Failed to initialize app:', error);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [initialProfile, onInit]);
|
||||||
|
|
||||||
|
// Initialize app
|
||||||
|
useEffect(() => {
|
||||||
|
initializeApp();
|
||||||
|
}, [initializeApp]);
|
||||||
|
|
||||||
|
// Consolidated app context value
|
||||||
|
const appContextValue = {
|
||||||
|
theme: {
|
||||||
|
themeMode,
|
||||||
|
activeTheme,
|
||||||
|
systemScheme,
|
||||||
|
styleThemeName,
|
||||||
|
styleTheme,
|
||||||
|
setThemeMode: async (mode) => {
|
||||||
|
if (!Object.values(THEME_MODES).includes(mode)) {
|
||||||
|
console.warn('[App] Invalid theme mode:', mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setThemeModeState(mode);
|
||||||
|
},
|
||||||
|
setStyleTheme: async (themeName) => {
|
||||||
|
setStyleThemeName(normalizeStyleThemeName(themeName));
|
||||||
|
},
|
||||||
|
toggleTheme: () => {
|
||||||
|
const nextMode =
|
||||||
|
themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK :
|
||||||
|
themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM :
|
||||||
|
THEME_MODES.LIGHT;
|
||||||
|
setThemeModeState(nextMode);
|
||||||
|
},
|
||||||
|
THEME_MODES
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
...securityState,
|
||||||
|
login: (credentials) => securityService.login(credentials),
|
||||||
|
logout: (options) => securityService.logout(options),
|
||||||
|
refreshSession: () => securityService.refreshSession(),
|
||||||
|
injectAuthHeaders: (headers) => securityService.injectAuthHeaders(headers),
|
||||||
|
userRequired: (options) => securityService.userRequired(options),
|
||||||
|
userPermitted: (rights, resourcePath, options) => securityService.userPermitted(rights, resourcePath, options),
|
||||||
|
isPermitted: (rights, resourcePath, options) => securityService.isPermitted(rights, resourcePath, options),
|
||||||
|
registerResource: (resource) => securityService.registerResource(resource),
|
||||||
|
updateAccountProfile: (patch) => securityService.updateAccountProfile(patch),
|
||||||
|
changePassword: (passwordInput) => securityService.changePassword(passwordInput),
|
||||||
|
listUsers: () => securityService.listUsers(),
|
||||||
|
listRoles: () => securityService.listRoles(),
|
||||||
|
listRealms: () => securityService.listRealms(),
|
||||||
|
listResources: () => securityService.listResources(),
|
||||||
|
listPermits: () => securityService.listPermits()
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
locale: envModuleRef.getLocaleSync(),
|
||||||
|
getLocale: (altValue) => envModuleRef.getLocale(altValue),
|
||||||
|
setLocale: (locale) => envModuleRef.setLocale(locale)
|
||||||
|
}
|
||||||
|
// Future managers can be added here: auth, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
|
||||||
|
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
|
||||||
|
const shouldHoldDuringInit = !initialized && !showInitialBootSplash;
|
||||||
|
const shouldRenderLoginGate = initialized && !shouldRenderBootScreen && securityState.enabled && securityState.requireLogin && !securityState.isAuthenticated;
|
||||||
|
let bootScreenContent = null;
|
||||||
|
let appContent = null;
|
||||||
|
|
||||||
|
if (shouldRenderBootScreen) {
|
||||||
|
if (typeof renderBootScreen === 'function') {
|
||||||
|
bootScreenContent = renderBootScreen({
|
||||||
|
mode: initialized ? effectiveBootMode : 'splash',
|
||||||
|
bootResult,
|
||||||
|
continueToRuntime: () => setBootModeOverride('runtime'),
|
||||||
|
completeSetup: async () => {
|
||||||
|
if (bootResult?.runtimeStorage?.markSetupComplete) {
|
||||||
|
await bootResult.runtimeStorage.markSetupComplete();
|
||||||
|
}
|
||||||
|
await initializeApp();
|
||||||
|
},
|
||||||
|
completeModuleSetup: async (moduleId) => {
|
||||||
|
if (bootResult?.runtimeStorage?.markModuleSetupComplete) {
|
||||||
|
await bootResult.runtimeStorage.markModuleSetupComplete(moduleId);
|
||||||
|
}
|
||||||
|
await initializeApp();
|
||||||
|
},
|
||||||
|
retryBoot: async () => {
|
||||||
|
await initializeApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRenderLoginGate) {
|
||||||
|
appContent = <LoginPage />;
|
||||||
|
} else if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
|
||||||
|
appContent = (
|
||||||
|
<Router initialPath="/home">
|
||||||
|
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
|
||||||
|
<Router.Endpoint path="/home" component={HomePage} />
|
||||||
|
|
||||||
|
<Router.Group path="/dashboard">
|
||||||
|
<Router.Endpoint path="/" component={DashboardPage} />
|
||||||
|
<Router.Endpoint path="/analytics" component={AnalyticsPage} />
|
||||||
|
<Router.Endpoint path="/reports" component={ReportsPage} />
|
||||||
|
</Router.Group>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<ShellComponent>
|
||||||
|
<Router.SelectedComponent placement="mainContent" />
|
||||||
|
</ShellComponent>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
} else if (shouldHoldDuringInit) {
|
||||||
|
appContent = (
|
||||||
|
<YStack width="100%" minHeight="100vh" backgroundColor="$background">
|
||||||
|
<ShellComponent>
|
||||||
|
<YStack flex={1} minHeight="100vh" backgroundColor="$background" />
|
||||||
|
</ShellComponent>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider value={appContextValue}>
|
||||||
|
<TamaguiProvider config={tamaguiConfig} defaultTheme={activeTheme}>
|
||||||
|
<Theme name={activeTheme}>
|
||||||
|
{shouldRenderBootScreen ? (
|
||||||
|
bootScreenContent
|
||||||
|
) : appContent}
|
||||||
|
</Theme>
|
||||||
|
</TamaguiProvider>
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useApp Hook
|
||||||
|
* Access all app managers (theme, etc.) within React components
|
||||||
|
*
|
||||||
|
* @returns {{
|
||||||
|
* theme: {
|
||||||
|
* themeMode: 'light' | 'dark' | 'system',
|
||||||
|
* activeTheme: 'light' | 'dark',
|
||||||
|
* systemScheme: 'light' | 'dark',
|
||||||
|
* styleThemeName: string,
|
||||||
|
* styleTheme: object,
|
||||||
|
* setThemeMode: (mode: string) => Promise<void>,
|
||||||
|
* setStyleTheme: (themeName: string) => Promise<void>,
|
||||||
|
* toggleTheme: () => void,
|
||||||
|
* THEME_MODES: object
|
||||||
|
* },
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function useApp() {
|
||||||
|
const context = useContext(AppContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useApp must be used within App component');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTheme Hook (Backward Compatibility)
|
||||||
|
* Access theme state and controls within React components
|
||||||
|
* @deprecated Use useApp().theme instead
|
||||||
|
*
|
||||||
|
* @returns {{
|
||||||
|
* themeMode: 'light' | 'dark' | 'system',
|
||||||
|
* activeTheme: 'light' | 'dark',
|
||||||
|
* systemScheme: 'light' | 'dark',
|
||||||
|
* styleThemeName: string,
|
||||||
|
* styleTheme: object,
|
||||||
|
* setThemeMode: (mode: string) => Promise<void>,
|
||||||
|
* setStyleTheme: (themeName: string) => Promise<void>,
|
||||||
|
* toggleTheme: () => void,
|
||||||
|
* THEME_MODES: object
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function useTheme() {
|
||||||
|
const app = useApp();
|
||||||
|
return app.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App Static Properties (Global Access)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Attach static properties to App
|
||||||
|
App.ThemeManager = themeManager;
|
||||||
|
App.useApp = useApp;
|
||||||
|
App.useTheme = useTheme; // Backward compatibility
|
||||||
|
App.THEME_MODES = THEME_MODES;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export and Initialize
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Export App as both default and named for flexibility
|
||||||
|
export default App;
|
||||||
|
export { App };
|
||||||
|
export { useApp, useTheme, THEME_MODES, themeManager as ThemeManager };
|
||||||
|
|
||||||
|
// Expose PWA utilities globally for console access
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.clearPWACache = clearPWACache;
|
||||||
|
window.__PWA_UTILS__ = {
|
||||||
|
clearPWACache,
|
||||||
|
clearAllCaches: async () => {
|
||||||
|
const { clearAllCaches } = await import('../platform/sw-register.js');
|
||||||
|
return clearAllCaches();
|
||||||
|
},
|
||||||
|
unregisterAllServiceWorkers: async () => {
|
||||||
|
const { unregisterAllServiceWorkers } = await import('../platform/sw-register.js');
|
||||||
|
return unregisterAllServiceWorkers();
|
||||||
|
},
|
||||||
|
clearAllStorage: async () => {
|
||||||
|
const { clearAllStorage } = await import('../platform/sw-register.js');
|
||||||
|
return clearAllStorage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('💡 PWA Utilities available:');
|
||||||
|
console.log(' - clearPWACache() - Clear everything (caches, SW, storage)');
|
||||||
|
console.log(' - __PWA_UTILS__.clearAllCaches() - Clear caches only');
|
||||||
|
console.log(' - __PWA_UTILS__.unregisterAllServiceWorkers() - Unregister SW only');
|
||||||
|
console.log(' - __PWA_UTILS__.clearAllStorage() - Clear storage only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: App initialization is handled by the consuming project (e.g., app-react.jsx)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* AppInfo Component
|
||||||
|
* Displays application information and status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { YStack, XStack, Text, Heading } from 'tamagui';
|
||||||
|
import { getConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppInfo Component
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.appName - Application name
|
||||||
|
* @param {string} props.swStatus - Service worker status
|
||||||
|
* @param {string} props.storageBackend - Storage backend name
|
||||||
|
* @param {Array} props.menuItems - Menu items to display
|
||||||
|
* @param {boolean} props.initialized - Whether app is initialized
|
||||||
|
*/
|
||||||
|
export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], initialized = false }) {
|
||||||
|
const [displayName, setDisplayName] = useState('PWA Template');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, 'PWA Template');
|
||||||
|
setDisplayName(name);
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack padding="$4" gap="$4" maxWidth={800} margin="0 auto">
|
||||||
|
<Heading size="$8">
|
||||||
|
{displayName}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<XStack gap="$2">
|
||||||
|
<Text>App:</Text>
|
||||||
|
<Text fontWeight="bold">{appName || 'Loading...'}</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack gap="$2">
|
||||||
|
<Text>Service Worker:</Text>
|
||||||
|
<Text fontWeight="bold">{swStatus}</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack gap="$2">
|
||||||
|
<Text>Storage Backend:</Text>
|
||||||
|
<Text fontWeight="bold">{storageBackend}</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{initialized && menuItems.length > 0 && (
|
||||||
|
<YStack marginTop="$4" gap="$2">
|
||||||
|
<Text fontWeight="bold">Menu Items:</Text>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<XStack key={item.id} gap="$2">
|
||||||
|
<Text>• {item.label}</Text>
|
||||||
|
{item.icon && <Text>({item.icon})</Text>}
|
||||||
|
</XStack>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<YStack marginTop="$4">
|
||||||
|
<Text>Ready for development!</Text>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppInfo;
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* DashboardShell - Dashboard shell
|
||||||
|
* Derived from EmptyShell, designed for dashboard/application pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import EmptyShell from './EmptyShell.jsx';
|
||||||
|
import { SideBar } from './SideBar.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardShell Component
|
||||||
|
* Shell component for dashboard pages with SideBar in left side
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render inside shell
|
||||||
|
*/
|
||||||
|
export function DashboardShell(props) {
|
||||||
|
return (
|
||||||
|
<EmptyShell {...props} initialLeftWidth={250}>
|
||||||
|
<SideBar placement="leftSide">
|
||||||
|
{/* Menu items will be placed here via placement */}
|
||||||
|
</SideBar>
|
||||||
|
{props.children}
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardShell;
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||||
|
|
||||||
|
export function DetView({
|
||||||
|
open = false,
|
||||||
|
onClose = null,
|
||||||
|
title = 'Detail View',
|
||||||
|
toolbar = [],
|
||||||
|
footerActions = [],
|
||||||
|
width = 420,
|
||||||
|
children = null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidePanelShell
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
toolbar={toolbar}
|
||||||
|
footerActions={footerActions}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidePanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetView;
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { normalizeColumnsArray } from './grid/utils.js';
|
||||||
|
|
||||||
|
const EMPTY_COLUMNS = [];
|
||||||
|
const EMPTY_ACTIONS = [];
|
||||||
|
const DEFAULT_SEARCH_CONFIG = { enabled: true, placeholder: 'Search records...' };
|
||||||
|
const EMPTY_SUMMARY_DEFINITIONS = [];
|
||||||
|
|
||||||
|
function getColumnJustify(align) {
|
||||||
|
if (align === 'right') {
|
||||||
|
return 'flex-end';
|
||||||
|
}
|
||||||
|
if (align === 'center') {
|
||||||
|
return 'center';
|
||||||
|
}
|
||||||
|
return 'flex-start';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSummaryValue(value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, count]) => `${key}: ${count}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCards({ summary }) {
|
||||||
|
if (!summary?.items?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack gap="$3" flexWrap="wrap">
|
||||||
|
{summary.items.map((item) => (
|
||||||
|
<YStack
|
||||||
|
key={item.id || item.label}
|
||||||
|
minWidth={140}
|
||||||
|
padding="$3"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
borderRadius="$4"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
gap="$1"
|
||||||
|
>
|
||||||
|
<Text fontSize="$3" color="$color" opacity={0.7}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
||||||
|
{normalizeSummaryValue(item.value)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderCell({ column, orderBy, order, onSort }) {
|
||||||
|
const sortable = column.sortable !== false;
|
||||||
|
const isActive = orderBy === column.id;
|
||||||
|
const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : '';
|
||||||
|
const justifyContent = getColumnJustify(column.align);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
flex={column.flex || 1}
|
||||||
|
flexBasis={0}
|
||||||
|
minWidth={column.minWidth || 120}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent={justifyContent}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
chromeless
|
||||||
|
disabled={!sortable}
|
||||||
|
onPress={() => sortable && onSort(column.id)}
|
||||||
|
padding={0}
|
||||||
|
justifyContent={justifyContent}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text fontSize="$4" fontWeight="700" color="$color" textAlign={column.align || 'left'} width="100%">
|
||||||
|
{column.label}{arrow ? ` ${arrow}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowCell({ column, record }) {
|
||||||
|
const value = record?.[column.id];
|
||||||
|
const content = column.render ? column.render(value, record, column) : (value ?? '');
|
||||||
|
const justifyContent = getColumnJustify(column.align);
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
flex={column.flex || 1}
|
||||||
|
flexBasis={0}
|
||||||
|
minWidth={column.minWidth || 120}
|
||||||
|
justifyContent={justifyContent}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<XStack width="100%" justifyContent={justifyContent} alignItems="center">
|
||||||
|
{React.isValidElement(content) ? content : (
|
||||||
|
<Text color="$color" numberOfLines={1} textAlign={column.align || 'left'} width="100%">
|
||||||
|
{String(content)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirView({
|
||||||
|
dataModel,
|
||||||
|
columns = EMPTY_COLUMNS,
|
||||||
|
title = 'Directory',
|
||||||
|
toolbarActions = EMPTY_ACTIONS,
|
||||||
|
toolbarItems = undefined,
|
||||||
|
actions = undefined,
|
||||||
|
topLeftContent = null,
|
||||||
|
topRightContent = null,
|
||||||
|
bodyHeaderContent = null,
|
||||||
|
bodyFooterContent = null,
|
||||||
|
searchConfig = DEFAULT_SEARCH_CONFIG,
|
||||||
|
searchValue = undefined,
|
||||||
|
onSearchChange = null,
|
||||||
|
initialSearchValue = '',
|
||||||
|
summaryDefinitions = EMPTY_SUMMARY_DEFINITIONS,
|
||||||
|
pageSize = 10,
|
||||||
|
bodyMaxHeight = 480,
|
||||||
|
onRowClick = null,
|
||||||
|
onRowPress = null,
|
||||||
|
onRefresh = null
|
||||||
|
}) {
|
||||||
|
const [dataVersion, setDataVersion] = useState(0);
|
||||||
|
const [records, setRecords] = useState([]);
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [internalSearchTerm, setInternalSearchTerm] = useState(initialSearchValue);
|
||||||
|
const resolvedColumns = useMemo(() => normalizeColumnsArray(columns), [columns]);
|
||||||
|
const effectiveToolbarItems = actions ?? toolbarItems ?? toolbarActions;
|
||||||
|
const effectiveSearchTerm = searchValue ?? internalSearchTerm;
|
||||||
|
const effectiveRowPress = onRowPress ?? onRowClick;
|
||||||
|
const [orderBy, setOrderBy] = useState(resolvedColumns.find((column) => column.sortable !== false)?.id || '');
|
||||||
|
const [order, setOrder] = useState('asc');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
|
const resolvedSummaryDefinitions = summaryDefinitions || EMPTY_SUMMARY_DEFINITIONS;
|
||||||
|
const RefreshIcon = getIcon('refresh');
|
||||||
|
const FirstPageIcon = getIcon('first-page');
|
||||||
|
const PreviousPageIcon = getIcon('chevron-left');
|
||||||
|
const NextPageIcon = getIcon('chevron-right');
|
||||||
|
const LastPageIcon = getIcon('last-page');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataModel?.subscribe) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataModel.subscribe(() => {
|
||||||
|
setDataVersion((value) => value + 1);
|
||||||
|
});
|
||||||
|
}, [dataModel]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(totalRecords / pageSize)), [totalRecords, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!dataModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const query = {
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
search: effectiveSearchTerm,
|
||||||
|
orderBy,
|
||||||
|
order
|
||||||
|
};
|
||||||
|
|
||||||
|
const [recordResult, summaryResult] = await Promise.all([
|
||||||
|
dataModel.queryRecords(query),
|
||||||
|
dataModel.querySummary(query, resolvedSummaryDefinitions)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setRecords(recordResult.records || []);
|
||||||
|
setTotalRecords(recordResult.totalRecords || 0);
|
||||||
|
setSummary(summaryResult || null);
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(loadError.message || 'Failed to load records');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentPage, dataModel, dataVersion, effectiveSearchTerm, order, orderBy, pageSize, resolvedSummaryDefinitions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextOrderBy = resolvedColumns.find((column) => column.sortable !== false)?.id || '';
|
||||||
|
setOrderBy((current) => {
|
||||||
|
if (current && resolvedColumns.some((column) => column.id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return nextOrderBy;
|
||||||
|
});
|
||||||
|
}, [resolvedColumns]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
setDataVersion((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSearchTerm = (value) => {
|
||||||
|
if (searchValue === undefined) {
|
||||||
|
setInternalSearchTerm(value);
|
||||||
|
}
|
||||||
|
onSearchChange?.(value);
|
||||||
|
searchConfig?.onChange?.(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderToolbarButton = (action, index) => {
|
||||||
|
if (React.isValidElement(action)) {
|
||||||
|
return React.cloneElement(action, { key: action.key || `toolbar-${index}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action?.kind === 'text') {
|
||||||
|
return (
|
||||||
|
<Text key={action?.key || action?.text || index} color="$color" opacity={0.7}>
|
||||||
|
{action?.text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action?.kind === 'node') {
|
||||||
|
return <React.Fragment key={action?.key || index}>{action?.node}</React.Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action?.id || action?.label || index}
|
||||||
|
size="$3"
|
||||||
|
theme={action?.theme}
|
||||||
|
chromeless={action?.chromeless}
|
||||||
|
disabled={loading || action?.disabled}
|
||||||
|
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||||
|
onPress={action?.onPress}
|
||||||
|
>
|
||||||
|
{action?.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack gap="$4" width="100%">
|
||||||
|
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
||||||
|
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
||||||
|
<Text fontSize="$8" fontWeight="800" color="$accentColor">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{topLeftContent}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
|
||||||
|
{searchConfig?.enabled ? (
|
||||||
|
<Input
|
||||||
|
width={260}
|
||||||
|
placeholder={searchConfig.placeholder || 'Search records...'}
|
||||||
|
value={effectiveSearchTerm}
|
||||||
|
onChangeText={updateSearchTerm}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{effectiveToolbarItems.map(renderToolbarButton)}
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
aria-label="Refresh directory"
|
||||||
|
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
||||||
|
onPress={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{topRightContent}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
|
||||||
|
<Text color="#b91c1c">{error}</Text>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SummaryCards summary={summary} />
|
||||||
|
|
||||||
|
{bodyHeaderContent}
|
||||||
|
|
||||||
|
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
|
||||||
|
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
|
||||||
|
{resolvedColumns.map((column) => (
|
||||||
|
<HeaderCell
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
orderBy={orderBy}
|
||||||
|
order={order}
|
||||||
|
onSort={(columnId) => {
|
||||||
|
const nextOrder = orderBy === columnId && order === 'asc' ? 'desc' : 'asc';
|
||||||
|
setOrderBy(columnId);
|
||||||
|
setOrder(nextOrder);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<ScrollView maxHeight={bodyMaxHeight}>
|
||||||
|
<YStack>
|
||||||
|
{loading ? (
|
||||||
|
<XStack justifyContent="center" padding="$6">
|
||||||
|
<Spinner size="large" color="$accentColor" />
|
||||||
|
</XStack>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<YStack padding="$6" alignItems="center">
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
No records found.
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
) : (
|
||||||
|
records.map((record, index) => (
|
||||||
|
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
|
||||||
|
<XStack
|
||||||
|
padding="$3"
|
||||||
|
gap="$3"
|
||||||
|
alignItems="center"
|
||||||
|
hoverStyle={{ backgroundColor: '$accentSurface' }}
|
||||||
|
pressStyle={{ backgroundColor: '$accentSurface' }}
|
||||||
|
cursor={effectiveRowPress ? 'pointer' : undefined}
|
||||||
|
onPress={() => effectiveRowPress?.(record)}
|
||||||
|
>
|
||||||
|
{resolvedColumns.map((column) => (
|
||||||
|
<RowCell key={column.id} column={column} record={record} />
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
{index < records.length - 1 ? <Separator /> : null}
|
||||||
|
</YStack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
</ScrollView>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
{bodyFooterContent}
|
||||||
|
|
||||||
|
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
||||||
|
<Text color="$color" opacity={0.7}>
|
||||||
|
Rows: {totalRecords}
|
||||||
|
</Text>
|
||||||
|
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
aria-label="First page"
|
||||||
|
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
aria-label="Previous page"
|
||||||
|
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
/>
|
||||||
|
<Text color="$color" opacity={0.75}>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
aria-label="Next page"
|
||||||
|
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
|
||||||
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
aria-label="Last page"
|
||||||
|
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DirView;
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* EmptyShell - Base shell component
|
||||||
|
* Base component for all UI shells with sectioned layout
|
||||||
|
* Platform-agnostic using Tamagui components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { XStack, YStack, useMedia } from 'tamagui';
|
||||||
|
import { View } from '@tamagui/core';
|
||||||
|
import { ShellProvider, useShell, ToastViewport } from './Shell.jsx';
|
||||||
|
|
||||||
|
// Section components for children placement
|
||||||
|
const SectionContext = React.createContext(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyShell Component
|
||||||
|
* Provides a responsive three-section layout: LeftSide, MiddleSide, RightSide
|
||||||
|
* MiddleSide contains: Header, MainContent, Footer
|
||||||
|
*
|
||||||
|
* Desktop Layout (gtSm, > 801px):
|
||||||
|
* ┌─────────────────────────────────────┐
|
||||||
|
* │ LeftSide │ MiddleSide │ RightSide │
|
||||||
|
* │ │ ┌────────┐ │ │
|
||||||
|
* │ │ │ Header │ │ │
|
||||||
|
* │ │ ├────────┤ │ │
|
||||||
|
* │ │ │ Main │ │ │
|
||||||
|
* │ │ │Content │ │ │
|
||||||
|
* │ │ ├────────┤ │ │
|
||||||
|
* │ │ │ Footer │ │ │
|
||||||
|
* │ │ └────────┘ │ │
|
||||||
|
* └─────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Mobile Layout (sm and below, ≤ 801px):
|
||||||
|
* ┌─────────────────────┐
|
||||||
|
* │ Header │
|
||||||
|
* ├─────────────────────┤
|
||||||
|
* │ LeftSide (TopBar) │
|
||||||
|
* ├─────────────────────┤
|
||||||
|
* │ MainContent │
|
||||||
|
* ├─────────────────────┤
|
||||||
|
* │ RightSide │
|
||||||
|
* ├─────────────────────┤
|
||||||
|
* │ Footer │
|
||||||
|
* └─────────────────────┘
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render (defaults to MainContent)
|
||||||
|
* @param {number} props.initialLeftWidth - Initial left side width (default: 0, desktop only)
|
||||||
|
* @param {number} props.initialRightWidth - Initial right side width (default: 0, desktop only)
|
||||||
|
* @param {number} props.initialHeaderHeight - Initial header height (default: 0)
|
||||||
|
* @param {number} props.initialFooterHeight - Initial footer height (default: 0)
|
||||||
|
*/
|
||||||
|
function EmptyShellInner({ children }) {
|
||||||
|
const shell = useShell();
|
||||||
|
const media = useMedia();
|
||||||
|
const isMobile = !media.gtSm; // Below 801px (sm breakpoint)
|
||||||
|
|
||||||
|
// Organize children by placement
|
||||||
|
const organizedChildren = useMemo(() => {
|
||||||
|
const sections = {
|
||||||
|
leftSide: [],
|
||||||
|
rightSide: [],
|
||||||
|
header: [],
|
||||||
|
footer: [],
|
||||||
|
mainContent: []
|
||||||
|
};
|
||||||
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
sections.mainContent.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = child.props?.placement || child.props?.shellPlacement || 'mainContent';
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'leftSide':
|
||||||
|
case 'left':
|
||||||
|
sections.leftSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'rightSide':
|
||||||
|
case 'right':
|
||||||
|
sections.rightSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
sections.header.push(child);
|
||||||
|
break;
|
||||||
|
case 'footer':
|
||||||
|
sections.footer.push(child);
|
||||||
|
break;
|
||||||
|
case 'mainContent':
|
||||||
|
case 'main':
|
||||||
|
default:
|
||||||
|
sections.mainContent.push(child);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
// Mobile layout: Vertical stack
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<YStack width="100%" height="100vh" overflow="hidden">
|
||||||
|
{/* Header */}
|
||||||
|
{organizedChildren.header.length > 0 && (
|
||||||
|
<View
|
||||||
|
height={shell.headerHeight > 0 ? shell.headerHeight : 'auto'}
|
||||||
|
width="100%"
|
||||||
|
overflow="visible"
|
||||||
|
style={{
|
||||||
|
transition: 'height 0.3s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 100
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.header}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Side - Becomes top bar on mobile */}
|
||||||
|
{organizedChildren.leftSide.length > 0 && (
|
||||||
|
<View
|
||||||
|
width="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.leftSide}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<View
|
||||||
|
flex={1}
|
||||||
|
width="100%"
|
||||||
|
overflow="auto"
|
||||||
|
style={{ flexShrink: 1 }}
|
||||||
|
>
|
||||||
|
{organizedChildren.mainContent}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right Side */}
|
||||||
|
{organizedChildren.rightSide.length > 0 && (
|
||||||
|
<View
|
||||||
|
width="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.rightSide}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{organizedChildren.footer.length > 0 && (
|
||||||
|
<View
|
||||||
|
height={shell.footerHeight > 0 ? shell.footerHeight : 'auto'}
|
||||||
|
width="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
transition: 'height 0.3s ease',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.footer}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
<ToastViewport />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop layout: Horizontal stack (original layout)
|
||||||
|
// Calculate middle section width (100% minus side widths)
|
||||||
|
const middleWidth = `calc(100% - ${shell.leftSideWidth}px - ${shell.rightSideWidth}px)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<XStack width="100%" height="100vh" overflow="hidden">
|
||||||
|
{/* Left Side */}
|
||||||
|
<View
|
||||||
|
width={shell.leftSideWidth}
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.leftSide}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Middle Section */}
|
||||||
|
<YStack
|
||||||
|
width={middleWidth}
|
||||||
|
height="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
height={shell.headerHeight}
|
||||||
|
width="100%"
|
||||||
|
overflow="visible"
|
||||||
|
style={{
|
||||||
|
transition: 'height 0.3s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 100
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.header}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<View
|
||||||
|
flex={1}
|
||||||
|
width="100%"
|
||||||
|
overflow="auto"
|
||||||
|
style={{ flexShrink: 1 }}
|
||||||
|
>
|
||||||
|
{organizedChildren.mainContent}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View
|
||||||
|
height={shell.footerHeight}
|
||||||
|
width="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
transition: 'height 0.3s ease',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.footer}
|
||||||
|
</View>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
{/* Right Side */}
|
||||||
|
<View
|
||||||
|
width={shell.rightSideWidth}
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{organizedChildren.rightSide}
|
||||||
|
</View>
|
||||||
|
</XStack>
|
||||||
|
<ToastViewport />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyShell - Wrapper with ShellProvider
|
||||||
|
* Pure layout component - handles section placement and dimensions only
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render (defaults to MainContent)
|
||||||
|
* @param {number} props.initialLeftWidth - Initial left side width (default: 0)
|
||||||
|
* @param {number} props.initialRightWidth - Initial right side width (default: 0)
|
||||||
|
* @param {number} props.initialHeaderHeight - Initial header height (default: 0)
|
||||||
|
* @param {number} props.initialFooterHeight - Initial footer height (default: 0)
|
||||||
|
*/
|
||||||
|
export function EmptyShell({
|
||||||
|
children,
|
||||||
|
initialLeftWidth = 0,
|
||||||
|
initialRightWidth = 0,
|
||||||
|
initialHeaderHeight = 0,
|
||||||
|
initialFooterHeight = 0
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ShellProvider
|
||||||
|
initialLeftWidth={initialLeftWidth}
|
||||||
|
initialRightWidth={initialRightWidth}
|
||||||
|
initialHeaderHeight={initialHeaderHeight}
|
||||||
|
initialFooterHeight={initialFooterHeight}
|
||||||
|
>
|
||||||
|
<EmptyShellInner children={children} />
|
||||||
|
</ShellProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyShell;
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons';
|
||||||
|
import { pickFile } from '../../platform/compat.js';
|
||||||
|
|
||||||
|
function FieldShell({ label, helperText, error, children }) {
|
||||||
|
return (
|
||||||
|
<YStack gap="$2" width="100%">
|
||||||
|
{label ? (
|
||||||
|
<Label color="$color" fontWeight="600">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
{error || helperText ? (
|
||||||
|
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3">
|
||||||
|
{error || helperText}
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
|
||||||
|
<Select.Trigger iconAfter={ChevronDown}>
|
||||||
|
<Select.Value placeholder={placeholder || label || 'Select'} />
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Adapt when="sm" platform="touch">
|
||||||
|
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
|
||||||
|
<Sheet.Frame>
|
||||||
|
<Sheet.ScrollView>
|
||||||
|
<Adapt.Contents />
|
||||||
|
</Sheet.ScrollView>
|
||||||
|
</Sheet.Frame>
|
||||||
|
<Sheet.Overlay />
|
||||||
|
</Sheet>
|
||||||
|
</Adapt>
|
||||||
|
|
||||||
|
<Select.Content zIndex={200000}>
|
||||||
|
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
||||||
|
<YStack zIndex={10}>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</YStack>
|
||||||
|
</Select.ScrollUpButton>
|
||||||
|
|
||||||
|
<Select.Viewport minWidth={220}>
|
||||||
|
<Select.Group>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<Select.Item key={option.value} index={index} value={String(option.value)}>
|
||||||
|
<Select.ItemText>{option.label}</Select.ItemText>
|
||||||
|
<Select.ItemIndicator marginLeft="auto">
|
||||||
|
<Check size={16} />
|
||||||
|
</Select.ItemIndicator>
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Viewport>
|
||||||
|
|
||||||
|
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
||||||
|
<YStack zIndex={10}>
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
</YStack>
|
||||||
|
</Select.ScrollDownButton>
|
||||||
|
</Select.Content>
|
||||||
|
</Select>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFilePick(fieldId, onChange, props = {}) {
|
||||||
|
const selection = await pickFile({
|
||||||
|
accept: props.accept || '*',
|
||||||
|
readAs: props.readAs || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selection?.file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(fieldId, selection.file, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({
|
||||||
|
id,
|
||||||
|
type = 'text',
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
options = [],
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
|
||||||
|
|
||||||
|
if (type === 'divider') {
|
||||||
|
return <Separator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'title') {
|
||||||
|
return (
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{helperText ? (
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
{helperText}
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'custom') {
|
||||||
|
return children || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'select') {
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
label={label}
|
||||||
|
value={fieldValue === '' ? '' : String(fieldValue)}
|
||||||
|
options={options}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onValueChange={(nextValue) => onChange?.(id, nextValue)}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'multiselect') {
|
||||||
|
const selectedValues = Array.isArray(fieldValue) ? fieldValue.map(String) : [];
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<XStack flexWrap="wrap" gap="$2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = selectedValues.includes(String(option.value));
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
size="$3"
|
||||||
|
theme={selected ? 'active' : undefined}
|
||||||
|
backgroundColor={selected ? '$accentColor' : '$background'}
|
||||||
|
color={selected ? 'white' : '$color'}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={selected ? '$accentColor' : '$borderColor'}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => {
|
||||||
|
const nextValues = selected
|
||||||
|
? selectedValues.filter((item) => item !== String(option.value))
|
||||||
|
: [...selectedValues, String(option.value)];
|
||||||
|
onChange?.(id, nextValues);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
alignSelf="flex-start"
|
||||||
|
backgroundColor={fieldValue ? '$accentColor' : '$background'}
|
||||||
|
color={fieldValue ? 'white' : '$color'}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={fieldValue ? '$accentColor' : '$borderColor'}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => onChange?.(id, !fieldValue)}
|
||||||
|
>
|
||||||
|
{fieldValue ? 'Enabled' : 'Disabled'}
|
||||||
|
</Button>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'radio') {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = String(fieldValue) === String(option.value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
size="$3"
|
||||||
|
backgroundColor={selected ? '$accentColor' : '$background'}
|
||||||
|
color={selected ? 'white' : '$color'}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={selected ? '$accentColor' : '$borderColor'}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => onChange?.(id, option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'file') {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<XStack alignItems="center" gap="$3" flexWrap="wrap">
|
||||||
|
<Button disabled={disabled} onPress={() => handleFilePick(id, onChange, props)}>
|
||||||
|
{fieldValue?.name ? 'Replace File' : 'Choose File'}
|
||||||
|
</Button>
|
||||||
|
<Text color="$color" opacity={0.75}>
|
||||||
|
{fieldValue?.name || 'No file selected'}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<TextArea
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={String(fieldValue)}
|
||||||
|
onChangeText={(nextValue) => onChange?.(id, nextValue)}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
minHeight={120}
|
||||||
|
/>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<YStack
|
||||||
|
padding="$3"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
borderRadius="$4"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
>
|
||||||
|
<Text>{String(fieldValue || '')}</Text>
|
||||||
|
</YStack>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={String(fieldValue)}
|
||||||
|
onChangeText={(nextValue) => onChange?.(id, type === 'number' ? Number(nextValue) : nextValue)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
type={type === 'datetime' ? 'datetime-local' : type}
|
||||||
|
keyboardType={type === 'number' ? 'numeric' : undefined}
|
||||||
|
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
|
||||||
|
/>
|
||||||
|
</FieldShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormField;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Button, XStack, YStack } from 'tamagui';
|
||||||
|
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||||
|
import { FormField } from './FormField.jsx';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
function defaultExpressionEvaluator(template, form) {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_match, fieldName) => form[fieldName] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAction(action, index, fallbackHandler) {
|
||||||
|
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||||
|
return {
|
||||||
|
id: action?.id || action?.label || `action-${index}`,
|
||||||
|
label: action?.label,
|
||||||
|
icon: action?.icon,
|
||||||
|
disabled: action?.disabled,
|
||||||
|
theme: action?.theme,
|
||||||
|
chromeless: action?.chromeless,
|
||||||
|
onPress: action?.onPress || fallbackHandler,
|
||||||
|
iconComponent: IconComponent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormView({
|
||||||
|
open = false,
|
||||||
|
onClose = null,
|
||||||
|
title = 'Edit Record',
|
||||||
|
toolbar = [],
|
||||||
|
fields = [],
|
||||||
|
values = {},
|
||||||
|
onChange = () => {},
|
||||||
|
onSubmit = () => {},
|
||||||
|
onReset = () => {},
|
||||||
|
buttons = [],
|
||||||
|
loading = false,
|
||||||
|
errors = {},
|
||||||
|
children = null,
|
||||||
|
hideButtons = false,
|
||||||
|
width = 460
|
||||||
|
}) {
|
||||||
|
const processedFields = useMemo(() => {
|
||||||
|
return fields.map((field) => {
|
||||||
|
if (!field.expression) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressionFunction = typeof field.expression === 'string'
|
||||||
|
? (form) => defaultExpressionEvaluator(field.expression, form)
|
||||||
|
: field.expression;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
expressionFunction,
|
||||||
|
readOnly: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
const computedValues = useMemo(() => {
|
||||||
|
return processedFields.reduce((accumulator, field) => {
|
||||||
|
if (field.expressionFunction) {
|
||||||
|
accumulator[field.id] = field.expressionFunction(values);
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}, [processedFields, values]);
|
||||||
|
|
||||||
|
const mergedValues = {
|
||||||
|
...values,
|
||||||
|
...computedValues
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerActions = useMemo(() => {
|
||||||
|
if (hideButtons) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceButtons = buttons.length > 0
|
||||||
|
? buttons
|
||||||
|
: [
|
||||||
|
{ label: 'Reset', chromeless: true, onPress: onReset },
|
||||||
|
{ label: 'Save', theme: 'active', onPress: onSubmit }
|
||||||
|
];
|
||||||
|
|
||||||
|
return sourceButtons.map((button, index) => renderAction(button, index, index === 0 ? onReset : onSubmit));
|
||||||
|
}, [buttons, hideButtons, onReset, onSubmit]);
|
||||||
|
|
||||||
|
const renderField = (field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id || `${field.type || 'field'}-${index}`}
|
||||||
|
{...field}
|
||||||
|
value={mergedValues[field.id]}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors[field.id]}
|
||||||
|
disabled={field.disabled || loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderChildren = () => {
|
||||||
|
if (!children) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement(child) && child.type === FormField) {
|
||||||
|
const fieldId = child.props.id;
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
value: mergedValues[fieldId],
|
||||||
|
onChange,
|
||||||
|
error: errors[fieldId],
|
||||||
|
disabled: child.props.disabled || loading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidePanelShell
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
toolbar={toolbar}
|
||||||
|
footerActions={footerActions}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
<YStack gap="$4">
|
||||||
|
{processedFields.map(renderField)}
|
||||||
|
{children ? (
|
||||||
|
<YStack gap="$4">
|
||||||
|
{renderChildren()}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
{loading ? (
|
||||||
|
<XStack justifyContent="flex-end">
|
||||||
|
<Button disabled>Saving...</Button>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</SidePanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormView;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* GeneralConfig - General settings configuration panel
|
||||||
|
* Simple panel component for settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Text } from 'tamagui';
|
||||||
|
import { SettingsPanel } from './SettingsPanel.jsx';
|
||||||
|
import { useGeneralSettingsViews } from '../runtime/general-settings.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeneralConfig Component
|
||||||
|
* General settings panel
|
||||||
|
*/
|
||||||
|
export function GeneralConfig() {
|
||||||
|
const views = useGeneralSettingsViews();
|
||||||
|
const content = views.map((view) => {
|
||||||
|
const ViewComponent = view.component;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: view.id,
|
||||||
|
label: view.label,
|
||||||
|
icon: view.icon || 'settings',
|
||||||
|
persistenceKey: `settings.general.${view.id}`,
|
||||||
|
content: <ViewComponent />
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPanel
|
||||||
|
icon="settings"
|
||||||
|
title="General Settings"
|
||||||
|
description="General system preferences and shared application controls live here."
|
||||||
|
defaultExpanded
|
||||||
|
persistenceKey="settings.general"
|
||||||
|
content={content}
|
||||||
|
contentStyle="list"
|
||||||
|
>
|
||||||
|
{views.length === 0 ? (
|
||||||
|
<Text fontSize="$4" color="$color" opacity={0.8}>
|
||||||
|
No general settings views are registered yet.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</SettingsPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GeneralConfig;
|
||||||
|
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons
|
||||||
|
* Cross-platform compatible (web + React Native)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Bold,
|
||||||
|
Bookmark,
|
||||||
|
Book,
|
||||||
|
Calendar,
|
||||||
|
Camera,
|
||||||
|
Check,
|
||||||
|
CheckCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Cloud,
|
||||||
|
CloudDownload,
|
||||||
|
CloudUpload,
|
||||||
|
Clipboard,
|
||||||
|
Code,
|
||||||
|
Copy,
|
||||||
|
Crop,
|
||||||
|
DollarSign,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
File,
|
||||||
|
FileText,
|
||||||
|
Filter,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
Globe,
|
||||||
|
HardDrive,
|
||||||
|
Heart,
|
||||||
|
HelpCircle,
|
||||||
|
Home,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
Italic,
|
||||||
|
LayoutDashboard,
|
||||||
|
Library,
|
||||||
|
Link,
|
||||||
|
Lock,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
Mail,
|
||||||
|
Map,
|
||||||
|
MapPin,
|
||||||
|
Menu,
|
||||||
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Minus,
|
||||||
|
MoreHorizontal,
|
||||||
|
MoreVertical,
|
||||||
|
Navigation,
|
||||||
|
Network,
|
||||||
|
Paperclip,
|
||||||
|
Pause,
|
||||||
|
Phone,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Power,
|
||||||
|
Printer,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
Save,
|
||||||
|
Scissors,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Signal,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Square,
|
||||||
|
SquarePen,
|
||||||
|
Star,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
TrendingUp,
|
||||||
|
Underline,
|
||||||
|
Unlock,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
UserCircle,
|
||||||
|
Users,
|
||||||
|
Video,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut
|
||||||
|
} from '@tamagui/lucide-icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon name to Lucide icon component mapping
|
||||||
|
* Maps Material Design icon names to Lucide equivalents
|
||||||
|
*/
|
||||||
|
const iconMap = {
|
||||||
|
// Navigation & UI
|
||||||
|
'home': Home,
|
||||||
|
'settings': Settings,
|
||||||
|
'user': User,
|
||||||
|
'person': User,
|
||||||
|
'account': UserCircle,
|
||||||
|
'menu': Menu,
|
||||||
|
'hamburger': Menu,
|
||||||
|
'search': Search,
|
||||||
|
'bell': Bell,
|
||||||
|
'notifications': Bell,
|
||||||
|
'mail': Mail,
|
||||||
|
'email': Mail,
|
||||||
|
|
||||||
|
// Files & Folders
|
||||||
|
'file': File,
|
||||||
|
'folder': Folder,
|
||||||
|
'folder-open': FolderOpen,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'edit': SquarePen,
|
||||||
|
'delete': Trash2,
|
||||||
|
'save': Save,
|
||||||
|
'close': X,
|
||||||
|
'x': X,
|
||||||
|
'check': Check,
|
||||||
|
'plus': Plus,
|
||||||
|
'minus': Minus,
|
||||||
|
|
||||||
|
// Arrows & Navigation
|
||||||
|
'arrow-right': ArrowRight,
|
||||||
|
'arrow-left': ArrowLeft,
|
||||||
|
'arrow-up': ArrowUp,
|
||||||
|
'arrow-down': ArrowDown,
|
||||||
|
'chevron-right': ChevronRight,
|
||||||
|
'chevron-down': ChevronDown,
|
||||||
|
'chevron-up': ChevronUp,
|
||||||
|
'chevron-left': ChevronLeft,
|
||||||
|
'chevrons-right': ChevronsRight,
|
||||||
|
'chevrons-left': ChevronsLeft,
|
||||||
|
'more-vert': MoreVertical,
|
||||||
|
'more-horiz': MoreHorizontal,
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
'logout': LogOut,
|
||||||
|
'login': LogIn,
|
||||||
|
'lock': Lock,
|
||||||
|
'unlock': Unlock,
|
||||||
|
|
||||||
|
// Dashboard & Analytics
|
||||||
|
'dashboard': LayoutDashboard,
|
||||||
|
'chart': BarChart3,
|
||||||
|
'analytics': TrendingUp,
|
||||||
|
'money': DollarSign,
|
||||||
|
'group': Users,
|
||||||
|
'report': FileText,
|
||||||
|
|
||||||
|
// Status & Feedback
|
||||||
|
'info': Info,
|
||||||
|
'warning': AlertTriangle,
|
||||||
|
'error': AlertCircle,
|
||||||
|
'success': CheckCircle,
|
||||||
|
'help': HelpCircle,
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
'visibility': Eye,
|
||||||
|
'visibility-off': EyeOff,
|
||||||
|
|
||||||
|
// Media
|
||||||
|
'image': Image,
|
||||||
|
'photo': Camera,
|
||||||
|
'video': Video,
|
||||||
|
'play': Play,
|
||||||
|
'pause': Pause,
|
||||||
|
'stop': Square,
|
||||||
|
|
||||||
|
// Communication
|
||||||
|
'chat': MessageCircle,
|
||||||
|
'message': MessageSquare,
|
||||||
|
'comment': MessageSquare,
|
||||||
|
'send': Send,
|
||||||
|
'phone': Phone,
|
||||||
|
|
||||||
|
// Content
|
||||||
|
'copy': Copy,
|
||||||
|
'cut': Scissors,
|
||||||
|
'paste': Clipboard,
|
||||||
|
'link': Link,
|
||||||
|
'attach': Paperclip,
|
||||||
|
|
||||||
|
// UI Controls
|
||||||
|
'filter': Filter,
|
||||||
|
'sort': ArrowUpDown,
|
||||||
|
'refresh': RefreshCw,
|
||||||
|
'download': Download,
|
||||||
|
'upload': Upload,
|
||||||
|
'share': Share2,
|
||||||
|
'language': Globe,
|
||||||
|
'locale': Globe,
|
||||||
|
'tune': SlidersHorizontal,
|
||||||
|
'first-page': SkipBack,
|
||||||
|
'last-page': SkipForward,
|
||||||
|
|
||||||
|
// Favorites & Bookmarks
|
||||||
|
'favorite': Heart,
|
||||||
|
'favorite-border': Heart,
|
||||||
|
'star': Star,
|
||||||
|
'star-border': Star,
|
||||||
|
'bookmark': Bookmark,
|
||||||
|
|
||||||
|
// Time & Calendar
|
||||||
|
'calendar': Calendar,
|
||||||
|
'time': Clock,
|
||||||
|
|
||||||
|
// Location
|
||||||
|
'location': MapPin,
|
||||||
|
'location-on': MapPin,
|
||||||
|
'map': Map,
|
||||||
|
'navigation': Navigation,
|
||||||
|
|
||||||
|
// System
|
||||||
|
'power': Power,
|
||||||
|
'brightness': Sun,
|
||||||
|
'wifi': Wifi,
|
||||||
|
'wifi-off': WifiOff,
|
||||||
|
|
||||||
|
// Media Controls
|
||||||
|
'volume-up': Volume2,
|
||||||
|
'volume-down': Volume1,
|
||||||
|
'volume-off': VolumeX,
|
||||||
|
'mute': VolumeX,
|
||||||
|
|
||||||
|
// Editing
|
||||||
|
'zoom-in': ZoomIn,
|
||||||
|
'zoom-out': ZoomOut,
|
||||||
|
'crop': Crop,
|
||||||
|
'rotate': RotateCw,
|
||||||
|
|
||||||
|
// Formatting
|
||||||
|
'format-bold': Bold,
|
||||||
|
'format-italic': Italic,
|
||||||
|
'format-underline': Underline,
|
||||||
|
'code': Code,
|
||||||
|
|
||||||
|
// Files & Documents
|
||||||
|
'document': FileText,
|
||||||
|
'article': FileText,
|
||||||
|
'book': Book,
|
||||||
|
'library': Library,
|
||||||
|
|
||||||
|
// Cloud & Storage
|
||||||
|
'cloud': Cloud,
|
||||||
|
'cloud-upload': CloudUpload,
|
||||||
|
'cloud-download': CloudDownload,
|
||||||
|
'drive': HardDrive,
|
||||||
|
|
||||||
|
// Network
|
||||||
|
'network': Network,
|
||||||
|
'signal': Signal,
|
||||||
|
|
||||||
|
// Print
|
||||||
|
'print': Printer,
|
||||||
|
|
||||||
|
// Add more mappings as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Lucide icon component by name
|
||||||
|
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings')
|
||||||
|
* @returns {React.Component|null} Lucide icon component or null
|
||||||
|
*/
|
||||||
|
export function getIcon(iconName) {
|
||||||
|
if (!iconName || typeof iconName !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = iconName.toLowerCase().trim();
|
||||||
|
return iconMap[normalizedName] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IconMapper Component
|
||||||
|
* Renders a Lucide icon by name with Tamagui theme support
|
||||||
|
* @param {string} iconName - Name of the icon
|
||||||
|
* @param {number|string} size - Size of the icon (number or Tamagui token like '$4')
|
||||||
|
* @param {string} color - Color of the icon (CSS color or Tamagui token like '$color')
|
||||||
|
* @returns {React.ReactElement|null} Rendered icon or null
|
||||||
|
*/
|
||||||
|
export function IconMapper({ iconName, size = 24, color = 'currentColor', ...props }) {
|
||||||
|
const IconComponent = getIcon(iconName);
|
||||||
|
|
||||||
|
if (!IconComponent) {
|
||||||
|
// Fallback for emojis or unknown icons
|
||||||
|
if (iconName && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||||
|
return <span style={{ fontSize: typeof size === 'string' ? size : `${size}px`, color }}>{iconName}</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert size if it's a number to a reasonable default
|
||||||
|
const iconSize = typeof size === 'string' ? size : size;
|
||||||
|
|
||||||
|
return <IconComponent size={iconSize} color={color} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconMapper;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Paragraph, Text, YStack } from 'tamagui';
|
||||||
|
import { useApp } from '../App.jsx';
|
||||||
|
import { SettingsPanel } from './SettingsPanel.jsx';
|
||||||
|
|
||||||
|
export function IdentityConfig() {
|
||||||
|
const { security } = useApp();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPanel
|
||||||
|
icon="lock"
|
||||||
|
title="Identity"
|
||||||
|
description="Identity provider status and login policy for the current application profile."
|
||||||
|
defaultExpanded={false}
|
||||||
|
persistenceKey="settings.identity"
|
||||||
|
>
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text fontWeight="700" color="$accentColor">
|
||||||
|
{security.enabled ? 'Identity Enabled' : 'Identity Disabled'}
|
||||||
|
</Text>
|
||||||
|
<Paragraph color="$color" opacity={0.78}>
|
||||||
|
Provider: {security.config.provider}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph color="$color" opacity={0.78}>
|
||||||
|
Require login: {security.requireLogin ? 'yes' : 'no'}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph color="$color" opacity={0.68}>
|
||||||
|
Identity is controlled by app profile configuration today. This panel reflects the active security policy and is the anchor point for future runtime controls.
|
||||||
|
</Paragraph>
|
||||||
|
</YStack>
|
||||||
|
</SettingsPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IdentityConfig;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* LandingShell - Landing page shell
|
||||||
|
* Derived from EmptyShell, designed for landing/home pages
|
||||||
|
* Profile `ui_shell` values `LandingShell`, `TopbarShell`, and `TopBarShell` all use this layout
|
||||||
|
* (TopBar in the shell header with primary menu; main route content below).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import EmptyShell from './EmptyShell.jsx';
|
||||||
|
import { TopBar } from './TopBar.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LandingShell Component
|
||||||
|
* Shell component for landing pages with TopBar in header
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render inside shell
|
||||||
|
*/
|
||||||
|
export function LandingShell(props) {
|
||||||
|
return (
|
||||||
|
<EmptyShell {...props} initialHeaderHeight={60}>
|
||||||
|
<TopBar placement="header">
|
||||||
|
{/* Menu items will be placed here via placement */}
|
||||||
|
</TopBar>
|
||||||
|
{props.children}
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LandingShell;
|
||||||
|
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
/**
|
||||||
|
* MenuItemButton Component
|
||||||
|
* Displays a menu item with icon, label, and optional group expansion
|
||||||
|
* Based on Tamagui's ListItem component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Button, XStack, YStack, Text } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import {
|
||||||
|
getBounds,
|
||||||
|
addDocumentEventListener,
|
||||||
|
addWindowEventListener,
|
||||||
|
elementContains,
|
||||||
|
getPopupBounds,
|
||||||
|
getPopupPositionStyle
|
||||||
|
} from '../../platform/compat.js';
|
||||||
|
import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js';
|
||||||
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuItemButton Component
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Object} props.menuItem - MenuItem instance from menu system
|
||||||
|
* @param {string} [props.orientation='horizontal'] - 'horizontal' | 'vertical' (layout direction)
|
||||||
|
* @param {string} [props.expand_mode] - 'popup' | 'below' (how groups expand). Defaults: horizontal='popup', vertical='below'
|
||||||
|
* @param {boolean} [props.selected=false] - Whether item is selected/active
|
||||||
|
* @param {boolean} [props.hovered=false] - Whether item is hovered
|
||||||
|
* @param {number|string} [props.width] - Width in device-independent units (default: 'auto' for horizontal, '100%' for vertical)
|
||||||
|
* @param {string} [props.size='$4'] - Size variant (Tamagui size token)
|
||||||
|
* @param {Function} [props.onClick] - Click handler (overrides menuItem.invoke if provided)
|
||||||
|
* @param {Function} [props.onExpand] - Expand/collapse handler for groups
|
||||||
|
* @param {boolean} [props.expanded] - Whether group is expanded (controlled)
|
||||||
|
* @param {boolean} [props.defaultExpanded=false] - Default expanded state (uncontrolled)
|
||||||
|
* @param {boolean} [props.collapsed=false] - Whether sidebar is collapsed (hides label, shows tooltip)
|
||||||
|
* @param {string} [props.displayStyle] - Override MenuItem style ('both', 'label_only', 'icon_only'). If not provided, uses menuItem.style
|
||||||
|
* @param {string|number} [props.padding] - Internal padding (default: '$2'). Can be a Tamagui token like '$2' or a number
|
||||||
|
* @param {Object} [props.style] - Additional styles
|
||||||
|
* @param {string} [props.testID] - Test identifier
|
||||||
|
*/
|
||||||
|
export function MenuItemButton({
|
||||||
|
menuItem,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
expand_mode,
|
||||||
|
selected = false,
|
||||||
|
hovered = false,
|
||||||
|
width,
|
||||||
|
size = '$4',
|
||||||
|
onClick,
|
||||||
|
onExpand,
|
||||||
|
expanded: controlledExpanded,
|
||||||
|
defaultExpanded = false,
|
||||||
|
collapsed = false,
|
||||||
|
displayStyle,
|
||||||
|
padding = '$2',
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
stateVersion,
|
||||||
|
...otherProps
|
||||||
|
}) {
|
||||||
|
if (!menuItem) {
|
||||||
|
console.warn('[MenuItemButton] menuItem is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that menuItem is a MenuItem instance
|
||||||
|
if (!(menuItem instanceof MenuItem)) {
|
||||||
|
console.error('[MenuItemButton] menuItem must be a MenuItem instance, but received:', {
|
||||||
|
type: typeof menuItem,
|
||||||
|
constructor: menuItem?.constructor?.name,
|
||||||
|
hasIsActionable: typeof menuItem?.isActionable === 'function',
|
||||||
|
hasExecute: typeof menuItem?.execute === 'function',
|
||||||
|
menuItem: menuItem,
|
||||||
|
stack: new Error().stack
|
||||||
|
});
|
||||||
|
// Log where this is being called from to help debug
|
||||||
|
console.error('[MenuItemButton] Call stack:', new Error().stack);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
const security = {
|
||||||
|
...securityState,
|
||||||
|
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!menuItem.isRenderable(security)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine expand_mode: default based on orientation, but allow override
|
||||||
|
// When collapsed, always use popup mode for groups (no room for inline expansion)
|
||||||
|
const effectiveExpandMode = collapsed
|
||||||
|
? 'popup'
|
||||||
|
: (expand_mode || (orientation === 'horizontal' ? 'popup' : 'below'));
|
||||||
|
|
||||||
|
// Handle expanded state (controlled or uncontrolled)
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(() => getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
||||||
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, alignRight: false, alignRightSide: false, alignBottom: false });
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
const buttonRef = useRef(null);
|
||||||
|
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||||
|
const renderableSubItems = menuItem.items
|
||||||
|
? Array.from(menuItem.items.values()).filter((item) => item instanceof MenuItem && item.isRenderable(security))
|
||||||
|
: [];
|
||||||
|
const hasSubitems = renderableSubItems.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlledExpanded !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInternalExpanded(getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
||||||
|
}, [controlledExpanded, defaultExpanded, menuItem.path, stateVersion]);
|
||||||
|
|
||||||
|
// Close popup when clicking outside (popup mode only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveExpandMode === 'popup' && popupOpen) {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (
|
||||||
|
popupRef.current &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!elementContains(popupRef, event.target) &&
|
||||||
|
!elementContains(buttonRef, event.target)
|
||||||
|
) {
|
||||||
|
setPopupOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Use compatibility layer for document event listener
|
||||||
|
return addDocumentEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [popupOpen, effectiveExpandMode]);
|
||||||
|
|
||||||
|
// Determine width
|
||||||
|
const itemWidth = width !== undefined
|
||||||
|
? width
|
||||||
|
: (orientation === 'horizontal' ? 'auto' : '100%');
|
||||||
|
|
||||||
|
// Calculate smart popup position using compatibility layer
|
||||||
|
const calculatePopupPosition = () => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
|
||||||
|
// Use compatibility layer for popup position calculation
|
||||||
|
const bounds = getPopupBounds(buttonRef, 200, 300, 8);
|
||||||
|
if (bounds) {
|
||||||
|
setPopupPosition(bounds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle toggle expand/collapse (for chevron and fallback)
|
||||||
|
const handleToggleExpand = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (effectiveExpandMode === 'popup') {
|
||||||
|
// Calculate popup position before opening
|
||||||
|
if (!popupOpen) {
|
||||||
|
calculatePopupPosition();
|
||||||
|
}
|
||||||
|
// Toggle popup
|
||||||
|
setPopupOpen(!popupOpen);
|
||||||
|
} else {
|
||||||
|
// Toggle inline expansion (below mode)
|
||||||
|
const newExpanded = !isExpanded;
|
||||||
|
if (controlledExpanded === undefined) {
|
||||||
|
setInternalExpanded(newExpanded);
|
||||||
|
}
|
||||||
|
if (menuItem.path) {
|
||||||
|
setMenuItemExpandedPreference(menuItem.path, newExpanded);
|
||||||
|
}
|
||||||
|
if (onExpand) {
|
||||||
|
onExpand(newExpanded, menuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle main item click (icon + label)
|
||||||
|
const handleMainClick = (e) => {
|
||||||
|
// If item is actionable (has invoke or invoke_type+invoke_target), execute it
|
||||||
|
if (menuItem.isActionable()) {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e, menuItem);
|
||||||
|
} else {
|
||||||
|
// Pass event source (button element) and event to execute
|
||||||
|
menuItem.execute(buttonRef.current, e);
|
||||||
|
}
|
||||||
|
// Close popup if open
|
||||||
|
if (popupOpen) {
|
||||||
|
setPopupOpen(false);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
} else if (hasSubitems) {
|
||||||
|
// If not actionable but has subitems, fallback to toggle expand/collapse
|
||||||
|
handleToggleExpand(e);
|
||||||
|
} else if (onClick) {
|
||||||
|
// If no invoke and no subitems, just call onClick if provided
|
||||||
|
onClick(e, menuItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalculate position when popup opens or window resizes
|
||||||
|
useEffect(() => {
|
||||||
|
if (popupOpen && effectiveExpandMode === 'popup' && buttonRef.current) {
|
||||||
|
// Initial calculation
|
||||||
|
calculatePopupPosition();
|
||||||
|
|
||||||
|
// Recalculate after popup renders to use actual dimensions
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (popupRef.current && buttonRef.current) {
|
||||||
|
// Use compatibility layer for recalculation with actual dimensions
|
||||||
|
const popupBounds = getBounds(popupRef);
|
||||||
|
const popupWidth = popupBounds?.width || 200;
|
||||||
|
const popupHeight = popupBounds?.height || 300;
|
||||||
|
const bounds = getPopupBounds(buttonRef, popupWidth, popupHeight, 8);
|
||||||
|
if (bounds) {
|
||||||
|
setPopupPosition(bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
calculatePopupPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use compatibility layer for window event listener
|
||||||
|
const removeResizeListener = addWindowEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
removeResizeListener();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [popupOpen, effectiveExpandMode]);
|
||||||
|
|
||||||
|
// Determine icon component
|
||||||
|
// Icon can be: string (icon name), React component, or null
|
||||||
|
let IconComponent = null;
|
||||||
|
if (menuItem.icon) {
|
||||||
|
if (typeof menuItem.icon === 'string') {
|
||||||
|
const iconStr = menuItem.icon.trim();
|
||||||
|
// Check if it's an emoji or special character (fallback for emojis)
|
||||||
|
if (iconStr.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconStr)) {
|
||||||
|
IconComponent = iconStr;
|
||||||
|
} else {
|
||||||
|
// Try to get icon from IconMapper
|
||||||
|
const Icon = getIcon(iconStr);
|
||||||
|
if (Icon) {
|
||||||
|
IconComponent = Icon;
|
||||||
|
} else {
|
||||||
|
// Fallback: don't render unknown icon names
|
||||||
|
IconComponent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Assume it's a React component
|
||||||
|
IconComponent = menuItem.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine background color based on state
|
||||||
|
const getBackgroundColor = () => {
|
||||||
|
if (selected) {
|
||||||
|
return '$accentBackground';
|
||||||
|
}
|
||||||
|
if (hovered) {
|
||||||
|
return '$backgroundPress';
|
||||||
|
}
|
||||||
|
return 'transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
if (selected) {
|
||||||
|
return '$accentColor';
|
||||||
|
}
|
||||||
|
if (menuItem.style === 'icon_only') {
|
||||||
|
return '$accentColor';
|
||||||
|
}
|
||||||
|
return '$color';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabelColor = () => {
|
||||||
|
if (selected) {
|
||||||
|
return '$accentColor';
|
||||||
|
}
|
||||||
|
return '$color';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrowColor = () => {
|
||||||
|
if (selected) {
|
||||||
|
return '$accentColor';
|
||||||
|
}
|
||||||
|
return '$colorSecondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine display style (both, label_only, icon_only)
|
||||||
|
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
|
||||||
|
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
|
||||||
|
const showIcon = (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'icon_only') && IconComponent;
|
||||||
|
// Hide label when collapsed (unless it's icon_only style which never shows label)
|
||||||
|
const showLabel = !collapsed && (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'label_only') && menuItem.label;
|
||||||
|
|
||||||
|
// Lucide chevron icons for groups
|
||||||
|
// For popup mode in vertical orientation, show chevron right (>)
|
||||||
|
// For popup mode in horizontal orientation, show chevron down (down arrow)
|
||||||
|
// For below mode, show chevron right when collapsed, chevron down when expanded
|
||||||
|
const arrowIconName = effectiveExpandMode === 'popup'
|
||||||
|
? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down')
|
||||||
|
: (isExpanded ? 'chevron-down' : 'chevron-right');
|
||||||
|
const ArrowIcon = getIcon(arrowIconName);
|
||||||
|
|
||||||
|
// Render based on orientation
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
const horizontalContent = (
|
||||||
|
<XStack
|
||||||
|
position="relative"
|
||||||
|
width={itemWidth}
|
||||||
|
alignItems="center"
|
||||||
|
style={style}
|
||||||
|
testID={testID}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
ref={buttonRef}
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
backgroundColor={getBackgroundColor()}
|
||||||
|
borderWidth={selected ? 1 : 0}
|
||||||
|
borderColor={selected ? '$accentBorder' : 'transparent'}
|
||||||
|
borderRadius="$2"
|
||||||
|
padding={padding}
|
||||||
|
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
||||||
|
>
|
||||||
|
{/* Icon + Label (clickable main area) */}
|
||||||
|
<XStack
|
||||||
|
flex={1}
|
||||||
|
alignItems="center"
|
||||||
|
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||||
|
}}
|
||||||
|
onPress={handleMainClick}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{showIcon && (
|
||||||
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||||
|
{typeof IconComponent === 'string' ? (
|
||||||
|
// Emoji fallback
|
||||||
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
|
) : IconComponent ? (
|
||||||
|
// Material Design icon component
|
||||||
|
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
{showLabel && (
|
||||||
|
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
|
||||||
|
{menuItem.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Group arrow (right side for horizontal) - clickable chevron */}
|
||||||
|
{hasSubitems && ArrowIcon && (
|
||||||
|
<XStack
|
||||||
|
cursor="pointer"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$1"
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: '$backgroundHover'
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||||
|
}}
|
||||||
|
onPress={handleToggleExpand}
|
||||||
|
>
|
||||||
|
<ArrowIcon
|
||||||
|
size={16}
|
||||||
|
color={getArrowColor()}
|
||||||
|
style={{ marginLeft: 4, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Popup menu for popup mode (horizontal) */}
|
||||||
|
{hasSubitems && popupOpen && effectiveExpandMode === 'popup' && orientation === 'horizontal' && buttonRef.current && (
|
||||||
|
<YStack
|
||||||
|
ref={popupRef}
|
||||||
|
position="fixed"
|
||||||
|
backgroundColor="$background"
|
||||||
|
borderRadius="$3"
|
||||||
|
padding={0}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
shadowOffset={{ width: 0, height: 4 }}
|
||||||
|
shadowOpacity={0.2}
|
||||||
|
shadowRadius={8}
|
||||||
|
elevation={8}
|
||||||
|
zIndex={9999}
|
||||||
|
minWidth={200}
|
||||||
|
maxWidth={300}
|
||||||
|
maxHeight="calc(100vh - 100px)"
|
||||||
|
overflow="auto"
|
||||||
|
gap="$1"
|
||||||
|
style={getPopupPositionStyle(popupPosition, buttonRef.current)}
|
||||||
|
>
|
||||||
|
{renderableSubItems.map((subItem) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={subItem.id}
|
||||||
|
menuItem={subItem}
|
||||||
|
orientation="vertical"
|
||||||
|
size={size}
|
||||||
|
hovered={false}
|
||||||
|
selected={false}
|
||||||
|
collapsed={false}
|
||||||
|
onClick={(e, item) => {
|
||||||
|
// First call the menuItem's invoke if it exists
|
||||||
|
if (item && item instanceof MenuItem && item.isActionable()) {
|
||||||
|
item.execute(e.target, e);
|
||||||
|
}
|
||||||
|
// Then call parent's onClick if provided
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e, item);
|
||||||
|
}
|
||||||
|
// Finally close the popup
|
||||||
|
setPopupOpen(false);
|
||||||
|
}}
|
||||||
|
onExpand={onExpand}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
return horizontalContent;
|
||||||
|
} else {
|
||||||
|
// Vertical orientation
|
||||||
|
const verticalContent = (
|
||||||
|
<YStack
|
||||||
|
width={itemWidth}
|
||||||
|
style={{ ...style, position: 'relative' }}
|
||||||
|
testID={testID}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
ref={buttonRef}
|
||||||
|
alignItems="center"
|
||||||
|
width="100%"
|
||||||
|
title={collapsed && menuItem.label ? menuItem.label : undefined}
|
||||||
|
backgroundColor={getBackgroundColor()}
|
||||||
|
borderWidth={selected ? 1 : 0}
|
||||||
|
borderColor={selected ? '$accentBorder' : 'transparent'}
|
||||||
|
borderRadius="$2"
|
||||||
|
padding={padding}
|
||||||
|
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
||||||
|
>
|
||||||
|
{/* Icon + Label (clickable main area) */}
|
||||||
|
<XStack
|
||||||
|
flex={1}
|
||||||
|
alignItems="center"
|
||||||
|
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||||
|
}}
|
||||||
|
onPress={handleMainClick}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{showIcon && (
|
||||||
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||||
|
{typeof IconComponent === 'string' ? (
|
||||||
|
// Emoji fallback
|
||||||
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
|
) : IconComponent ? (
|
||||||
|
// Material Design icon component
|
||||||
|
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
{showLabel && (
|
||||||
|
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
|
||||||
|
{menuItem.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Group arrow (right side for vertical) - clickable chevron */}
|
||||||
|
{hasSubitems && ArrowIcon && (
|
||||||
|
<XStack
|
||||||
|
cursor="pointer"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$1"
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: '$backgroundHover'
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||||
|
}}
|
||||||
|
onPress={handleToggleExpand}
|
||||||
|
>
|
||||||
|
<ArrowIcon
|
||||||
|
size={16}
|
||||||
|
color={getArrowColor()}
|
||||||
|
style={{ marginLeft: 8, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Expanded subitems (below mode only) */}
|
||||||
|
{hasSubitems && isExpanded && effectiveExpandMode === 'below' && (
|
||||||
|
<YStack
|
||||||
|
marginLeft="$4"
|
||||||
|
marginTop="$2"
|
||||||
|
gap="$1"
|
||||||
|
pointerEvents="auto"
|
||||||
|
onPointerEnter={(e) => e.stopPropagation()}
|
||||||
|
onPointerLeave={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderableSubItems.map((subItem) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={subItem.id}
|
||||||
|
menuItem={subItem}
|
||||||
|
orientation="vertical"
|
||||||
|
size={size}
|
||||||
|
selected={false}
|
||||||
|
hovered={false}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onClick={onClick}
|
||||||
|
onExpand={onExpand}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Popup menu for popup mode in vertical orientation */}
|
||||||
|
{hasSubitems && popupOpen && effectiveExpandMode === 'popup' && orientation === 'vertical' && buttonRef.current && (
|
||||||
|
<YStack
|
||||||
|
ref={popupRef}
|
||||||
|
position="fixed"
|
||||||
|
backgroundColor="$background"
|
||||||
|
borderRadius="$3"
|
||||||
|
padding={0}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
shadowOffset={{ width: 0, height: 4 }}
|
||||||
|
shadowOpacity={0.2}
|
||||||
|
shadowRadius={8}
|
||||||
|
elevation={8}
|
||||||
|
zIndex={9999}
|
||||||
|
minWidth={200}
|
||||||
|
maxWidth={300}
|
||||||
|
maxHeight="calc(100vh - 100px)"
|
||||||
|
overflow="auto"
|
||||||
|
gap="$1"
|
||||||
|
style={getPopupPositionStyle(popupPosition, buttonRef.current)}
|
||||||
|
>
|
||||||
|
{renderableSubItems.map((subItem) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={subItem.id}
|
||||||
|
menuItem={subItem}
|
||||||
|
orientation="vertical"
|
||||||
|
size={size}
|
||||||
|
hovered={false}
|
||||||
|
selected={false}
|
||||||
|
onClick={(e, item) => {
|
||||||
|
// First call the menuItem's invoke if it exists
|
||||||
|
if (item && item instanceof MenuItem && item.isActionable()) {
|
||||||
|
item.execute(e.target, e);
|
||||||
|
}
|
||||||
|
// Then call parent's onClick if provided
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e, item);
|
||||||
|
}
|
||||||
|
// Finally close the popup
|
||||||
|
setPopupOpen(false);
|
||||||
|
}}
|
||||||
|
onExpand={onExpand}
|
||||||
|
collapsed={false}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return verticalContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MenuItemButton;
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Page - Base page component with header and body
|
||||||
|
* Provides a consistent layout structure for all pages
|
||||||
|
* Platform-agnostic using Tamagui components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { XStack, YStack, Text } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Component
|
||||||
|
* Base component for all pages with header and body sections
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render in body
|
||||||
|
* @param {string} [props.icon] - Icon name for header (first in headerLeft)
|
||||||
|
* @param {string} [props.title] - Page title (second in headerLeft)
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerLeft] - Components to render in headerLeft (after icon and title)
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerMiddle] - Components to render in headerMiddle
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerRight] - Components to render in headerRight
|
||||||
|
*/
|
||||||
|
export function Page({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
headerLeft = [],
|
||||||
|
headerMiddle = [],
|
||||||
|
headerRight = []
|
||||||
|
}) {
|
||||||
|
// Build headerLeft array: icon, title, then custom components
|
||||||
|
const headerLeftItems = [];
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const IconComponent = getIcon(icon);
|
||||||
|
if (IconComponent) {
|
||||||
|
headerLeftItems.push(
|
||||||
|
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
|
||||||
|
<IconComponent size={24} color="$accentColor" />
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
headerLeftItems.push(
|
||||||
|
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom headerLeft components
|
||||||
|
if (Array.isArray(headerLeft)) {
|
||||||
|
headerLeft.forEach((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
headerLeftItems.push(React.cloneElement(item, { key: `headerLeft-${index}` }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack width="100%" height="100%" flex={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
padding="$4"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomColor="$accentBorder"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$3"
|
||||||
|
minHeight={64}
|
||||||
|
>
|
||||||
|
{/* Header Left */}
|
||||||
|
<XStack alignItems="center" gap="$2" flexShrink={0}>
|
||||||
|
{headerLeftItems}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Header Middle */}
|
||||||
|
<XStack flex={1} alignItems="center" justifyContent="center" gap="$2" minWidth={0}>
|
||||||
|
{Array.isArray(headerMiddle) && headerMiddle.map((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
return React.cloneElement(item, { key: `headerMiddle-${index}` });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Header Right */}
|
||||||
|
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexShrink={0}>
|
||||||
|
{Array.isArray(headerRight) && headerRight.map((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
return React.cloneElement(item, { key: `headerRight-${index}` });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<YStack flex={1} width="100%" padding="$4" overflow="auto">
|
||||||
|
{children}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Panel - Panel component with header and body
|
||||||
|
* Provides a consistent layout structure for nested panels/sections within pages
|
||||||
|
* Platform-agnostic using Tamagui components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { XStack, YStack, Text } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header size mapping function
|
||||||
|
* Maps headerSize (1-4) to icon size, title fontSize, padding, and border radius
|
||||||
|
* Size 1 = largest (Page size), Size 4 = smallest
|
||||||
|
*
|
||||||
|
* @param {number} headerSize - Header size (1-4)
|
||||||
|
* @returns {Object} Object with iconSize, titleFontSize, padding, borderRadius, minHeight
|
||||||
|
*/
|
||||||
|
function getHeaderSizeStyles(headerSize) {
|
||||||
|
const sizeMap = {
|
||||||
|
1: {
|
||||||
|
iconSize: 24,
|
||||||
|
titleFontSize: '$6',
|
||||||
|
padding: '$3',
|
||||||
|
borderRadius: '$4',
|
||||||
|
minHeight: 64
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
iconSize: 20,
|
||||||
|
titleFontSize: '$5',
|
||||||
|
padding: '$2',
|
||||||
|
borderRadius: '$3',
|
||||||
|
minHeight: 48
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
iconSize: 18,
|
||||||
|
titleFontSize: '$4',
|
||||||
|
padding: '$1.5',
|
||||||
|
borderRadius: '$2',
|
||||||
|
minHeight: 44
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
iconSize: 16,
|
||||||
|
titleFontSize: '$3',
|
||||||
|
padding: '$1',
|
||||||
|
borderRadius: '$2',
|
||||||
|
minHeight: 36
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp size to valid range (1-4)
|
||||||
|
const clampedSize = Math.max(1, Math.min(4, Math.round(headerSize)));
|
||||||
|
return sizeMap[clampedSize] || sizeMap[2]; // Default to size 2 if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel Component
|
||||||
|
* Panel component for nested sections within pages with header and body sections
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render in body
|
||||||
|
* @param {string} [props.icon] - Icon name for header (first in headerLeft)
|
||||||
|
* @param {string} [props.title] - Panel title (second in headerLeft)
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerLeft] - Components to render in headerLeft (after icon and title)
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerMiddle] - Components to render in headerMiddle
|
||||||
|
* @param {Array<React.ReactNode>} [props.headerRight] - Components to render in headerRight
|
||||||
|
* @param {boolean} [props.border=true] - Whether to show border around panel
|
||||||
|
* @param {string|number} [props.width=null] - Panel width (null for content-sized)
|
||||||
|
* @param {string|number} [props.height=null] - Panel height (null for content-sized)
|
||||||
|
* @param {number} [props.headerSize=2] - Header size (1-4), where 1 is largest (Page size) and 4 is smallest. Defaults to 2 for tighter panels.
|
||||||
|
* @param {Object} [props.headerFront] - Style object for header foreground elements (icon and title). Can include color, opacity, etc. Spread into icon and title components.
|
||||||
|
* @param {Object} [props.headerBack] - Style object for header background. Can include backgroundColor, opacity, etc. Spread into header XStack.
|
||||||
|
*/
|
||||||
|
export function Panel({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
headerLeft = [],
|
||||||
|
headerMiddle = [],
|
||||||
|
headerRight = [],
|
||||||
|
border = true,
|
||||||
|
width = null,
|
||||||
|
height = null,
|
||||||
|
headerSize = 2,
|
||||||
|
headerFront = null,
|
||||||
|
headerBack = null
|
||||||
|
}) {
|
||||||
|
// Get size-specific styles
|
||||||
|
const sizeStyles = getHeaderSizeStyles(headerSize);
|
||||||
|
|
||||||
|
// Set default headerBack if not provided
|
||||||
|
const effectiveHeaderBack = headerBack || { backgroundColor: '$backgroundHover' };
|
||||||
|
|
||||||
|
// Build headerLeft array: icon, title, then custom components
|
||||||
|
const headerLeftItems = [];
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const IconComponent = getIcon(icon);
|
||||||
|
if (IconComponent) {
|
||||||
|
headerLeftItems.push(
|
||||||
|
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
|
||||||
|
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} />
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
headerLeftItems.push(
|
||||||
|
<Text
|
||||||
|
key="panel-title"
|
||||||
|
fontWeight="600"
|
||||||
|
fontSize={sizeStyles.titleFontSize}
|
||||||
|
color="$color"
|
||||||
|
{...(headerFront || {})}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom headerLeft components
|
||||||
|
if (Array.isArray(headerLeft)) {
|
||||||
|
headerLeft.forEach((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
headerLeftItems.push(React.cloneElement(item, { key: `headerLeft-${index}` }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build style object for container
|
||||||
|
const containerStyle = {};
|
||||||
|
if (width !== null) {
|
||||||
|
containerStyle.width = width;
|
||||||
|
}
|
||||||
|
if (height !== null) {
|
||||||
|
containerStyle.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
|
||||||
|
{...(border ? {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '$borderColor',
|
||||||
|
borderRadius: sizeStyles.borderRadius
|
||||||
|
} : {})}
|
||||||
|
backgroundColor="$background"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
padding={sizeStyles.padding}
|
||||||
|
borderBottomWidth={border ? 1 : 0}
|
||||||
|
borderBottomColor="$borderColor"
|
||||||
|
backgroundColor="$background"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$3"
|
||||||
|
minHeight={sizeStyles.minHeight}
|
||||||
|
{...effectiveHeaderBack}
|
||||||
|
>
|
||||||
|
{/* Header Left */}
|
||||||
|
<XStack alignItems="center" gap="$2" flexShrink={0}>
|
||||||
|
{headerLeftItems}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Header Middle */}
|
||||||
|
<XStack flex={1} alignItems="center" justifyContent="center" gap="$2" minWidth={0}>
|
||||||
|
{Array.isArray(headerMiddle) && headerMiddle.map((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
return React.cloneElement(item, { key: `headerMiddle-${index}` });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Header Right */}
|
||||||
|
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexShrink={0}>
|
||||||
|
{Array.isArray(headerRight) && headerRight.map((item, index) => {
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
return React.cloneElement(item, { key: `headerRight-${index}` });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<YStack width="100%" padding={sizeStyles.padding} overflow="auto" {...(height !== null ? { flex: 1 } : {})}>
|
||||||
|
{children}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Panel;
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { MenuItem } from '../../platform/menu.js';
|
||||||
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||||
|
|
||||||
|
function createSecurityRenderContext(securityState) {
|
||||||
|
return {
|
||||||
|
...securityState,
|
||||||
|
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalMenuItem({
|
||||||
|
personalRoot = null,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
expand_mode = 'popup',
|
||||||
|
width,
|
||||||
|
collapsed = false,
|
||||||
|
padding,
|
||||||
|
displayStyle,
|
||||||
|
testID,
|
||||||
|
stateVersion
|
||||||
|
}) {
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
const security = useMemo(() => createSecurityRenderContext(securityState), [securityState]);
|
||||||
|
|
||||||
|
const resolvedMenuItem = useMemo(() => {
|
||||||
|
if (!securityState.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!securityState.isAuthenticated) {
|
||||||
|
return new MenuItem({
|
||||||
|
id: 'login',
|
||||||
|
label: 'Login',
|
||||||
|
icon: 'login',
|
||||||
|
style: 'both',
|
||||||
|
invoke_type: 'page',
|
||||||
|
invoke_target: securityState.config?.login_route || '/login'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleChildren = new Map();
|
||||||
|
if (personalRoot?.items instanceof Map) {
|
||||||
|
for (const [id, item] of personalRoot.items.entries()) {
|
||||||
|
if (item instanceof MenuItem && item.isRenderable(security)) {
|
||||||
|
visibleChildren.set(id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MenuItem({
|
||||||
|
id: 'personal-smart',
|
||||||
|
label: 'Account',
|
||||||
|
icon: 'account',
|
||||||
|
style: 'both',
|
||||||
|
invoke_type: 'page',
|
||||||
|
invoke_target: '/account',
|
||||||
|
items: visibleChildren
|
||||||
|
});
|
||||||
|
}, [personalRoot, security, securityState.config?.login_route, securityState.enabled, securityState.isAuthenticated]);
|
||||||
|
|
||||||
|
if (!resolvedMenuItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItemButton
|
||||||
|
menuItem={resolvedMenuItem}
|
||||||
|
orientation={orientation}
|
||||||
|
expand_mode={expand_mode}
|
||||||
|
width={width}
|
||||||
|
collapsed={collapsed}
|
||||||
|
padding={padding}
|
||||||
|
displayStyle={displayStyle}
|
||||||
|
testID={testID}
|
||||||
|
stateVersion={stateVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonalMenuItem;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* ProgressBar — determinate or indeterminate progress with optional chrome.
|
||||||
|
* Uses Tamagui stacks/tokens only (no raw CSS gradients) for consistent theming and RN compatibility.
|
||||||
|
*
|
||||||
|
* Slots (all optional): `header`, `footer` (e.g. wrap your own ScrollView for history), `leftSlot` / `rightSlot`
|
||||||
|
* for inline actions, `label` centered above the track in the main column.
|
||||||
|
*
|
||||||
|
* @typedef {'determinate' | 'indeterminate'} ProgressMode
|
||||||
|
* @typedef {'inline' | 'fixedTop'} ProgressVariant
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { scheduleInterval, scheduleTimeout } from '../../platform/compat.js';
|
||||||
|
|
||||||
|
function clamp01(n) {
|
||||||
|
const x = Number(n);
|
||||||
|
if (!Number.isFinite(x)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node) {
|
||||||
|
if (node === null || node === undefined || node === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSlot(node) {
|
||||||
|
return node !== null && node !== undefined && node !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({
|
||||||
|
mode = 'indeterminate',
|
||||||
|
value = 0,
|
||||||
|
active = false,
|
||||||
|
/** Delay before unmount after `active` becomes false (smooth fade-out). Inline only; `fixedTop` hides immediately to avoid layout jump (label removed) during opacity animation. */
|
||||||
|
retainAfterInactiveMs = 420,
|
||||||
|
label,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
leftSlot,
|
||||||
|
rightSlot,
|
||||||
|
variant = 'inline',
|
||||||
|
trackHeight = 4,
|
||||||
|
zIndex = 20000
|
||||||
|
}) {
|
||||||
|
const [offset, setOffset] = useState(-45);
|
||||||
|
const [mounted, setMounted] = useState(active);
|
||||||
|
/** `fixedTop` follows `active` only — no delayed unmount, so the track cannot re-anchor to the top when the parent clears `label` on the same tick as `active`. */
|
||||||
|
const useRetainAfterInactive = variant !== 'fixedTop';
|
||||||
|
const visible = useRetainAfterInactive ? mounted : active;
|
||||||
|
|
||||||
|
const determinateWidth = useMemo(() => `${clamp01(value)}%`, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
setMounted(true);
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || mode !== 'indeterminate') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduleInterval(() => {
|
||||||
|
setOffset((v) => (v >= 100 ? -45 : v + 4));
|
||||||
|
}, 80);
|
||||||
|
}, [visible, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useRetainAfterInactive || active || !mounted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduleTimeout(() => {
|
||||||
|
setMounted(false);
|
||||||
|
}, retainAfterInactiveMs);
|
||||||
|
}, [active, mounted, retainAfterInactiveMs, useRetainAfterInactive]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = (
|
||||||
|
<YStack
|
||||||
|
position="relative"
|
||||||
|
width="100%"
|
||||||
|
height={trackHeight}
|
||||||
|
borderRadius="$1"
|
||||||
|
backgroundColor="$borderColor"
|
||||||
|
opacity={0.55}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{mode === 'determinate' ? (
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
height="100%"
|
||||||
|
width={determinateWidth}
|
||||||
|
borderRadius="$1"
|
||||||
|
backgroundColor="$accentColor"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
height="100%"
|
||||||
|
width="42%"
|
||||||
|
borderRadius="$1"
|
||||||
|
backgroundColor="$accentColor"
|
||||||
|
left={`${offset}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const centerBlock = (
|
||||||
|
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
|
||||||
|
{label !== null && label !== undefined && label !== '' ? (
|
||||||
|
typeof label === 'string' ? (
|
||||||
|
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{track}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<YStack
|
||||||
|
width="100%"
|
||||||
|
pointerEvents="box-none"
|
||||||
|
opacity={useRetainAfterInactive ? (active ? 1 : 0) : 1}
|
||||||
|
animation={useRetainAfterInactive ? 'medium' : undefined}
|
||||||
|
gap="$2"
|
||||||
|
>
|
||||||
|
{hasSlot(header) ? <YStack width="100%">{renderNode(header)}</YStack> : null}
|
||||||
|
|
||||||
|
<XStack width="100%" alignItems="center" justifyContent="center" gap="$2" pointerEvents="box-none">
|
||||||
|
{hasSlot(leftSlot) ? (
|
||||||
|
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
|
||||||
|
{renderNode(leftSlot)}
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
{centerBlock}
|
||||||
|
{hasSlot(rightSlot) ? (
|
||||||
|
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
|
||||||
|
{renderNode(rightSlot)}
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{hasSlot(footer) ? (
|
||||||
|
<YStack width="100%" flexShrink={0}>
|
||||||
|
{renderNode(footer)}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'fixedTop') {
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
position="fixed"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
zIndex={zIndex}
|
||||||
|
paddingHorizontal="$1"
|
||||||
|
paddingTop="$1"
|
||||||
|
pointerEvents="box-none"
|
||||||
|
backgroundColor="transparent"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <YStack width="100%" pointerEvents="box-none">{body}</YStack>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Shell components
|
||||||
|
|
||||||
|
When consuming **@reliancy/bface** from npm, import shells and helpers from `@reliancy/bface/ui/components` (for example `import { EmptyShell, useShell } from '@reliancy/bface/ui/components'`). This repository uses `@ui/*` TypeScript path aliases in source; published paths follow `package.json` `exports`.
|
||||||
|
|
||||||
|
Shell components provide a flexible, responsive, sectioned layout system for the application. All components are built with Tamagui for cross-platform compatibility (web + React Native).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Responsive Layout Structure
|
||||||
|
|
||||||
|
#### Desktop Layout (gtSm, > 801px)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ LeftSide │ MiddleSide │ RightSide │
|
||||||
|
│ │ ┌────────┐ │ │
|
||||||
|
│ │ │ Header │ │ │
|
||||||
|
│ │ ├────────┤ │ │
|
||||||
|
│ │ │ Main │ │ │
|
||||||
|
│ │ │Content │ │ │
|
||||||
|
│ │ ├────────┤ │ │
|
||||||
|
│ │ │ Footer │ │ │
|
||||||
|
│ │ └────────┘ │ │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mobile Layout (sm and below, ≤ 801px)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Header │
|
||||||
|
├─────────────────────┤
|
||||||
|
│ LeftSide (TopBar) │ ← Hamburger menu bar
|
||||||
|
├─────────────────────┤
|
||||||
|
│ MainContent │
|
||||||
|
├─────────────────────┤
|
||||||
|
│ RightSide │
|
||||||
|
├─────────────────────┤
|
||||||
|
│ Footer │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default Dimensions
|
||||||
|
|
||||||
|
- **LeftSide**: 0px width (collapsed on desktop), full width on mobile
|
||||||
|
- **RightSide**: 0px width (collapsed on desktop), full width on mobile
|
||||||
|
- **MiddleSide**: 100% width (takes remaining space on desktop)
|
||||||
|
- **Header**: 0px height (collapsed)
|
||||||
|
- **Footer**: 0px height (collapsed)
|
||||||
|
- **MainContent**: 100% height (flex: 1)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmptyShell } from '@ui/components';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<EmptyShell>
|
||||||
|
<div>This goes to MainContent by default</div>
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Placing Children in Specific Sections
|
||||||
|
|
||||||
|
#### Method 1: Using ShellPlacement Component
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmptyShell, ShellPlacement } from '@ui/components';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<EmptyShell>
|
||||||
|
<ShellPlacement placement="leftSide">
|
||||||
|
<Sidebar />
|
||||||
|
</ShellPlacement>
|
||||||
|
|
||||||
|
<ShellPlacement placement="header">
|
||||||
|
<Header />
|
||||||
|
</ShellPlacement>
|
||||||
|
|
||||||
|
<div>Main content (default placement)</div>
|
||||||
|
|
||||||
|
<ShellPlacement placement="footer">
|
||||||
|
<Footer />
|
||||||
|
</ShellPlacement>
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Method 2: Using placement prop directly
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmptyShell } from '@ui/components';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<EmptyShell>
|
||||||
|
<Sidebar placement="leftSide" />
|
||||||
|
<Header placement="header" />
|
||||||
|
<MainContent /> {/* Defaults to mainContent */}
|
||||||
|
<Footer placement="footer" />
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
Use the `useShell` hook to control section dimensions:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmptyShell, useShell } from '@ui/components';
|
||||||
|
|
||||||
|
function SidebarToggle() {
|
||||||
|
const { toggleLeftSide, leftSideWidth } = useShell();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => toggleLeftSide(250)}>
|
||||||
|
{leftSideWidth === 0 ? 'Show' : 'Hide'} Sidebar
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
return (
|
||||||
|
<EmptyShell>
|
||||||
|
<Sidebar placement="leftSide" />
|
||||||
|
<SidebarToggle />
|
||||||
|
<MainContent />
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Control Functions
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const {
|
||||||
|
// Current dimensions
|
||||||
|
leftSideWidth,
|
||||||
|
rightSideWidth,
|
||||||
|
headerHeight,
|
||||||
|
footerHeight,
|
||||||
|
|
||||||
|
// Setter functions
|
||||||
|
setLeftSideWidth,
|
||||||
|
setRightSideWidth,
|
||||||
|
setHeaderHeight,
|
||||||
|
setFooterHeight,
|
||||||
|
|
||||||
|
// Convenience toggles
|
||||||
|
toggleLeftSide, // (targetWidth = 250) => void
|
||||||
|
toggleRightSide // (targetWidth = 250) => void
|
||||||
|
} = useShell();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: DashboardShell with Toggleable Sidebar
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmptyShell, useShell } from '@ui/components';
|
||||||
|
|
||||||
|
function DashboardShell({ children }) {
|
||||||
|
return (
|
||||||
|
<EmptyShell initialLeftWidth={250}>
|
||||||
|
<Sidebar placement="leftSide" />
|
||||||
|
<DashboardHeader placement="header" />
|
||||||
|
{children}
|
||||||
|
<DashboardFooter placement="footer" />
|
||||||
|
</EmptyShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar() {
|
||||||
|
const { toggleLeftSide, leftSideWidth } = useShell();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => toggleLeftSide(250)}>
|
||||||
|
{leftSideWidth === 0 ? '☰' : '✕'}
|
||||||
|
</button>
|
||||||
|
{/* Sidebar content */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Placement Values
|
||||||
|
|
||||||
|
- `'leftSide'` or `'left'` - Left sidebar
|
||||||
|
- `'rightSide'` or `'right'` - Right sidebar
|
||||||
|
- `'header'` - Top header section
|
||||||
|
- `'footer'` - Bottom footer section
|
||||||
|
- `'mainContent'` or `'main'` - Main content area (default)
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
All shell components are responsive and automatically adapt to screen size using Tamagui's `useMedia()` hook:
|
||||||
|
|
||||||
|
- **Breakpoint**: Switches at `sm` (801px) using Tamagui's default breakpoints
|
||||||
|
- **Desktop (> 801px)**: Horizontal layout with sidebars
|
||||||
|
- **Mobile (≤ 801px)**: Vertical stack layout
|
||||||
|
|
||||||
|
### TopBar Component
|
||||||
|
|
||||||
|
The `TopBar` component automatically switches between wide and narrow layouts:
|
||||||
|
|
||||||
|
- **Desktop**: Full horizontal navigation bar with all menu items visible
|
||||||
|
- **Mobile**: Hamburger menu button + Sheet drawer for menu items
|
||||||
|
|
||||||
|
**Subcomponents:**
|
||||||
|
- `TopBar.Wide` - Desktop layout (horizontal menu items)
|
||||||
|
- `TopBar.Narrow` - Mobile layout (hamburger + Sheet)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```jsx
|
||||||
|
import { TopBar } from '@ui/components';
|
||||||
|
|
||||||
|
// Automatically responsive
|
||||||
|
<TopBar>
|
||||||
|
{/* Custom content with placement */}
|
||||||
|
</TopBar>
|
||||||
|
```
|
||||||
|
|
||||||
|
### SideBar Component
|
||||||
|
|
||||||
|
The `SideBar` component automatically switches between wide and narrow layouts:
|
||||||
|
|
||||||
|
- **Desktop**: Fixed vertical sidebar with all menu items visible
|
||||||
|
- **Mobile**: Hamburger menu button + Sheet drawer for menu items
|
||||||
|
|
||||||
|
**Subcomponents:**
|
||||||
|
- `SideBar.Wide` - Desktop layout (vertical sidebar)
|
||||||
|
- `SideBar.Narrow` - Mobile layout (hamburger + Sheet)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```jsx
|
||||||
|
import { SideBar } from '@ui/components';
|
||||||
|
|
||||||
|
// Automatically responsive
|
||||||
|
<SideBar>
|
||||||
|
{/* Custom content with placement */}
|
||||||
|
</SideBar>
|
||||||
|
```
|
||||||
|
|
||||||
|
### EmptyShell Responsive Behavior
|
||||||
|
|
||||||
|
`EmptyShell` automatically adapts its layout:
|
||||||
|
|
||||||
|
- **Desktop**: Horizontal `XStack` with LeftSide, MiddleSide, RightSide side-by-side
|
||||||
|
- **Mobile**: Vertical `YStack` with sections stacked: Header → LeftSide → MainContent → RightSide → Footer
|
||||||
|
|
||||||
|
On mobile, `LeftSide` becomes a full-width top bar (perfect for hamburger menus), and `RightSide` stacks below main content.
|
||||||
|
|
||||||
|
## Platform-Agnostic
|
||||||
|
|
||||||
|
All shell components use Tamagui components (`XStack`, `YStack`, `View`, `Sheet`, `useMedia`) making them work on:
|
||||||
|
- Web (React)
|
||||||
|
- iOS (React Native)
|
||||||
|
- Android (React Native)
|
||||||
|
- Other platforms supported by Tamagui
|
||||||
|
|
||||||
|
The responsive design uses Tamagui's built-in breakpoints and media queries, ensuring consistent behavior across platforms.
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button, Paragraph, Separator, Tabs, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { getConfig, setConfig } from '../../platform/env.js';
|
||||||
|
|
||||||
|
function normalizeToggleAlign(toggleAlign) {
|
||||||
|
return toggleAlign === 'left' ? 'left' : 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariant(variant, styleVariant) {
|
||||||
|
return styleVariant || variant || 'accordion';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderIcon(icon, color = '$color') {
|
||||||
|
const IconComponent = getIcon(icon);
|
||||||
|
if (!IconComponent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IconComponent size={18} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContentStyle(contentStyle) {
|
||||||
|
return contentStyle === 'tabs' ? 'tabs' : 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentNode(item) {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.render === 'function') {
|
||||||
|
return item.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.component) {
|
||||||
|
const Component = item.component;
|
||||||
|
return <Component />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.content !== undefined) {
|
||||||
|
return item.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children !== undefined) {
|
||||||
|
return item.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
children,
|
||||||
|
content = [],
|
||||||
|
contentStyle = 'list',
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
description = '',
|
||||||
|
defaultExpanded = true,
|
||||||
|
expanded: expandedProp,
|
||||||
|
onExpandedChange,
|
||||||
|
variant,
|
||||||
|
styleVariant,
|
||||||
|
toggleAlign = 'right',
|
||||||
|
headerRight = null,
|
||||||
|
bodyPadding = '$3',
|
||||||
|
persistenceKey = null
|
||||||
|
}) {
|
||||||
|
const effectiveVariant = normalizeVariant(variant, styleVariant);
|
||||||
|
const effectiveToggleAlign = normalizeToggleAlign(toggleAlign);
|
||||||
|
const effectiveContentStyle = normalizeContentStyle(contentStyle);
|
||||||
|
const isControlled = typeof expandedProp === 'boolean';
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||||
|
const [hasLoadedPreference, setHasLoadedPreference] = useState(!persistenceKey || isControlled);
|
||||||
|
const [selectedContentId, setSelectedContentId] = useState(content[0]?.id || null);
|
||||||
|
const [hasLoadedTabPreference, setHasLoadedTabPreference] = useState(!persistenceKey || effectiveContentStyle !== 'tabs');
|
||||||
|
const hasWrittenInitialPreference = useRef(false);
|
||||||
|
const hasWrittenInitialTabPreference = useRef(false);
|
||||||
|
const expanded = isControlled ? expandedProp : internalExpanded;
|
||||||
|
const configKey = persistenceKey ? `settings.panel.${persistenceKey}.expanded` : null;
|
||||||
|
const tabConfigKey = persistenceKey ? `settings.panel.${persistenceKey}.selected-tab` : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content.length === 0) {
|
||||||
|
setSelectedContentId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingItem = content.find((item) => item.id === selectedContentId);
|
||||||
|
if (!matchingItem) {
|
||||||
|
setSelectedContentId(content[0].id);
|
||||||
|
}
|
||||||
|
}, [content, selectedContentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadPreference() {
|
||||||
|
if (!configKey || isControlled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedValue = await getConfig(configKey, null);
|
||||||
|
if (!cancelled && typeof savedValue === 'boolean') {
|
||||||
|
setInternalExpanded(savedValue);
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasLoadedPreference(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreference();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [configKey, isControlled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadTabPreference() {
|
||||||
|
if (!tabConfigKey || effectiveContentStyle !== 'tabs') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedValue = await getConfig(tabConfigKey, null);
|
||||||
|
if (!cancelled && typeof savedValue === 'string' && content.some((item) => item.id === savedValue)) {
|
||||||
|
setSelectedContentId(savedValue);
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasLoadedTabPreference(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTabPreference();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [content, effectiveContentStyle, tabConfigKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configKey || isControlled || !hasLoadedPreference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWrittenInitialPreference.current) {
|
||||||
|
hasWrittenInitialPreference.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(configKey, expanded).catch((error) => {
|
||||||
|
console.warn(`[SettingsPanel] Failed to persist expanded state for ${configKey}:`, error);
|
||||||
|
});
|
||||||
|
}, [configKey, expanded, hasLoadedPreference, isControlled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabConfigKey || effectiveContentStyle !== 'tabs' || !hasLoadedTabPreference || !selectedContentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWrittenInitialTabPreference.current) {
|
||||||
|
hasWrittenInitialTabPreference.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(tabConfigKey, selectedContentId).catch((error) => {
|
||||||
|
console.warn(`[SettingsPanel] Failed to persist selected tab for ${tabConfigKey}:`, error);
|
||||||
|
});
|
||||||
|
}, [effectiveContentStyle, hasLoadedTabPreference, selectedContentId, tabConfigKey]);
|
||||||
|
|
||||||
|
const ToggleIcon = useMemo(() => {
|
||||||
|
return getIcon(expanded ? 'chevron-down' : 'chevron-right');
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const nextValue = !expanded;
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalExpanded(nextValue);
|
||||||
|
}
|
||||||
|
onExpandedChange?.(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleButton = (
|
||||||
|
<Button
|
||||||
|
key="settings-toggle"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
size="$3"
|
||||||
|
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
|
||||||
|
onPress={handleToggle}
|
||||||
|
icon={ToggleIcon ? <ToggleIcon size={16} /> : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerContent = (
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
gap="$3"
|
||||||
|
width="100%"
|
||||||
|
minHeight={effectiveVariant === 'panel' ? 42 : 40}
|
||||||
|
>
|
||||||
|
{effectiveToggleAlign === 'left' ? toggleButton : null}
|
||||||
|
{icon ? (
|
||||||
|
<XStack
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')}
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap">
|
||||||
|
<Text fontWeight="700" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$color">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}>
|
||||||
|
{description}
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
{headerRight}
|
||||||
|
{effectiveToggleAlign !== 'left' ? toggleButton : null}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedBody = (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{content.length > 0 ? (
|
||||||
|
effectiveContentStyle === 'tabs' ? (
|
||||||
|
<Tabs
|
||||||
|
value={selectedContentId || content[0]?.id}
|
||||||
|
onValueChange={setSelectedContentId}
|
||||||
|
orientation="horizontal"
|
||||||
|
flexDirection="column"
|
||||||
|
gap="$3"
|
||||||
|
>
|
||||||
|
<Tabs.List
|
||||||
|
disablePassBorderRadius
|
||||||
|
backgroundColor="$backgroundStrong"
|
||||||
|
borderRadius="$4"
|
||||||
|
padding="$1"
|
||||||
|
gap="$1"
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{content.map((item) => (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={item.id}
|
||||||
|
value={item.id}
|
||||||
|
borderRadius="$3"
|
||||||
|
backgroundColor="$background"
|
||||||
|
hoverStyle={{ backgroundColor: '$backgroundHover' }}
|
||||||
|
pressStyle={{ backgroundColor: '$backgroundPress' }}
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" gap="$2">
|
||||||
|
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null}
|
||||||
|
<Text fontWeight="700" color="$color">
|
||||||
|
{item.label || item.title}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{content.map((item) => (
|
||||||
|
<Tabs.Content key={item.id} value={item.id}>
|
||||||
|
<YStack
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
borderRadius="$4"
|
||||||
|
backgroundColor="$background"
|
||||||
|
padding="$3"
|
||||||
|
gap="$3"
|
||||||
|
>
|
||||||
|
{getContentNode(item)}
|
||||||
|
</YStack>
|
||||||
|
</Tabs.Content>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<YStack gap="$3">
|
||||||
|
{content.map((item) => (
|
||||||
|
<SettingsPanel
|
||||||
|
key={item.id}
|
||||||
|
icon={item.icon || 'settings'}
|
||||||
|
title={item.label || item.title || item.id}
|
||||||
|
description={item.description || ''}
|
||||||
|
variant="panel"
|
||||||
|
defaultExpanded={item.defaultExpanded ?? false}
|
||||||
|
toggleAlign="right"
|
||||||
|
persistenceKey={item.persistenceKey}
|
||||||
|
>
|
||||||
|
{getContentNode(item)}
|
||||||
|
</SettingsPanel>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (effectiveVariant === 'panel') {
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
borderRadius="$4"
|
||||||
|
backgroundColor="$background"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
alignItems="center"
|
||||||
|
backgroundColor="$backgroundHover"
|
||||||
|
borderBottomWidth={expanded ? 1 : 0}
|
||||||
|
borderBottomColor="$borderColor"
|
||||||
|
>
|
||||||
|
{headerContent}
|
||||||
|
</XStack>
|
||||||
|
{expanded ? (
|
||||||
|
<YStack padding={bodyPadding} gap="$3">
|
||||||
|
{renderedBody}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack gap="$2" paddingVertical="$1">
|
||||||
|
<Button
|
||||||
|
chromeless
|
||||||
|
onPress={handleToggle}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
borderWidth={0}
|
||||||
|
padding={0}
|
||||||
|
justifyContent="flex-start"
|
||||||
|
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }}
|
||||||
|
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<YStack gap="$2">
|
||||||
|
{headerContent}
|
||||||
|
</YStack>
|
||||||
|
</Button>
|
||||||
|
{expanded ? (
|
||||||
|
<YStack paddingLeft={effectiveToggleAlign === 'left' ? '$7' : icon ? '$7' : '$1'} paddingRight="$1" gap="$3">
|
||||||
|
{renderedBody}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
<Separator borderColor="$borderColor" />
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPanel;
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
/**
|
||||||
|
* Shell
|
||||||
|
* Provides shell state management, context, provider, placement, and singleton manager
|
||||||
|
* Platform-agnostic shell system for UI layout control
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { XStack, YStack, Text, Button } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shell Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ShellContext = createContext(null);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shell Manager (Singleton)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell Manager
|
||||||
|
* Singleton that maintains shell state and provides global access for handlers
|
||||||
|
* Accessible via Shell.Manager for global access
|
||||||
|
*/
|
||||||
|
class ShellManager {
|
||||||
|
constructor() {
|
||||||
|
this._state = {
|
||||||
|
leftSideWidth: 0,
|
||||||
|
rightSideWidth: 0,
|
||||||
|
headerHeight: 0,
|
||||||
|
footerHeight: 0
|
||||||
|
};
|
||||||
|
this._setters = {
|
||||||
|
setLeftSideWidth: null,
|
||||||
|
setRightSideWidth: null,
|
||||||
|
setHeaderHeight: null,
|
||||||
|
setFooterHeight: null
|
||||||
|
};
|
||||||
|
this._listeners = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize shell manager (called by ShellProvider)
|
||||||
|
* @param {Object} setters - State setter functions from ShellProvider
|
||||||
|
*/
|
||||||
|
_init(setters) {
|
||||||
|
this._setters = setters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update internal state (called by ShellProvider)
|
||||||
|
*/
|
||||||
|
_updateState(state) {
|
||||||
|
this._state = { ...state };
|
||||||
|
this._notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all listeners of state changes
|
||||||
|
*/
|
||||||
|
_notifyListeners() {
|
||||||
|
this._listeners.forEach(listener => {
|
||||||
|
try {
|
||||||
|
listener({ ...this._state });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ShellManager] Listener error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current state
|
||||||
|
* @returns {Object} Current shell state
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
return { ...this._state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get left side width
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getLeftSideWidth() {
|
||||||
|
return this._state.leftSideWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get right side width
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getRightSideWidth() {
|
||||||
|
return this._state.rightSideWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set left side width
|
||||||
|
* @param {number} width
|
||||||
|
*/
|
||||||
|
setLeftSideWidth(width) {
|
||||||
|
if (this._setters.setLeftSideWidth) {
|
||||||
|
this._setters.setLeftSideWidth(width);
|
||||||
|
} else {
|
||||||
|
console.warn('[ShellManager] setLeftSideWidth not initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set right side width
|
||||||
|
* @param {number} width
|
||||||
|
*/
|
||||||
|
setRightSideWidth(width) {
|
||||||
|
if (this._setters.setRightSideWidth) {
|
||||||
|
this._setters.setRightSideWidth(width);
|
||||||
|
} else {
|
||||||
|
console.warn('[ShellManager] setRightSideWidth not initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle left side
|
||||||
|
* @param {number} [targetWidth=250] - Target width when opening
|
||||||
|
*/
|
||||||
|
toggleLeftSide(targetWidth = 250) {
|
||||||
|
const currentWidth = this._state.leftSideWidth;
|
||||||
|
this.setLeftSideWidth(currentWidth === 0 ? targetWidth : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle right side
|
||||||
|
* @param {number} [targetWidth=250] - Target width when opening
|
||||||
|
*/
|
||||||
|
toggleRightSide(targetWidth = 250) {
|
||||||
|
const currentWidth = this._state.rightSideWidth;
|
||||||
|
this.setRightSideWidth(currentWidth === 0 ? targetWidth : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to state changes
|
||||||
|
* @param {Function} listener - Callback function
|
||||||
|
* @returns {Function} Unsubscribe function
|
||||||
|
*/
|
||||||
|
subscribe(listener) {
|
||||||
|
this._listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this._listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
const shellManager = new ShellManager();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Toast Manager (Singleton)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast Manager
|
||||||
|
* Singleton that maintains toast state and provides global access for handlers
|
||||||
|
* Accessible via Shell.ToastManager for global access
|
||||||
|
*/
|
||||||
|
class ToastManager {
|
||||||
|
constructor() {
|
||||||
|
this._toasts = [];
|
||||||
|
this._setToasts = null;
|
||||||
|
this._maxToasts = 5;
|
||||||
|
this._defaultDuration = 5000;
|
||||||
|
this._timeouts = new Map(); // Map of toast ID to timeout ID
|
||||||
|
this._pausedTimes = new Map(); // Map of toast ID to remaining time when paused
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize toast manager (called by ShellProvider)
|
||||||
|
* @param {Function} setToasts - State setter function from ShellProvider
|
||||||
|
*/
|
||||||
|
_init(setToasts) {
|
||||||
|
this._setToasts = setToasts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique toast ID
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_generateId() {
|
||||||
|
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast notification
|
||||||
|
* @param {string} title - Toast title
|
||||||
|
* @param {string} [message] - Toast message
|
||||||
|
* @param {Object} [options] - Toast options
|
||||||
|
* @param {string} [options.type='info'] - Toast type: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
* @param {number} [options.duration] - Auto-dismiss duration in ms (default: 5000)
|
||||||
|
* @returns {string} Toast ID
|
||||||
|
*/
|
||||||
|
show(title, message = '', options = {}) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
duration = this._defaultDuration
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const toast = {
|
||||||
|
id: this._generateId(),
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
duration,
|
||||||
|
timestamp: startTime,
|
||||||
|
startTime: startTime // Store start time for pause/resume calculations
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this._setToasts) {
|
||||||
|
console.warn('[ToastManager] Toast setter not initialized');
|
||||||
|
return toast.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setToasts(prev => {
|
||||||
|
const updated = [toast, ...prev];
|
||||||
|
// Limit max toasts
|
||||||
|
return updated.slice(0, this._maxToasts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-dismiss after duration
|
||||||
|
if (duration > 0) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.hide(toast.id);
|
||||||
|
this._timeouts.delete(toast.id);
|
||||||
|
}, duration);
|
||||||
|
this._timeouts.set(toast.id, timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide a toast by ID
|
||||||
|
* @param {string} id - Toast ID
|
||||||
|
*/
|
||||||
|
hide(id) {
|
||||||
|
if (!this._setToasts) {
|
||||||
|
console.warn('[ToastManager] Toast setter not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear timeout if exists
|
||||||
|
const timeoutId = this._timeouts.get(id);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this._timeouts.delete(id);
|
||||||
|
}
|
||||||
|
this._pausedTimes.delete(id);
|
||||||
|
|
||||||
|
this._setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause auto-dismiss timeout for a toast (e.g., when hovered)
|
||||||
|
* @param {string} id - Toast ID
|
||||||
|
*/
|
||||||
|
pauseTimeout(id) {
|
||||||
|
const timeoutId = this._timeouts.get(id);
|
||||||
|
if (!timeoutId) return; // No active timeout
|
||||||
|
|
||||||
|
if (!this._setToasts) return;
|
||||||
|
|
||||||
|
// Get the toast to calculate remaining time
|
||||||
|
let toast = null;
|
||||||
|
this._setToasts(prev => {
|
||||||
|
toast = prev.find(t => t.id === id);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toast || !toast.duration || !toast.startTime) return;
|
||||||
|
|
||||||
|
// Calculate elapsed time and remaining time
|
||||||
|
const elapsed = Date.now() - toast.startTime;
|
||||||
|
const remaining = toast.duration - elapsed;
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// Time already expired, hide immediately
|
||||||
|
this.hide(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the current timeout
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this._timeouts.delete(id);
|
||||||
|
|
||||||
|
// Store remaining time
|
||||||
|
this._pausedTimes.set(id, { remaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume auto-dismiss timeout for a toast (e.g., when hover ends)
|
||||||
|
* @param {string} id - Toast ID
|
||||||
|
*/
|
||||||
|
resumeTimeout(id) {
|
||||||
|
const pausedData = this._pausedTimes.get(id);
|
||||||
|
if (!pausedData) return; // Wasn't paused
|
||||||
|
|
||||||
|
if (!this._setToasts) return;
|
||||||
|
|
||||||
|
// Get the toast to verify it still exists
|
||||||
|
let toast = null;
|
||||||
|
this._setToasts(prev => {
|
||||||
|
toast = prev.find(t => t.id === id);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toast || !toast.duration) return;
|
||||||
|
|
||||||
|
// Use the remaining time from when it was paused
|
||||||
|
const remaining = pausedData.remaining;
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// Time already expired, hide immediately
|
||||||
|
this.hide(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout with remaining time
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.hide(id);
|
||||||
|
this._timeouts.delete(id);
|
||||||
|
}, remaining);
|
||||||
|
|
||||||
|
this._timeouts.set(id, timeoutId);
|
||||||
|
this._pausedTimes.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all toasts
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
if (!this._setToasts) {
|
||||||
|
console.warn('[ToastManager] Toast setter not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all timeouts
|
||||||
|
this._timeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||||
|
this._timeouts.clear();
|
||||||
|
this._pausedTimes.clear();
|
||||||
|
|
||||||
|
this._setToasts([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active toasts
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
getToasts() {
|
||||||
|
return [...this._toasts];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
const toastManager = new ToastManager();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shell Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell Provider Component
|
||||||
|
* Provides shell state and control functions to child components
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {React.ReactNode} props.children
|
||||||
|
* @param {number} [props.initialLeftWidth=0]
|
||||||
|
* @param {number} [props.initialRightWidth=0]
|
||||||
|
* @param {number} [props.initialHeaderHeight=0]
|
||||||
|
* @param {number} [props.initialFooterHeight=0]
|
||||||
|
*/
|
||||||
|
export function ShellProvider({
|
||||||
|
children,
|
||||||
|
initialLeftWidth = 0,
|
||||||
|
initialRightWidth = 0,
|
||||||
|
initialHeaderHeight = 0,
|
||||||
|
initialFooterHeight = 0
|
||||||
|
}) {
|
||||||
|
const [leftSideWidth, setLeftSideWidth] = useState(initialLeftWidth);
|
||||||
|
const [rightSideWidth, setRightSideWidth] = useState(initialRightWidth);
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(initialHeaderHeight);
|
||||||
|
const [footerHeight, setFooterHeight] = useState(initialFooterHeight);
|
||||||
|
|
||||||
|
// Toast state
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
// Initialize shell manager with setters
|
||||||
|
useEffect(() => {
|
||||||
|
shellManager._init({
|
||||||
|
setLeftSideWidth,
|
||||||
|
setRightSideWidth,
|
||||||
|
setHeaderHeight,
|
||||||
|
setFooterHeight
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize toast manager with setter
|
||||||
|
useEffect(() => {
|
||||||
|
toastManager._init(setToasts);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update shell manager state when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
shellManager._updateState({
|
||||||
|
leftSideWidth,
|
||||||
|
rightSideWidth,
|
||||||
|
headerHeight,
|
||||||
|
footerHeight
|
||||||
|
});
|
||||||
|
}, [leftSideWidth, rightSideWidth, headerHeight, footerHeight]);
|
||||||
|
|
||||||
|
// Toggle functions for convenience
|
||||||
|
const toggleLeftSide = useCallback((targetWidth = 250) => {
|
||||||
|
setLeftSideWidth(prev => prev === 0 ? targetWidth : 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleRightSide = useCallback((targetWidth = 250) => {
|
||||||
|
setRightSideWidth(prev => prev === 0 ? targetWidth : 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toast functions
|
||||||
|
const showToast = useCallback((title, message = '', options = {}) => {
|
||||||
|
return toastManager.show(title, message, options);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideToast = useCallback((id) => {
|
||||||
|
toastManager.hide(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearToasts = useCallback(() => {
|
||||||
|
toastManager.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseToast = useCallback((id) => {
|
||||||
|
toastManager.pauseTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resumeToast = useCallback((id) => {
|
||||||
|
toastManager.resumeTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
leftSideWidth,
|
||||||
|
rightSideWidth,
|
||||||
|
headerHeight,
|
||||||
|
footerHeight,
|
||||||
|
setLeftSideWidth,
|
||||||
|
setRightSideWidth,
|
||||||
|
setHeaderHeight,
|
||||||
|
setFooterHeight,
|
||||||
|
toggleLeftSide,
|
||||||
|
toggleRightSide,
|
||||||
|
toast: {
|
||||||
|
show: showToast,
|
||||||
|
hide: hideToast,
|
||||||
|
clear: clearToasts,
|
||||||
|
pause: pauseToast,
|
||||||
|
resume: resumeToast,
|
||||||
|
toasts
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShellContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ShellContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shell Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access shell context
|
||||||
|
* @returns {Object} Shell state and control functions
|
||||||
|
*/
|
||||||
|
export function useShell() {
|
||||||
|
const context = useContext(ShellContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useShell must be used within a ShellProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Toast Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast Component
|
||||||
|
* Individual toast notification item
|
||||||
|
*/
|
||||||
|
function Toast({ toast, onClose, onPause, onResume }) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
|
// Pause/resume timeout on hover
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHovered) {
|
||||||
|
onPause?.(toast.id);
|
||||||
|
} else {
|
||||||
|
onResume?.(toast.id);
|
||||||
|
}
|
||||||
|
}, [isHovered, toast.id, onPause, onResume]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose(toast.id);
|
||||||
|
}, 300); // Match animation duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type-specific styling
|
||||||
|
const typeStyles = {
|
||||||
|
info: {
|
||||||
|
backgroundColor: '$blue3',
|
||||||
|
borderColor: '$blue8',
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
backgroundColor: '$green3',
|
||||||
|
borderColor: '$green8',
|
||||||
|
icon: 'success'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
backgroundColor: '$yellow3',
|
||||||
|
borderColor: '$yellow8',
|
||||||
|
icon: 'warning'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
backgroundColor: '$red3',
|
||||||
|
borderColor: '$red8',
|
||||||
|
icon: 'error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = typeStyles[toast.type] || typeStyles.info;
|
||||||
|
const Icon = getIcon(style.icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
backgroundColor={style.backgroundColor}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={style.borderColor}
|
||||||
|
borderRadius="$4"
|
||||||
|
padding="$3"
|
||||||
|
minWidth={300}
|
||||||
|
maxWidth={400}
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
shadowOffset={{ width: 0, height: 2 }}
|
||||||
|
shadowOpacity={0.1}
|
||||||
|
shadowRadius={8}
|
||||||
|
elevation={4}
|
||||||
|
gap="$2"
|
||||||
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
|
animation="quick"
|
||||||
|
opacity={isExiting ? 0 : 1}
|
||||||
|
style={{
|
||||||
|
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||||
|
transform: isExiting ? 'translateX(400px)' : 'translateX(0)'
|
||||||
|
}}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{Icon ? (
|
||||||
|
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
<YStack flex={1} gap="$1">
|
||||||
|
{toast.title && (
|
||||||
|
<Text fontWeight="600" fontSize="$4" color="$color">
|
||||||
|
{toast.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{toast.message && (
|
||||||
|
<Text fontSize="$3" color="$color" opacity={0.9}>
|
||||||
|
{toast.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
<Button
|
||||||
|
size="$2"
|
||||||
|
circular
|
||||||
|
chromeless
|
||||||
|
onPress={handleClose}
|
||||||
|
aria-label="Close toast"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const CloseIcon = getIcon('close');
|
||||||
|
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</Text>;
|
||||||
|
})()}
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToastViewport Component
|
||||||
|
* Container for toast notifications (fixed bottom-right)
|
||||||
|
*/
|
||||||
|
export function ToastViewport() {
|
||||||
|
const { toast } = useShell();
|
||||||
|
|
||||||
|
if (!toast || !toast.toasts || toast.toasts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
position="fixed"
|
||||||
|
bottom="$4"
|
||||||
|
right="$4"
|
||||||
|
gap="$2"
|
||||||
|
zIndex={10000}
|
||||||
|
pointerEvents="none"
|
||||||
|
maxWidth="calc(100vw - 32px)"
|
||||||
|
>
|
||||||
|
{toast.toasts.map((toastItem) => (
|
||||||
|
<XStack
|
||||||
|
key={toastItem.id}
|
||||||
|
pointerEvents="auto"
|
||||||
|
>
|
||||||
|
<Toast
|
||||||
|
toast={toastItem}
|
||||||
|
onClose={toast.hide}
|
||||||
|
onPause={toast.pause}
|
||||||
|
onResume={toast.resume}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shell Placement
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShellPlacement Component
|
||||||
|
* Helper component for placing children in specific shell sections
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <ShellPlacement placement="leftSide">Sidebar content</ShellPlacement>
|
||||||
|
* <ShellPlacement placement="header">Header content</ShellPlacement>
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.placement - 'leftSide' | 'rightSide' | 'header' | 'footer' | 'mainContent'
|
||||||
|
* @param {React.ReactNode} props.children - Content to place
|
||||||
|
*/
|
||||||
|
export function ShellPlacement({ placement = 'mainContent', children }) {
|
||||||
|
// Clone children and add placement prop
|
||||||
|
return React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement(child)) {
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
placement,
|
||||||
|
shellPlacement: placement
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Attach static properties
|
||||||
|
ShellProvider.Manager = shellManager;
|
||||||
|
ShellProvider.ToastManager = toastManager;
|
||||||
|
ShellProvider.Context = ShellContext;
|
||||||
|
ShellProvider.Placement = ShellPlacement;
|
||||||
|
|
||||||
|
export default ShellProvider;
|
||||||
|
export { shellManager as ShellManager, toastManager as ToastManager };
|
||||||
|
export { ShellContext };
|
||||||
|
// ShellPlacement is already exported as a function declaration above
|
||||||
|
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* SideBar Component
|
||||||
|
* Vertical navigation bar with three sections: topSide, middleSide, bottomSide
|
||||||
|
* Platform-agnostic using Tamagui components
|
||||||
|
* Responsive: Uses Adapt to switch between Wide and Narrow variants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
|
||||||
|
import { View } from '@tamagui/core';
|
||||||
|
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||||
|
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
|
||||||
|
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||||
|
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { useShell } from './Shell.jsx';
|
||||||
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track menu changes and force re-render
|
||||||
|
*/
|
||||||
|
function useMenuVersion() {
|
||||||
|
const [version, setVersion] = useState(() => getMenuVersion());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeToMenuChanges((newVersion) => {
|
||||||
|
setVersion(newVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared logic for organizing children and menu items
|
||||||
|
* Used by both Wide and Narrow variants
|
||||||
|
*/
|
||||||
|
function useSideBarContent(children) {
|
||||||
|
// Subscribe to menu changes to force re-render when items are registered
|
||||||
|
const menuVersion = useMenuVersion();
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const security = {
|
||||||
|
...securityState,
|
||||||
|
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get menu items directly from menu.js (ground truth)
|
||||||
|
const primaryRoot = getRootItem('primary');
|
||||||
|
const secondaryRoot = getRootItem('secondary');
|
||||||
|
const personalRoot = getRootItem('personal');
|
||||||
|
|
||||||
|
const primaryMenuItems = primaryRoot ? Array.from(primaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
|
||||||
|
const secondaryMenuItems = secondaryRoot ? Array.from(secondaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
|
||||||
|
|
||||||
|
const sections = {
|
||||||
|
topSide: [],
|
||||||
|
middleSide: [],
|
||||||
|
bottomSide: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// First, add primary menu items to middleSide (scrollable area)
|
||||||
|
primaryMenuItems.forEach((item) => {
|
||||||
|
// Validate that item is a MenuItem instance
|
||||||
|
if (!(item instanceof MenuItem)) {
|
||||||
|
console.error('[SideBar] Expected MenuItem instance but got:', {
|
||||||
|
type: typeof item,
|
||||||
|
constructor: item?.constructor?.name,
|
||||||
|
item: item,
|
||||||
|
stack: new Error().stack
|
||||||
|
});
|
||||||
|
return; // Skip invalid items
|
||||||
|
}
|
||||||
|
sections.middleSide.push(
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="vertical"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, sift through children
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
sections.topSide.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = child.props?.placement || child.props?.sideBarPlacement || 'topSide';
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'topSide':
|
||||||
|
case 'top':
|
||||||
|
default:
|
||||||
|
sections.topSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'middleSide':
|
||||||
|
case 'middle':
|
||||||
|
sections.middleSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'bottomSide':
|
||||||
|
case 'bottom':
|
||||||
|
sections.bottomSide.push(child);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add secondary menu items to bottomSide
|
||||||
|
secondaryMenuItems.forEach((item) => {
|
||||||
|
// Validate that item is a MenuItem instance
|
||||||
|
if (!(item instanceof MenuItem)) {
|
||||||
|
console.error('[SideBar] Expected MenuItem instance but got:', {
|
||||||
|
type: typeof item,
|
||||||
|
constructor: item?.constructor?.name,
|
||||||
|
item: item,
|
||||||
|
stack: new Error().stack
|
||||||
|
});
|
||||||
|
return; // Skip invalid items
|
||||||
|
}
|
||||||
|
sections.bottomSide.push(
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="vertical"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add personal menu item as last element in bottomSide
|
||||||
|
if (personalRoot && personalRoot instanceof MenuItem) {
|
||||||
|
sections.bottomSide.push(
|
||||||
|
<PersonalMenuItem
|
||||||
|
key="personal-menu"
|
||||||
|
personalRoot={personalRoot}
|
||||||
|
orientation="vertical"
|
||||||
|
expand_mode="popup"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
primaryMenuItems,
|
||||||
|
secondaryMenuItems,
|
||||||
|
personalRoot,
|
||||||
|
menuVersion
|
||||||
|
};
|
||||||
|
}, [children, menuVersion, securityState]); // Include menuVersion to re-compute when menu changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SideBar.Wide - Desktop/tablet wide layout
|
||||||
|
* Fixed vertical sidebar with all menu items visible
|
||||||
|
* Supports collapse/expand functionality
|
||||||
|
*/
|
||||||
|
function SideBarWide({
|
||||||
|
children,
|
||||||
|
topSideHeight = 0,
|
||||||
|
bottomSideHeight = 0,
|
||||||
|
expandedWidth = 250,
|
||||||
|
collapsedWidth = 80
|
||||||
|
}) {
|
||||||
|
const [brandLogo, setBrandLogo] = useState(null);
|
||||||
|
const [appName, setAppName] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||||
|
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||||
|
setBrandLogo(logo);
|
||||||
|
setAppName(name);
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const organizedChildren = useSideBarContent(children);
|
||||||
|
const shell = useShell();
|
||||||
|
|
||||||
|
// Collapse/expand state
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Load collapsed state from storage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCollapsedState() {
|
||||||
|
try {
|
||||||
|
const saved = await getConfig('sidebar.collapsed', false);
|
||||||
|
if (saved === true) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SideBar] Failed to load collapsed state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadCollapsedState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update Shell leftSideWidth when collapsed state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (shell && shell.setLeftSideWidth) {
|
||||||
|
shell.setLeftSideWidth(isCollapsed ? collapsedWidth : expandedWidth);
|
||||||
|
}
|
||||||
|
}, [isCollapsed, collapsedWidth, expandedWidth, shell]);
|
||||||
|
|
||||||
|
// Toggle collapse/expand
|
||||||
|
const handleToggle = async () => {
|
||||||
|
const newState = !isCollapsed;
|
||||||
|
setIsCollapsed(newState);
|
||||||
|
try {
|
||||||
|
await setConfig('sidebar.collapsed', newState);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SideBar] Failed to save collapsed state:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentWidth = isCollapsed ? collapsedWidth : expandedWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
width={currentWidth}
|
||||||
|
height="100%"
|
||||||
|
gap="$2"
|
||||||
|
padding="$2"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderRightWidth={1}
|
||||||
|
borderRightColor="$accentBorder"
|
||||||
|
animation="quick"
|
||||||
|
animateOnly={['width']}
|
||||||
|
>
|
||||||
|
{/* Top Side */}
|
||||||
|
{topSideHeight > 0 && (
|
||||||
|
<XStack
|
||||||
|
height={topSideHeight}
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{organizedChildren.sections.topSide}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Brand Logo, App Name, and Toggle Button */}
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
paddingVertical="$2"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{brandLogo && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: brandLogo }}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appName && !isCollapsed && (
|
||||||
|
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor">
|
||||||
|
{appName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle Button (chevron) - matches MenuItemButton chevron style */}
|
||||||
|
<XStack
|
||||||
|
cursor="pointer"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$1"
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: '$accentBackground'
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: '$accentHover'
|
||||||
|
}}
|
||||||
|
onPress={handleToggle}
|
||||||
|
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const ChevronIcon = getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left');
|
||||||
|
if (!ChevronIcon) return null;
|
||||||
|
return (
|
||||||
|
<ChevronIcon
|
||||||
|
size={16}
|
||||||
|
color="$accentColor"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Middle Side - Primary menu items and other content */}
|
||||||
|
<YStack
|
||||||
|
flex={1}
|
||||||
|
width="100%"
|
||||||
|
gap="$2"
|
||||||
|
style={{ flexShrink: 1, overflow: 'auto' }}
|
||||||
|
>
|
||||||
|
{React.Children.map(organizedChildren.sections.middleSide, (child) => {
|
||||||
|
if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) {
|
||||||
|
return React.cloneElement(child, { collapsed: isCollapsed });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
{/* Bottom Side - Secondary and Personal menu items (always shown if has content) */}
|
||||||
|
{organizedChildren.sections.bottomSide.length > 0 && (
|
||||||
|
<YStack
|
||||||
|
width="100%"
|
||||||
|
gap="$2"
|
||||||
|
alignItems="flex-start"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
paddingTop="$2"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{React.Children.map(organizedChildren.sections.bottomSide, (child) => {
|
||||||
|
if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) {
|
||||||
|
return React.cloneElement(child, { collapsed: isCollapsed });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SideBar.Narrow - Mobile narrow layout
|
||||||
|
* Hamburger menu button + Sheet for menu items
|
||||||
|
*/
|
||||||
|
function SideBarNarrow({ children }) {
|
||||||
|
const [brandLogo, setBrandLogo] = useState(null);
|
||||||
|
const [appName, setAppName] = useState(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||||
|
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||||
|
setBrandLogo(logo);
|
||||||
|
setAppName(name);
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const organizedChildren = useSideBarContent(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
padding="$2"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomColor="$accentBorder"
|
||||||
|
>
|
||||||
|
{/* Hamburger Menu Button */}
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
circular
|
||||||
|
icon={getIcon('menu')}
|
||||||
|
backgroundColor="$accentBackground"
|
||||||
|
color="$accentColor"
|
||||||
|
onPress={() => setMenuOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand Logo */}
|
||||||
|
{brandLogo && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: brandLogo }}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App Name - takes remaining space */}
|
||||||
|
{appName && (
|
||||||
|
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
||||||
|
{appName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */}
|
||||||
|
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
|
||||||
|
<XStack flexShrink={0}>
|
||||||
|
<PersonalMenuItem
|
||||||
|
key="personal-menu"
|
||||||
|
personalRoot={organizedChildren.personalRoot}
|
||||||
|
orientation="horizontal"
|
||||||
|
expand_mode="popup"
|
||||||
|
width="auto"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Mobile Menu Sheet */}
|
||||||
|
<Sheet
|
||||||
|
modal
|
||||||
|
open={menuOpen}
|
||||||
|
onOpenChange={setMenuOpen}
|
||||||
|
snapPoints={[85]}
|
||||||
|
dismissOnSnapToBottom
|
||||||
|
>
|
||||||
|
<Sheet.Overlay />
|
||||||
|
<Sheet.Handle />
|
||||||
|
<Sheet.Frame padding="$4" gap="$2">
|
||||||
|
<YStack gap="$2" width="100%">
|
||||||
|
{/* Primary Menu Items */}
|
||||||
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="vertical"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
onClick={(e, menuItem) => {
|
||||||
|
// Execute the menu item action if it's actionable
|
||||||
|
if (menuItem && menuItem.isActionable()) {
|
||||||
|
menuItem.execute(e.target, e);
|
||||||
|
}
|
||||||
|
// Close the sheet after clicking
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Secondary Menu Items */}
|
||||||
|
{organizedChildren.secondaryMenuItems.map((item) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="vertical"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
onClick={(e, menuItem) => {
|
||||||
|
// Execute the menu item action if it's actionable
|
||||||
|
if (menuItem && menuItem.isActionable()) {
|
||||||
|
menuItem.execute(e.target, e);
|
||||||
|
}
|
||||||
|
// Close the sheet after clicking
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</Sheet.Frame>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SideBar Component
|
||||||
|
* Responsive vertical navigation bar that adapts to screen size
|
||||||
|
* Uses Adapt to switch between Wide and Narrow variants
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render (defaults to topSide)
|
||||||
|
* @param {number} [props.topSideHeight=0] - Top side height (default: 0, wide only)
|
||||||
|
* @param {number} [props.bottomSideHeight=0] - Bottom side height (default: 0, wide only)
|
||||||
|
*/
|
||||||
|
export function SideBar(props) {
|
||||||
|
const media = useMedia();
|
||||||
|
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
||||||
|
|
||||||
|
// Use conditional rendering based on screen size
|
||||||
|
if (isNarrow) {
|
||||||
|
return <SideBarNarrow {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SideBarWide {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export subcomponents
|
||||||
|
SideBar.Wide = SideBarWide;
|
||||||
|
SideBar.Narrow = SideBarNarrow;
|
||||||
|
|
||||||
|
export default SideBar;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
function ActionButton({ action }) {
|
||||||
|
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
theme={action?.theme}
|
||||||
|
chromeless={action?.chromeless}
|
||||||
|
disabled={action?.disabled}
|
||||||
|
onPress={action?.onPress}
|
||||||
|
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
{action?.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidePanelShell({
|
||||||
|
open = false,
|
||||||
|
onClose = null,
|
||||||
|
title = 'Panel',
|
||||||
|
toolbar = [],
|
||||||
|
footerActions = [],
|
||||||
|
width = 420,
|
||||||
|
children = null
|
||||||
|
}) {
|
||||||
|
const [mounted, setMounted] = useState(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => setMounted(false), 220);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CloseIcon = getIcon('close');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
position="fixed"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
zIndex={18000}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
backgroundColor="rgba(15,23,42,0.26)"
|
||||||
|
opacity={open ? 1 : 0}
|
||||||
|
animation="quick"
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
width={width}
|
||||||
|
maxWidth="96vw"
|
||||||
|
backgroundColor="$background"
|
||||||
|
borderLeftWidth={1}
|
||||||
|
borderLeftColor="$borderColor"
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
shadowOpacity={0.18}
|
||||||
|
shadowRadius={20}
|
||||||
|
shadowOffset={{ width: -4, height: 0 }}
|
||||||
|
style={{
|
||||||
|
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
transition: 'transform 220ms ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
padding="$4"
|
||||||
|
gap="$3"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomColor="$borderColor"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
>
|
||||||
|
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||||
|
{toolbar.map((action, index) => (
|
||||||
|
<ActionButton key={action?.id || action?.label || index} action={action} />
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
circular
|
||||||
|
chromeless
|
||||||
|
onPress={onClose}
|
||||||
|
icon={CloseIcon ? <CloseIcon size={18} /> : undefined}
|
||||||
|
aria-label="Close panel"
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<ScrollView flex={1}>
|
||||||
|
<YStack padding="$4" gap="$4">
|
||||||
|
{children}
|
||||||
|
</YStack>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{footerActions.length > 0 ? (
|
||||||
|
<XStack
|
||||||
|
justifyContent="flex-end"
|
||||||
|
gap="$2"
|
||||||
|
padding="$4"
|
||||||
|
borderTopWidth={1}
|
||||||
|
borderTopColor="$borderColor"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{footerActions.map((action, index) => (
|
||||||
|
<ActionButton key={action?.id || action?.label || index} action={action} />
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SidePanelShell;
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* TopBar Component
|
||||||
|
* Horizontal navigation bar with three sections: leftSide, middleSide, rightSide
|
||||||
|
* Platform-agnostic using Tamagui components
|
||||||
|
* Responsive: Uses Adapt to switch between Wide and Narrow variants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
|
||||||
|
import { View } from '@tamagui/core';
|
||||||
|
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||||
|
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
|
||||||
|
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||||
|
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track menu changes and force re-render
|
||||||
|
*/
|
||||||
|
function useMenuVersion() {
|
||||||
|
const [version, setVersion] = useState(() => getMenuVersion());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeToMenuChanges((newVersion) => {
|
||||||
|
setVersion(newVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared logic for organizing children and menu items
|
||||||
|
* Used by both Wide and Narrow variants
|
||||||
|
*/
|
||||||
|
function useTopBarContent(children) {
|
||||||
|
// Subscribe to menu changes to force re-render when items are registered
|
||||||
|
const menuVersion = useMenuVersion();
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const security = {
|
||||||
|
...securityState,
|
||||||
|
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get menu items directly from menu.js (ground truth)
|
||||||
|
const primaryRoot = getRootItem('primary');
|
||||||
|
const secondaryRoot = getRootItem('secondary');
|
||||||
|
const personalRoot = getRootItem('personal');
|
||||||
|
|
||||||
|
const primaryMenuItems = primaryRoot ? Array.from(primaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
|
||||||
|
const secondaryMenuItems = secondaryRoot ? Array.from(secondaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
|
||||||
|
|
||||||
|
const sections = {
|
||||||
|
leftSide: [],
|
||||||
|
middleSide: [],
|
||||||
|
rightSide: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// First, add primary menu items to leftSide
|
||||||
|
primaryMenuItems.forEach((item) => {
|
||||||
|
// Validate that item is a MenuItem instance
|
||||||
|
if (!(item instanceof MenuItem)) {
|
||||||
|
console.error('[TopBar] Expected MenuItem instance but got:', {
|
||||||
|
type: typeof item,
|
||||||
|
constructor: item?.constructor?.name,
|
||||||
|
item: item,
|
||||||
|
stack: new Error().stack
|
||||||
|
});
|
||||||
|
return; // Skip invalid items
|
||||||
|
}
|
||||||
|
sections.leftSide.push(
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="horizontal"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, sift through children
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
sections.leftSide.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = child.props?.placement || child.props?.topBarPlacement || 'leftSide';
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'middleSide':
|
||||||
|
case 'middle':
|
||||||
|
sections.middleSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'rightSide':
|
||||||
|
case 'right':
|
||||||
|
sections.rightSide.push(child);
|
||||||
|
break;
|
||||||
|
case 'leftSide':
|
||||||
|
case 'left':
|
||||||
|
default:
|
||||||
|
sections.leftSide.push(child);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add secondary menu items to rightSide (icon only)
|
||||||
|
secondaryMenuItems.forEach((item) => {
|
||||||
|
// Validate that item is a MenuItem instance
|
||||||
|
if (!(item instanceof MenuItem)) {
|
||||||
|
console.error('[TopBar] Expected MenuItem instance but got:', {
|
||||||
|
type: typeof item,
|
||||||
|
constructor: item?.constructor?.name,
|
||||||
|
item: item,
|
||||||
|
stack: new Error().stack
|
||||||
|
});
|
||||||
|
return; // Skip invalid items
|
||||||
|
}
|
||||||
|
sections.rightSide.push(
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="horizontal"
|
||||||
|
displayStyle="icon_only"
|
||||||
|
padding="$1"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add personal menu item as last element in rightSide
|
||||||
|
if (personalRoot && personalRoot instanceof MenuItem) {
|
||||||
|
sections.rightSide.push(
|
||||||
|
<PersonalMenuItem
|
||||||
|
key="personal-menu"
|
||||||
|
personalRoot={personalRoot}
|
||||||
|
orientation="horizontal"
|
||||||
|
expand_mode="popup"
|
||||||
|
stateVersion={menuVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
hasRightSideContent: sections.rightSide.length > 0,
|
||||||
|
primaryMenuItems,
|
||||||
|
secondaryMenuItems,
|
||||||
|
personalRoot,
|
||||||
|
menuVersion
|
||||||
|
};
|
||||||
|
}, [children, menuVersion, securityState]); // Include menuVersion to re-compute when menu changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopBar.Wide - Desktop/tablet wide layout
|
||||||
|
* Horizontal navigation bar with all menu items visible
|
||||||
|
*/
|
||||||
|
function TopBarWide({
|
||||||
|
children,
|
||||||
|
leftSideWidth = '100%',
|
||||||
|
middleSideWidth = 0,
|
||||||
|
rightSideWidth = 0
|
||||||
|
}) {
|
||||||
|
const [brandLogo, setBrandLogo] = useState(null);
|
||||||
|
const [appName, setAppName] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||||
|
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||||
|
setBrandLogo(logo);
|
||||||
|
setAppName(name);
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const organizedChildren = useTopBarContent(children);
|
||||||
|
|
||||||
|
const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0);
|
||||||
|
const hasMiddleOrRight = middleSideWidth > 0 || effectiveRightWidth > 0;
|
||||||
|
const leftUsesFlex = leftSideWidth === '100%' && !hasMiddleOrRight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
padding="$2"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomColor="$accentBorder"
|
||||||
|
>
|
||||||
|
{/* Left Side */}
|
||||||
|
<XStack
|
||||||
|
flex={leftUsesFlex ? 1 : 0}
|
||||||
|
width={leftUsesFlex ? undefined : (typeof leftSideWidth === 'number' ? leftSideWidth : '100%')}
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{/* Brand Logo */}
|
||||||
|
{brandLogo && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: brandLogo }}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App Name */}
|
||||||
|
{appName && (
|
||||||
|
<Text fontWeight="bold" fontSize="$4" color="$accentColor">
|
||||||
|
{appName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Side Items */}
|
||||||
|
{organizedChildren.sections.leftSide}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Middle Side */}
|
||||||
|
{middleSideWidth > 0 && (
|
||||||
|
<XStack
|
||||||
|
width={middleSideWidth}
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{organizedChildren.sections.middleSide}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right Side */}
|
||||||
|
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
|
||||||
|
<XStack
|
||||||
|
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
|
||||||
|
flex={effectiveRightWidth === 'auto' ? 0 : undefined}
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
gap="$1"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{organizedChildren.sections.rightSide}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopBar.Narrow - Mobile narrow layout
|
||||||
|
* Hamburger menu button + Sheet for menu items
|
||||||
|
*/
|
||||||
|
function TopBarNarrow({ children }) {
|
||||||
|
const [brandLogo, setBrandLogo] = useState(null);
|
||||||
|
const [appName, setAppName] = useState(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||||
|
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||||
|
setBrandLogo(logo);
|
||||||
|
setAppName(name);
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const organizedChildren = useTopBarContent(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
gap="$2"
|
||||||
|
padding="$2"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomColor="$accentBorder"
|
||||||
|
>
|
||||||
|
{/* Hamburger Menu Button */}
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
circular
|
||||||
|
icon={getIcon('menu')}
|
||||||
|
backgroundColor="$accentBackground"
|
||||||
|
color="$accentColor"
|
||||||
|
onPress={() => setMenuOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand Logo */}
|
||||||
|
{brandLogo && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: brandLogo }}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App Name - takes remaining space */}
|
||||||
|
{appName && (
|
||||||
|
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
||||||
|
{appName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary Menu Items - render in topbar, left of personal menu */}
|
||||||
|
{organizedChildren.secondaryMenuItems.length > 0 && (
|
||||||
|
<XStack flexShrink={0} alignItems="center" gap="$1">
|
||||||
|
{organizedChildren.secondaryMenuItems.map((item) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="horizontal"
|
||||||
|
displayStyle="icon_only"
|
||||||
|
padding="$1"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */}
|
||||||
|
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
|
||||||
|
<XStack flexShrink={0}>
|
||||||
|
<PersonalMenuItem
|
||||||
|
key="personal-menu"
|
||||||
|
personalRoot={organizedChildren.personalRoot}
|
||||||
|
orientation="horizontal"
|
||||||
|
expand_mode="popup"
|
||||||
|
width="auto"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{/* Mobile Menu Sheet */}
|
||||||
|
<Sheet
|
||||||
|
modal
|
||||||
|
open={menuOpen}
|
||||||
|
onOpenChange={setMenuOpen}
|
||||||
|
snapPoints={[85]}
|
||||||
|
dismissOnSnapToBottom
|
||||||
|
>
|
||||||
|
<Sheet.Overlay />
|
||||||
|
<Sheet.Handle />
|
||||||
|
<Sheet.Frame padding="$4" gap="$2">
|
||||||
|
<YStack gap="$2" width="100%">
|
||||||
|
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
||||||
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
|
<MenuItemButton
|
||||||
|
key={item.id || item.path}
|
||||||
|
menuItem={item}
|
||||||
|
orientation="vertical"
|
||||||
|
stateVersion={organizedChildren.menuVersion}
|
||||||
|
onClick={(e, menuItem) => {
|
||||||
|
// Execute the menu item action if it's actionable
|
||||||
|
if (menuItem && menuItem.isActionable()) {
|
||||||
|
menuItem.execute(e.target, e);
|
||||||
|
}
|
||||||
|
// Close the sheet after clicking
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</Sheet.Frame>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopBar Component
|
||||||
|
* Responsive navigation bar that adapts to screen size
|
||||||
|
* Uses Adapt to switch between Wide and Narrow variants
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {React.ReactNode} props.children - Content to render (defaults to leftSide)
|
||||||
|
* @param {number} [props.leftSideWidth='100%'] - Left side width (default: '100%', wide only)
|
||||||
|
* @param {number} [props.middleSideWidth=0] - Middle side width (default: 0, wide only)
|
||||||
|
* @param {number} [props.rightSideWidth=0] - Right side width (default: 0, wide only)
|
||||||
|
*/
|
||||||
|
export function TopBar(props) {
|
||||||
|
const media = useMedia();
|
||||||
|
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
||||||
|
|
||||||
|
// Use conditional rendering based on screen size
|
||||||
|
if (isNarrow) {
|
||||||
|
return <TopBarNarrow {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TopBarWide {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export subcomponents
|
||||||
|
TopBar.Wide = TopBarWide;
|
||||||
|
TopBar.Narrow = TopBarNarrow;
|
||||||
|
|
||||||
|
export default TopBar;
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { GridViewContext } from './context.js';
|
||||||
|
import { GridSegmentsLayout } from './layout.jsx';
|
||||||
|
import {
|
||||||
|
areSortEntriesEqual,
|
||||||
|
normalizeColumnDefinition,
|
||||||
|
normalizeColumnDefinitionsInput,
|
||||||
|
resolveVisibleColumns
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
export function GridView({
|
||||||
|
dataModel = null,
|
||||||
|
columns = undefined,
|
||||||
|
direction = 'vertical',
|
||||||
|
header = null,
|
||||||
|
body = null,
|
||||||
|
footer = null,
|
||||||
|
headerSize = 'auto',
|
||||||
|
bodySize = 'auto',
|
||||||
|
footerSize = 'auto',
|
||||||
|
visible = true,
|
||||||
|
model = null,
|
||||||
|
columnDefinitions = {},
|
||||||
|
statusText = '',
|
||||||
|
selectable = false,
|
||||||
|
nested = false,
|
||||||
|
onClose = undefined,
|
||||||
|
onReload = undefined,
|
||||||
|
initialPageSize = 6,
|
||||||
|
initialFilterBy = {},
|
||||||
|
filterBy: controlledFilterBy = undefined,
|
||||||
|
onFilterByChange = undefined,
|
||||||
|
initialSortBy = [],
|
||||||
|
...layoutProps
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [pageSize] = useState(initialPageSize);
|
||||||
|
const [sortBy, setSortBy] = useState(initialSortBy);
|
||||||
|
const [internalFilterBy, setInternalFilterBy] = useState(initialFilterBy);
|
||||||
|
const [structure, setStructure] = useState({ columns: {} });
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
|
const [reloadTick, setReloadTick] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [tableViewportWidth, setTableViewportWidth] = useState(0);
|
||||||
|
const effectiveModel = dataModel ?? model;
|
||||||
|
const effectiveColumnDefinitions = useMemo(
|
||||||
|
() => normalizeColumnDefinitionsInput(columns ?? columnDefinitions),
|
||||||
|
[columns, columnDefinitions]
|
||||||
|
);
|
||||||
|
const effectiveFilterBy = controlledFilterBy ?? internalFilterBy;
|
||||||
|
|
||||||
|
const setFilterBy = useCallback((nextValue) => {
|
||||||
|
const nextFilterBy =
|
||||||
|
typeof nextValue === 'function' ? nextValue(effectiveFilterBy) : nextValue;
|
||||||
|
|
||||||
|
if (controlledFilterBy !== undefined) {
|
||||||
|
onFilterByChange?.(nextFilterBy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalFilterBy(nextFilterBy);
|
||||||
|
}, [controlledFilterBy, effectiveFilterBy, onFilterByChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOffset(0);
|
||||||
|
}, [effectiveFilterBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function loadStructure() {
|
||||||
|
if (!effectiveModel?.queryStructure) {
|
||||||
|
setStructure({ columns: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextStructure = await effectiveModel.queryStructure();
|
||||||
|
if (active) {
|
||||||
|
setStructure(nextStructure || { columns: {} });
|
||||||
|
}
|
||||||
|
} catch (structureError) {
|
||||||
|
if (active) {
|
||||||
|
setError(String(structureError?.message || structureError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStructure();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [effectiveModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function loadRows() {
|
||||||
|
if (!effectiveModel?.queryRecords) {
|
||||||
|
setRows([]);
|
||||||
|
setTotal(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await effectiveModel.queryRecords({
|
||||||
|
offset,
|
||||||
|
page_size: pageSize,
|
||||||
|
sort_by: sortBy,
|
||||||
|
filter_by: effectiveFilterBy
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(response?.rows || []);
|
||||||
|
setTotal(response?.total || 0);
|
||||||
|
} catch (recordsError) {
|
||||||
|
if (active) {
|
||||||
|
setRows([]);
|
||||||
|
setTotal(0);
|
||||||
|
setError(String(recordsError?.message || recordsError));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRows();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [effectiveFilterBy, effectiveModel, offset, pageSize, reloadTick, sortBy]);
|
||||||
|
|
||||||
|
const resolvedColumns = useMemo(() => {
|
||||||
|
const sourceColumns = structure?.columns || {};
|
||||||
|
const fieldOrder = Array.from(
|
||||||
|
new Set([...Object.keys(sourceColumns), ...Object.keys(effectiveColumnDefinitions || {})])
|
||||||
|
);
|
||||||
|
const rowsSample = rows[0] || {};
|
||||||
|
|
||||||
|
return fieldOrder.map((field) =>
|
||||||
|
normalizeColumnDefinition(
|
||||||
|
field,
|
||||||
|
{ ...(sourceColumns[field] || {}), ...(effectiveColumnDefinitions[field] || {}) },
|
||||||
|
rowsSample[field]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [effectiveColumnDefinitions, rows, structure]);
|
||||||
|
|
||||||
|
const visibleColumns = useMemo(
|
||||||
|
() => resolveVisibleColumns(resolvedColumns, tableViewportWidth),
|
||||||
|
[resolvedColumns, tableViewportWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visibleFields = new Set(visibleColumns.map((column) => column.field));
|
||||||
|
setSortBy((current) => {
|
||||||
|
const next = current.filter((entry) => visibleFields.has(entry.field));
|
||||||
|
return areSortEntriesEqual(current, next) ? current : next;
|
||||||
|
});
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil((total || 0) / pageSize));
|
||||||
|
const currentPage = Math.min(pageCount, Math.floor(offset / pageSize) + 1);
|
||||||
|
const resolvedStatusText =
|
||||||
|
statusText ||
|
||||||
|
(error
|
||||||
|
? `Error: ${error}`
|
||||||
|
: isLoading
|
||||||
|
? `Loading records ${offset + 1} to ${Math.min(offset + pageSize, total || offset + pageSize)}...`
|
||||||
|
: `${total} records available`);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
model: effectiveModel,
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
filterBy: effectiveFilterBy,
|
||||||
|
setFilterBy,
|
||||||
|
structure,
|
||||||
|
resolvedColumns,
|
||||||
|
visibleColumns,
|
||||||
|
selectedIds,
|
||||||
|
selectable,
|
||||||
|
nested,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
tableViewportWidth,
|
||||||
|
setTableViewportWidth,
|
||||||
|
statusText: resolvedStatusText,
|
||||||
|
reload: async () => {
|
||||||
|
onReload?.();
|
||||||
|
setReloadTick((current) => current + 1);
|
||||||
|
},
|
||||||
|
close: () => onClose?.(),
|
||||||
|
setPage: (pageNumber) => {
|
||||||
|
const normalizedPage = Math.max(1, Math.min(pageCount, pageNumber));
|
||||||
|
setOffset((normalizedPage - 1) * pageSize);
|
||||||
|
},
|
||||||
|
toggleSort: (field) => {
|
||||||
|
const current = sortBy.find((entry) => entry.field === field);
|
||||||
|
if (!current) {
|
||||||
|
setSortBy([{ field, direction: 'asc' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current.direction === 'asc') {
|
||||||
|
setSortBy([{ field, direction: 'desc' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSortBy([]);
|
||||||
|
},
|
||||||
|
toggleSelectRow: (rowId) => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(rowId)) {
|
||||||
|
next.delete(rowId);
|
||||||
|
} else {
|
||||||
|
next.add(rowId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setFilterValue: (key, value) => {
|
||||||
|
setOffset(0);
|
||||||
|
setFilterBy((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
currentPage,
|
||||||
|
effectiveFilterBy,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
effectiveModel,
|
||||||
|
nested,
|
||||||
|
offset,
|
||||||
|
onClose,
|
||||||
|
onReload,
|
||||||
|
pageCount,
|
||||||
|
pageSize,
|
||||||
|
resolvedColumns,
|
||||||
|
resolvedStatusText,
|
||||||
|
rows,
|
||||||
|
selectable,
|
||||||
|
selectedIds,
|
||||||
|
sortBy,
|
||||||
|
structure,
|
||||||
|
tableViewportWidth,
|
||||||
|
total,
|
||||||
|
setFilterBy,
|
||||||
|
visibleColumns
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridViewContext.Provider value={contextValue}>
|
||||||
|
<GridSegmentsLayout
|
||||||
|
direction={direction}
|
||||||
|
header={header}
|
||||||
|
body={body}
|
||||||
|
footer={footer}
|
||||||
|
headerSize={headerSize}
|
||||||
|
bodySize={bodySize}
|
||||||
|
footerSize={footerSize}
|
||||||
|
visible={visible}
|
||||||
|
{...layoutProps}
|
||||||
|
/>
|
||||||
|
</GridViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GridView;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Grid Components
|
||||||
|
|
||||||
|
The `grid/` package provides a composable dataset presentation primitive for `bface`.
|
||||||
|
|
||||||
|
## Mental Model
|
||||||
|
|
||||||
|
- `DirView` is the opinionated, fast-start directory component.
|
||||||
|
- `GridView` is the more composable shell for datasets that may need alternate presentations such as table and panel/card layouts.
|
||||||
|
|
||||||
|
They are intentionally related, and their data-facing APIs are aligned where practical:
|
||||||
|
|
||||||
|
- both accept `dataModel`
|
||||||
|
- both accept `columns`
|
||||||
|
- both support column-level renderers
|
||||||
|
- both support searching, sorting, and paging
|
||||||
|
- both can use action collections in a similar style (`toolbarItems` / `actions`)
|
||||||
|
|
||||||
|
`GridView` differs in one major way: instead of owning one fixed rendering pattern, it composes `header`, `body`, and `footer` subviews around a shared grid context.
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
- `GridDataModel`
|
||||||
|
- `GridView`
|
||||||
|
- `GridSegmentsLayout`
|
||||||
|
- `PanelHeader`
|
||||||
|
- `PanelBodyView`
|
||||||
|
- `PanelFooter`
|
||||||
|
- `TableHeader`
|
||||||
|
- `TableBodyView`
|
||||||
|
- `TableFooter`
|
||||||
|
- `createPanelGridViewProps`
|
||||||
|
- `createTableGridViewProps`
|
||||||
|
|
||||||
|
## API Alignment With DirView
|
||||||
|
|
||||||
|
### Shared data props
|
||||||
|
|
||||||
|
Both `DirView` and `GridView` should prefer:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
dataModel={model}
|
||||||
|
columns={columns}
|
||||||
|
```
|
||||||
|
|
||||||
|
For compatibility, `GridView` still accepts `model` and `columnDefinitions`.
|
||||||
|
|
||||||
|
### Search and action alignment
|
||||||
|
|
||||||
|
`DirView` now supports both simple and more controlled usage:
|
||||||
|
|
||||||
|
- `searchConfig` for the built-in search input
|
||||||
|
- `searchValue` and `onSearchChange` for controlled search state
|
||||||
|
- `toolbarItems` or `actions` as aliases for `toolbarActions`
|
||||||
|
|
||||||
|
This keeps `DirView` closer to the way `GridView` is typically composed, while still preserving its more opinionated out-of-the-box behavior.
|
||||||
|
|
||||||
|
### Column shape
|
||||||
|
|
||||||
|
Both components tolerate either:
|
||||||
|
|
||||||
|
- array columns using `id`
|
||||||
|
- array columns using `field`
|
||||||
|
- object maps keyed by field name
|
||||||
|
|
||||||
|
Both also tolerate either:
|
||||||
|
|
||||||
|
- `render`
|
||||||
|
- `renderer`
|
||||||
|
|
||||||
|
for cell-level custom rendering.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import {
|
||||||
|
GridDataModel,
|
||||||
|
GridView,
|
||||||
|
PanelHeader,
|
||||||
|
PanelBodyView,
|
||||||
|
PanelFooter,
|
||||||
|
createPanelGridViewProps
|
||||||
|
} from '@reliancy/bface/ui/components';
|
||||||
|
|
||||||
|
const model = new GridDataModel({
|
||||||
|
rows: [
|
||||||
|
{ id: 1, customer: 'Northwind', total: 1200 },
|
||||||
|
{ id: 2, customer: 'Blue Harbor', total: 980 }
|
||||||
|
],
|
||||||
|
columns: {
|
||||||
|
customer: { label: 'Customer', alwaysVisible: true },
|
||||||
|
total: { label: 'Total', type: 'currency', align: 'right' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
<GridView
|
||||||
|
dataModel={model}
|
||||||
|
columns={{
|
||||||
|
total: { type: 'currency', currency: 'USD' }
|
||||||
|
}}
|
||||||
|
header={<PanelHeader title="Customers" />}
|
||||||
|
body={<PanelBodyView />}
|
||||||
|
footer={<PanelFooter />}
|
||||||
|
{...createPanelGridViewProps()}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## When To Use What
|
||||||
|
|
||||||
|
- Use `DirView` when you want a straightforward directory/table with summaries and a minimal API.
|
||||||
|
- Use `GridView` when the same dataset may need different bodies or shell arrangements, or when you want to compose the table/panel fragments yourself.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export const GridViewContext = createContext(null);
|
||||||
|
|
||||||
|
export function useGridView() {
|
||||||
|
const context = useContext(GridViewContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('GridView subcomponents must be rendered inside GridView.');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { GridView, default as GridViewDefault } from './GridView.jsx';
|
||||||
|
export { GridDataModel } from './model.js';
|
||||||
|
export { useGridView } from './context.js';
|
||||||
|
export { GridSegmentsLayout } from './layout.jsx';
|
||||||
|
export {
|
||||||
|
PanelHeader,
|
||||||
|
PanelBodyView,
|
||||||
|
PanelFooter,
|
||||||
|
PanelFooterStatusBar,
|
||||||
|
PanelToolBar
|
||||||
|
} from './panel.jsx';
|
||||||
|
export { TableHeader, TableBodyView, TableFooter } from './table.jsx';
|
||||||
|
export { createPanelGridViewProps, createTableGridViewProps } from './presets.js';
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { XStack, YStack } from 'tamagui';
|
||||||
|
|
||||||
|
function resolveSegmentLayout(direction, size, { isFlexible = false } = {}) {
|
||||||
|
const shared = {
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (size == null || size === 'auto') {
|
||||||
|
return isFlexible
|
||||||
|
? { ...shared, flex: 1 }
|
||||||
|
: { ...shared, flexShrink: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
if (size > 0 && size <= 1) {
|
||||||
|
return {
|
||||||
|
...shared,
|
||||||
|
flex: size,
|
||||||
|
flexBasis: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'vertical') {
|
||||||
|
return {
|
||||||
|
...shared,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: `${size}rem`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shared,
|
||||||
|
flexShrink: 0,
|
||||||
|
width: `${size}rem`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'vertical') {
|
||||||
|
return {
|
||||||
|
...shared,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shared,
|
||||||
|
flexShrink: 0,
|
||||||
|
width: size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentContainer({ direction, segmentKey, size, children }) {
|
||||||
|
if (children == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
key={segmentKey}
|
||||||
|
overflow="hidden"
|
||||||
|
{...resolveSegmentLayout(direction, size, { isFlexible: segmentKey === 'body' })}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSegmentsLayout({
|
||||||
|
direction = 'vertical',
|
||||||
|
header = null,
|
||||||
|
body = null,
|
||||||
|
footer = null,
|
||||||
|
headerSize = 'auto',
|
||||||
|
bodySize = 'auto',
|
||||||
|
footerSize = 'auto',
|
||||||
|
visible = true,
|
||||||
|
...stackProps
|
||||||
|
}) {
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StackComponent = direction === 'horizontal' ? XStack : YStack;
|
||||||
|
const rootLayoutProps = {
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
stackProps.height != null ||
|
||||||
|
stackProps.maxHeight != null ||
|
||||||
|
stackProps.minHeight != null ||
|
||||||
|
stackProps.flex != null
|
||||||
|
) {
|
||||||
|
rootLayoutProps.height = stackProps.height ?? '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackComponent {...rootLayoutProps} {...stackProps}>
|
||||||
|
<SegmentContainer direction={direction} segmentKey="header" size={headerSize}>
|
||||||
|
{header}
|
||||||
|
</SegmentContainer>
|
||||||
|
<SegmentContainer direction={direction} segmentKey="body" size={bodySize}>
|
||||||
|
{body}
|
||||||
|
</SegmentContainer>
|
||||||
|
<SegmentContainer direction={direction} segmentKey="footer" size={footerSize}>
|
||||||
|
{footer}
|
||||||
|
</SegmentContainer>
|
||||||
|
</StackComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
compareValues,
|
||||||
|
getColumnKeysFromRows,
|
||||||
|
normalizeColumnDefinition
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
export class GridDataModel {
|
||||||
|
constructor({ rows = [], columns = {}, latency = 0 } = {}) {
|
||||||
|
this.rows = rows;
|
||||||
|
this.columns = columns;
|
||||||
|
this.latency = latency;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryStructure() {
|
||||||
|
const sampleRow = this.rows[0] || {};
|
||||||
|
const inferredFields = getColumnKeysFromRows(this.rows);
|
||||||
|
const fields = inferredFields.length ? inferredFields : Object.keys(this.columns || {});
|
||||||
|
const resolvedColumns = {};
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
resolvedColumns[field] = normalizeColumnDefinition(
|
||||||
|
field,
|
||||||
|
this.columns[field],
|
||||||
|
sampleRow[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { columns: resolvedColumns };
|
||||||
|
}
|
||||||
|
|
||||||
|
filterRows(rows, filterBy = {}) {
|
||||||
|
const filters = Object.entries(filterBy || {}).filter(
|
||||||
|
([, value]) => value !== null && value !== undefined && value !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filters.length) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every(([field, value]) => {
|
||||||
|
if (field === 'search') {
|
||||||
|
const haystack = Object.values(row || {}).join(' ').toLowerCase();
|
||||||
|
return haystack.includes(String(value).trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return String(row?.[field] ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(value).trim().toLowerCase());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortRows(rows, sortBy = []) {
|
||||||
|
const activeSorts = Array.isArray(sortBy)
|
||||||
|
? sortBy.filter((entry) => entry?.field && entry?.direction)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!activeSorts.length) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rows].sort((leftRow, rightRow) => {
|
||||||
|
for (const sort of activeSorts) {
|
||||||
|
const result = compareValues(
|
||||||
|
leftRow?.[sort.field],
|
||||||
|
rightRow?.[sort.field],
|
||||||
|
sort.direction
|
||||||
|
);
|
||||||
|
if (result !== 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryRecords({ offset = 0, page_size = 10, sort_by = [], filter_by = {} } = {}) {
|
||||||
|
const filteredRows = this.filterRows(this.rows, filter_by);
|
||||||
|
const sortedRows = this.sortRows(filteredRows, sort_by);
|
||||||
|
const rows = sortedRows.slice(offset, offset + page_size);
|
||||||
|
|
||||||
|
if (this.latency) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, this.latency));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total: sortedRows.length,
|
||||||
|
offset,
|
||||||
|
page_size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAggregate({ metric, field, filter_by = {} } = {}) {
|
||||||
|
const filteredRows = this.filterRows(this.rows, filter_by);
|
||||||
|
|
||||||
|
if (metric === 'count') {
|
||||||
|
return filteredRows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric === 'sum' && field) {
|
||||||
|
return filteredRows.reduce((sum, row) => sum + (Number(row?.[field]) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAggregates({ metrics = [], filter_by = {} } = {}) {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const metric of metrics) {
|
||||||
|
if (typeof metric === 'string' && metric.startsWith('sum:')) {
|
||||||
|
const field = metric.slice(4);
|
||||||
|
result[metric] = await this.queryAggregate({ metric: 'sum', field, filter_by });
|
||||||
|
} else {
|
||||||
|
result[metric] = await this.queryAggregate({ metric, filter_by });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from '../IconMapper.jsx';
|
||||||
|
import { useGridView } from './context.js';
|
||||||
|
import { formatValueByColumn } from './utils.js';
|
||||||
|
|
||||||
|
function renderToolbarItem(item) {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (React.isValidElement(item)) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'button') {
|
||||||
|
const IconComponent = item.icon ? getIcon(item.icon) : null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.key || item.label}
|
||||||
|
size="$3"
|
||||||
|
theme={item.theme}
|
||||||
|
chromeless={item.chromeless}
|
||||||
|
disabled={item.disabled}
|
||||||
|
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||||
|
onPress={item.onClick || item.onPress}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'text') {
|
||||||
|
return (
|
||||||
|
<Text key={item.key || item.text} color="$color" opacity={0.7}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'search') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
key={item.key || item.placeholder || 'search'}
|
||||||
|
width={item.width || 240}
|
||||||
|
value={item.value}
|
||||||
|
placeholder={item.placeholder || 'Search'}
|
||||||
|
onChangeText={(value) => item.onChange?.(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'node') {
|
||||||
|
return <React.Fragment key={item.key || 'node'}>{item.node}</React.Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <React.Fragment key={item.key || 'item'}>{item}</React.Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultPanelRecordRenderer({ row }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const titleColumn =
|
||||||
|
grid.resolvedColumns.find((column) =>
|
||||||
|
['customer', 'name', 'title', 'label'].includes(column.field)
|
||||||
|
) ||
|
||||||
|
grid.resolvedColumns.find((column) => column.type === 'text') ||
|
||||||
|
grid.resolvedColumns[0];
|
||||||
|
const subtitleColumn = grid.resolvedColumns.find((column) =>
|
||||||
|
['description', 'region', 'owner', 'status'].includes(column.field)
|
||||||
|
);
|
||||||
|
const summaryColumns = grid.resolvedColumns.filter(
|
||||||
|
(column) => ![titleColumn?.field, subtitleColumn?.field, 'description'].includes(column.field)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack gap="$3">
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor">
|
||||||
|
Record Summary
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$6" fontWeight="700">
|
||||||
|
{titleColumn ? row?.[titleColumn.field] : row?.id}
|
||||||
|
</Text>
|
||||||
|
{subtitleColumn ? (
|
||||||
|
<Paragraph color="$color" opacity={0.7}>
|
||||||
|
{row?.[subtitleColumn.field] || ''}
|
||||||
|
</Paragraph>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
|
{summaryColumns.slice(0, 3).map((column) => (
|
||||||
|
<YStack
|
||||||
|
key={`${row.id}-${column.field}-chip`}
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$2"
|
||||||
|
borderRadius="$6"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$accentBorder"
|
||||||
|
>
|
||||||
|
<Text fontSize="$2" color="$color" opacity={0.65}>
|
||||||
|
{column.label}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$4" fontWeight="600">
|
||||||
|
{formatValueByColumn(row?.[column.field], column)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack gap="$3" flexWrap="wrap">
|
||||||
|
{summaryColumns.map((column) => (
|
||||||
|
<YStack
|
||||||
|
key={`${row.id}-${column.field}`}
|
||||||
|
minWidth={160}
|
||||||
|
flex={1}
|
||||||
|
padding="$3"
|
||||||
|
borderRadius="$4"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$borderColor"
|
||||||
|
backgroundColor="$background"
|
||||||
|
gap="$1"
|
||||||
|
>
|
||||||
|
<Text fontSize="$3" color="$color" opacity={0.65}>
|
||||||
|
{column.label}
|
||||||
|
</Text>
|
||||||
|
<Text>{formatValueByColumn(row?.[column.field], column)}</Text>
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelToolBar({ items = [], visible = true }) {
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack gap="$2" alignItems="center" justifyContent="flex-end" flexWrap="wrap">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<React.Fragment key={item?.key || item?.label || item?.text || index}>
|
||||||
|
{renderToolbarItem(item)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelFooterStatusBar({ text, visible = true }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color="$color" opacity={0.7}>
|
||||||
|
{text || grid.statusText}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelHeader({ title, toolbarItems = [], visible = true, showDivider = true }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const RefreshIcon = getIcon('refresh');
|
||||||
|
const CloseIcon = getIcon('close');
|
||||||
|
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap="$3"
|
||||||
|
padding="$3"
|
||||||
|
minHeight={64}
|
||||||
|
borderBottomWidth={showDivider ? 1 : 0}
|
||||||
|
borderBottomColor="$borderColor"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
>
|
||||||
|
<Text fontSize="$6" fontWeight="700" color="$accentColor">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<XStack gap="$2" alignItems="center" flexWrap="wrap" justifyContent="flex-end">
|
||||||
|
<PanelToolBar items={toolbarItems} />
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
||||||
|
onPress={grid.reload}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
disabled={!grid.close}
|
||||||
|
icon={CloseIcon ? <CloseIcon size={16} /> : undefined}
|
||||||
|
onPress={grid.close}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelFooter({ toolbarItems = [], visible = true }) {
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap="$3"
|
||||||
|
padding="$3"
|
||||||
|
minHeight={56}
|
||||||
|
borderTopWidth={1}
|
||||||
|
borderTopColor="$borderColor"
|
||||||
|
backgroundColor="$background"
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
<PanelFooterStatusBar />
|
||||||
|
<PanelToolBar items={toolbarItems} />
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelBodyView({
|
||||||
|
visible = true,
|
||||||
|
recordRenderer: RecordRenderer = DefaultPanelRecordRenderer,
|
||||||
|
columns = 2
|
||||||
|
}) {
|
||||||
|
const grid = useGridView();
|
||||||
|
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.error) {
|
||||||
|
return (
|
||||||
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
|
<Text color="#b91c1c">{grid.error}</Text>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.isLoading && !grid.rows.length) {
|
||||||
|
return (
|
||||||
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
|
<Text color="$color" opacity={0.7}>Loading cards...</Text>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grid.rows.length) {
|
||||||
|
return (
|
||||||
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
|
<Text color="$color" opacity={0.7}>No records available.</Text>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsiveColumns = typeof columns === 'number' ? columns : 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView flex={1}>
|
||||||
|
<YStack padding="$4" gap="$3">
|
||||||
|
<XStack gap="$3" flexWrap="wrap">
|
||||||
|
{grid.rows.map((row) => (
|
||||||
|
<YStack
|
||||||
|
key={row.id}
|
||||||
|
minWidth={responsiveColumns > 1 ? 320 : 240}
|
||||||
|
flex={1}
|
||||||
|
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
|
||||||
|
padding="$4"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'}
|
||||||
|
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
|
||||||
|
borderRadius="$5"
|
||||||
|
gap="$3"
|
||||||
|
>
|
||||||
|
{grid.selectable ? (
|
||||||
|
<XStack justifyContent="flex-start">
|
||||||
|
<Checkbox
|
||||||
|
checked={grid.selectedIds.has(row.id)}
|
||||||
|
onCheckedChange={() => grid.toggleSelectRow(row.id)}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator />
|
||||||
|
</Checkbox>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RecordRenderer row={row} />
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{grid.isLoading ? (
|
||||||
|
<Text color="$color" opacity={0.7}>
|
||||||
|
Refreshing records...
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function createPanelGridViewProps(overrides = {}) {
|
||||||
|
return {
|
||||||
|
direction: 'vertical',
|
||||||
|
headerSize: 4.25,
|
||||||
|
bodySize: 18,
|
||||||
|
footerSize: 3.5,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTableGridViewProps(overrides = {}) {
|
||||||
|
return {
|
||||||
|
direction: 'vertical',
|
||||||
|
headerSize: 3.25,
|
||||||
|
bodySize: 20,
|
||||||
|
footerSize: 3.5,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from '../IconMapper.jsx';
|
||||||
|
import { useGridView } from './context.js';
|
||||||
|
import {
|
||||||
|
formatValueByColumn,
|
||||||
|
getColumnJustify,
|
||||||
|
getColumnLayoutStyle,
|
||||||
|
resolveCellAlignment,
|
||||||
|
resolveCellValue
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
function DefaultGridCellRenderer({ value, column }) {
|
||||||
|
return (
|
||||||
|
<Text width="100%" textAlign={column.align || 'left'} opacity={value == null || value === '' ? 0.6 : 1}>
|
||||||
|
{formatValueByColumn(value, column)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UtilityCell({ rowId }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const ChevronRightIcon = getIcon('chevron-right');
|
||||||
|
|
||||||
|
if (!grid.selectable && !grid.nested) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.nested) {
|
||||||
|
return (
|
||||||
|
<XStack width={36} alignItems="center" justifyContent="center">
|
||||||
|
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack width={36} alignItems="center" justifyContent="center">
|
||||||
|
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}>
|
||||||
|
<Checkbox.Indicator />
|
||||||
|
</Checkbox>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useViewportTracking(enabled = true) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const bodyRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateViewportWidth = () => {
|
||||||
|
const element = bodyRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextWidth = Math.max(0, Math.round(element.getBoundingClientRect().width));
|
||||||
|
grid.setTableViewportWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateViewportWidth();
|
||||||
|
window.addEventListener('resize', updateViewportWidth);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateViewportWidth);
|
||||||
|
};
|
||||||
|
}, [enabled, grid]);
|
||||||
|
|
||||||
|
return bodyRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
alignItems="stretch"
|
||||||
|
borderTopWidth={showTopBorder ? 1 : 0}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderColor="$accentBorder"
|
||||||
|
backgroundColor="$accentSurface"
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
>
|
||||||
|
{grid.selectable || grid.nested ? <XStack width={36} /> : null}
|
||||||
|
{activeColumns.map((column) => {
|
||||||
|
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
||||||
|
const sortLabel =
|
||||||
|
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={column.field}
|
||||||
|
chromeless
|
||||||
|
disabled={!column.sortable}
|
||||||
|
onPress={() => column.sortable && grid.toggleSort(column.field)}
|
||||||
|
justifyContent={getColumnJustify(column.align)}
|
||||||
|
alignItems="center"
|
||||||
|
paddingVertical="$3"
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
{...getColumnLayoutStyle(column)}
|
||||||
|
>
|
||||||
|
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700">
|
||||||
|
{column.label}{sortLabel ? ` ${sortLabel}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBodyView({ visible = true }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const bodyRef = useViewportTracking(visible);
|
||||||
|
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
||||||
|
|
||||||
|
if (grid.error) {
|
||||||
|
return (
|
||||||
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
|
<Text color="#b91c1c">{grid.error}</Text>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView flex={1}>
|
||||||
|
<YStack ref={bodyRef}>
|
||||||
|
{grid.rows.map((row, index) => (
|
||||||
|
<YStack key={row.id ?? index}>
|
||||||
|
<XStack
|
||||||
|
alignItems="stretch"
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
|
||||||
|
>
|
||||||
|
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
|
||||||
|
{activeColumns.map((column) => {
|
||||||
|
const Renderer = column.renderer || DefaultGridCellRenderer;
|
||||||
|
const cellValue = resolveCellValue(row, column);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
key={`${row.id}-${column.field}`}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent={getColumnJustify(resolveCellAlignment(column))}
|
||||||
|
paddingVertical="$3"
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
minHeight={46}
|
||||||
|
{...getColumnLayoutStyle(column)}
|
||||||
|
>
|
||||||
|
<Renderer value={cellValue} row={row} column={column} grid={grid} />
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
<Separator />
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!grid.rows.length && !grid.isLoading ? (
|
||||||
|
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
|
||||||
|
<Text color="$color" opacity={0.7}>No records available.</Text>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{grid.isLoading ? (
|
||||||
|
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4">
|
||||||
|
<Text color="$color" opacity={0.7}>Loading...</Text>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableFooter({ visible = true }) {
|
||||||
|
const grid = useGridView();
|
||||||
|
const FirstPageIcon = getIcon('first-page');
|
||||||
|
const PreviousPageIcon = getIcon('chevron-left');
|
||||||
|
const NextPageIcon = getIcon('chevron-right');
|
||||||
|
const LastPageIcon = getIcon('last-page');
|
||||||
|
|
||||||
|
if (visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap="$3"
|
||||||
|
padding="$3"
|
||||||
|
minHeight={56}
|
||||||
|
borderTopWidth={1}
|
||||||
|
borderTopColor="$borderColor"
|
||||||
|
backgroundColor="$background"
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
<Text color="$color" opacity={0.7}>
|
||||||
|
{grid.total} records
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<XStack gap="$1" alignItems="center" flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
disabled={grid.currentPage <= 1}
|
||||||
|
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => grid.setPage(1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
disabled={grid.currentPage <= 1}
|
||||||
|
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => grid.setPage(grid.currentPage - 1)}
|
||||||
|
/>
|
||||||
|
<Text color="$color" opacity={0.75}>
|
||||||
|
Page {grid.currentPage} of {grid.pageCount}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
disabled={grid.currentPage >= grid.pageCount}
|
||||||
|
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => grid.setPage(grid.currentPage + 1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
disabled={grid.currentPage >= grid.pageCount}
|
||||||
|
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
||||||
|
onPress={() => grid.setPage(grid.pageCount)}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
export function prettyLabel(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const withSpaces = String(value)
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferColumnType(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColumnDefinition(field, columnDefinition = {}, sampleValue) {
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
id: field,
|
||||||
|
label: columnDefinition.label || columnDefinition.display_name || prettyLabel(field),
|
||||||
|
sortable: columnDefinition.sortable ?? true,
|
||||||
|
filterable: columnDefinition.filterable ?? true,
|
||||||
|
align: columnDefinition.align || (inferColumnType(sampleValue) === 'number' ? 'right' : 'left'),
|
||||||
|
width: columnDefinition.width ?? null,
|
||||||
|
type: columnDefinition.type || inferColumnType(sampleValue),
|
||||||
|
format: columnDefinition.format || null,
|
||||||
|
renderer: columnDefinition.renderer || columnDefinition.render || null,
|
||||||
|
currency: columnDefinition.currency || 'USD',
|
||||||
|
priority: columnDefinition.priority || null,
|
||||||
|
alwaysVisible: columnDefinition.alwaysVisible ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColumnDefinitionsInput(input = {}) {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
input
|
||||||
|
.map((column) => {
|
||||||
|
const field = column?.field || column?.id;
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
field,
|
||||||
|
{
|
||||||
|
...column,
|
||||||
|
field,
|
||||||
|
id: field,
|
||||||
|
renderer: column.renderer || column.render || null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(input).map(([field, column]) => [
|
||||||
|
field,
|
||||||
|
{
|
||||||
|
...(column || {}),
|
||||||
|
field: column?.field || column?.id || field,
|
||||||
|
id: column?.id || column?.field || field,
|
||||||
|
renderer: column?.renderer || column?.render || null
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColumnsArray(input = []) {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input
|
||||||
|
.map((column) => {
|
||||||
|
const id = column?.id || column?.field;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
id,
|
||||||
|
field: column.field || id,
|
||||||
|
render: column.render || column.renderer || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
|
return Object.entries(input).map(([field, column]) => ({
|
||||||
|
...(column || {}),
|
||||||
|
id: column?.id || column?.field || field,
|
||||||
|
field: column?.field || column?.id || field,
|
||||||
|
render: column?.render || column?.renderer || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareValues(left, right, direction = 'asc') {
|
||||||
|
if (left === right) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (left === null || left === undefined || left === '') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (right === null || right === undefined || right === '') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftNumber = Number(left);
|
||||||
|
const rightNumber = Number(right);
|
||||||
|
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber);
|
||||||
|
const result = bothNumeric
|
||||||
|
? leftNumber - rightNumber
|
||||||
|
: String(left).localeCompare(String(right), undefined, { sensitivity: 'base' });
|
||||||
|
|
||||||
|
return direction === 'desc' ? -result : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnKeysFromRows(rows = []) {
|
||||||
|
const fields = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
Object.keys(row || {}).forEach((field) => fields.add(field));
|
||||||
|
}
|
||||||
|
return Array.from(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCellValue(row, column) {
|
||||||
|
return row?.[column.field];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCellAlignment(column) {
|
||||||
|
return column.align || 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleColumns(columns = [], viewportWidth = 0) {
|
||||||
|
if (!columns.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let hiddenPriorities = new Set();
|
||||||
|
if (viewportWidth > 0 && viewportWidth < 760) {
|
||||||
|
hiddenPriorities = new Set(['wide', 'mid']);
|
||||||
|
} else if (viewportWidth > 0 && viewportWidth < 980) {
|
||||||
|
hiddenPriorities = new Set(['wide']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = columns.filter((column) => {
|
||||||
|
if (column.alwaysVisible || !column.priority) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !hiddenPriorities.has(column.priority);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.length ? filtered : columns.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areSortEntriesEqual(left = [], right = []) {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.every(
|
||||||
|
(entry, index) =>
|
||||||
|
entry.field === right[index]?.field && entry.direction === right[index]?.direction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnJustify(align = 'left') {
|
||||||
|
if (align === 'right') {
|
||||||
|
return 'flex-end';
|
||||||
|
}
|
||||||
|
if (align === 'center') {
|
||||||
|
return 'center';
|
||||||
|
}
|
||||||
|
return 'flex-start';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnLayoutStyle(column = {}) {
|
||||||
|
const width = column.width;
|
||||||
|
|
||||||
|
if (typeof width === 'number') {
|
||||||
|
if (width > 0 && width <= 1) {
|
||||||
|
return {
|
||||||
|
flex: width,
|
||||||
|
flexBasis: 0,
|
||||||
|
minWidth: column.minWidth || 120
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (width > 1) {
|
||||||
|
return {
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
width: `${width}em`,
|
||||||
|
minWidth: `${width}em`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof width === 'string') {
|
||||||
|
return {
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
width,
|
||||||
|
minWidth: width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
flex: column.flex || 1,
|
||||||
|
flexBasis: 0,
|
||||||
|
minWidth: column.minWidth || 120
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValueByColumn(value, column = {}) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof column.format === 'function') {
|
||||||
|
return column.format(value, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column.type === 'currency') {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: column.currency || 'USD'
|
||||||
|
}).format(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column.type === 'boolean') {
|
||||||
|
return value ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* UI Components Index
|
||||||
|
* Central export point for all UI components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { EmptyShell, default as EmptyShellDefault } from './EmptyShell.jsx';
|
||||||
|
export { LandingShell, LandingShell as TopBarShell, default as LandingShellDefault } from './LandingShell.jsx';
|
||||||
|
export { DashboardShell, default as DashboardShellDefault } from './DashboardShell.jsx';
|
||||||
|
export { ShellProvider, useShell, ShellPlacement, ShellManager, ShellContext, ToastViewport, ToastManager } from './Shell.jsx';
|
||||||
|
export { AppInfo, default as AppInfoDefault } from './AppInfo.jsx';
|
||||||
|
export { TopBar, default as TopBarDefault } from './TopBar.jsx';
|
||||||
|
export { SideBar, default as SideBarDefault } from './SideBar.jsx';
|
||||||
|
export { MenuItemButton, default as MenuItemButtonDefault } from './MenuItemButton.jsx';
|
||||||
|
export { PersonalMenuItem, default as PersonalMenuItemDefault } from './PersonalMenuItem.jsx';
|
||||||
|
export { DirView, default as DirViewDefault } from './DirView.jsx';
|
||||||
|
export { DetView, default as DetViewDefault } from './DetView.jsx';
|
||||||
|
export { FormView, default as FormViewDefault } from './FormView.jsx';
|
||||||
|
export { FormField, default as FormFieldDefault } from './FormField.jsx';
|
||||||
|
export { Router, useRouter, useRoute } from './Router.jsx';
|
||||||
|
export { Page, default as PageDefault } from './Page.jsx';
|
||||||
|
export { ProgressBar, default as ProgressBarDefault } from './ProgressBar.jsx';
|
||||||
|
export { Panel, default as PanelDefault } from './Panel.jsx';
|
||||||
|
export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.jsx';
|
||||||
|
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
|
||||||
|
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
|
||||||
|
export * from './grid/index.js';
|
||||||
|
|
||||||
|
// Re-export App helpers for convenience.
|
||||||
|
// The App component itself is exported from src/index.js and src/ui/App.jsx.
|
||||||
|
export { useApp, useTheme, THEME_MODES } from '../App.jsx';
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* SettingsPage - Settings page component
|
||||||
|
* Derived from Page component
|
||||||
|
* Displays all settings fragment routes as panels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Page } from '../components/Page.jsx';
|
||||||
|
import { YStack, Input, Text } from 'tamagui';
|
||||||
|
import { useRouter, useRoute } from '../components/Router.jsx';
|
||||||
|
import { getRootItem } from '../../platform/menu.js';
|
||||||
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsPage Component
|
||||||
|
* Settings page with icon and title
|
||||||
|
* Queries Router for all routes under /settings and renders them as panels
|
||||||
|
*/
|
||||||
|
export function SettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const securityState = useSecurityState();
|
||||||
|
|
||||||
|
// Initialize search query from fragment path if we navigated via a fragment route
|
||||||
|
const initialSearchQuery = useMemo(() => {
|
||||||
|
if (route.fragment_path) {
|
||||||
|
// Extract the last segment from fragment path (e.g., "general" from "/settings/general")
|
||||||
|
const segments = route.fragment_path.split('/').filter(s => s.length > 0);
|
||||||
|
if (segments.length > 1) {
|
||||||
|
return segments[segments.length - 1]; // Return last segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''; // No fragment, show all
|
||||||
|
}, [route.fragment_path]);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState(initialSearchQuery);
|
||||||
|
|
||||||
|
// Update search query when fragment path changes (e.g., navigating between fragments)
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSearchQuery(initialSearchQuery);
|
||||||
|
}, [initialSearchQuery]);
|
||||||
|
|
||||||
|
// Get the base path for this settings page (flexible - could be /settings, /config, etc.)
|
||||||
|
const basePath = useMemo(() => {
|
||||||
|
// Get current route path and extract the base (e.g., /settings from /settings/general)
|
||||||
|
if (route.path) {
|
||||||
|
// If it's a fragment route, use fragment_path, otherwise use path
|
||||||
|
const currentPath = route.fragment_path || route.path;
|
||||||
|
// Extract base path (first segment)
|
||||||
|
const segments = currentPath.split('/').filter(s => s.length > 0);
|
||||||
|
return segments.length > 0 ? `/${segments[0]}` : '/settings';
|
||||||
|
}
|
||||||
|
return '/settings'; // Default fallback
|
||||||
|
}, [route.path, route.fragment_path]);
|
||||||
|
|
||||||
|
// Query Router for all routes under the base path
|
||||||
|
// Depend on router.currentRoute to trigger recalculation when routes are registered
|
||||||
|
// (currentRoute depends on routesVersion which changes when routes are registered)
|
||||||
|
const childRoutes = useMemo(() => {
|
||||||
|
const allRoutes = router.getRoutes();
|
||||||
|
const settingsRoot = getRootItem('settings');
|
||||||
|
const settingsItems = settingsRoot ? Array.from(settingsRoot.items.values()) : [];
|
||||||
|
const security = {
|
||||||
|
...securityState,
|
||||||
|
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
|
||||||
|
};
|
||||||
|
const routes = [];
|
||||||
|
|
||||||
|
// Find all routes that start with basePath but are not the basePath itself
|
||||||
|
for (const [path, routeData] of allRoutes.entries()) {
|
||||||
|
if (path.startsWith(basePath + '/') && path !== basePath) {
|
||||||
|
// Only include fragment routes
|
||||||
|
if (routeData.is_fragment) {
|
||||||
|
const matchingMenuItem = settingsItems.find((item) => item.invoke_target === path);
|
||||||
|
if (matchingMenuItem && !matchingMenuItem.isRenderable(security)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
routes.push({
|
||||||
|
path,
|
||||||
|
component: routeData.component,
|
||||||
|
options: routeData.options || {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by path for consistent ordering
|
||||||
|
routes.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}, [router, basePath, router.currentRoute, securityState]); // Include router.currentRoute to trigger when routes are registered
|
||||||
|
|
||||||
|
// Filter routes based on search query
|
||||||
|
const filteredRoutes = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return childRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return childRoutes.filter(route => {
|
||||||
|
// Extract the last segment of the path as the route name
|
||||||
|
const segments = route.path.split('/').filter(s => s.length > 0);
|
||||||
|
const routeName = segments[segments.length - 1] || '';
|
||||||
|
return routeName.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [childRoutes, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
icon="settings"
|
||||||
|
title="Settings"
|
||||||
|
headerRight={[
|
||||||
|
// Add buttons or controls here later
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<YStack gap="$4" width="100%">
|
||||||
|
{/* Search bar */}
|
||||||
|
<Input
|
||||||
|
placeholder="Search settings..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
size="$4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Render all child route components */}
|
||||||
|
{filteredRoutes.length > 0 ? (
|
||||||
|
filteredRoutes.map((routeItem) => {
|
||||||
|
const RouteComponent = routeItem.component;
|
||||||
|
return (
|
||||||
|
<RouteComponent key={routeItem.path} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$4" color="$color" opacity={0.6}>
|
||||||
|
{searchQuery ? 'No settings found matching your search.' : 'No settings available.'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy route helper: `React.lazy` with `displayName` and optional named export.
|
||||||
|
* Progress / route-chunk UI belongs on the boot screen or in local Suspense fallbacks — not a second global bar in App.
|
||||||
|
*/
|
||||||
|
export function createLazyRoute(loader, label, exportName = 'default') {
|
||||||
|
const lazyComponent = React.lazy(() =>
|
||||||
|
loader().then((module) => ({
|
||||||
|
default: exportName === 'default'
|
||||||
|
? (module.default || module)
|
||||||
|
: (module[exportName] || module.default)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
lazyComponent.displayName = label;
|
||||||
|
return lazyComponent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
const generalSettingsViews = [];
|
||||||
|
const listeners = new Set();
|
||||||
|
let cachedViews = [];
|
||||||
|
|
||||||
|
function rebuildSnapshot() {
|
||||||
|
cachedViews = [...generalSettingsViews].sort((a, b) => {
|
||||||
|
const orderA = a.order || 0;
|
||||||
|
const orderB = b.order || 0;
|
||||||
|
if (orderA !== orderB) {
|
||||||
|
return orderA - orderB;
|
||||||
|
}
|
||||||
|
return (a.label || a.id || '').localeCompare(b.label || b.id || '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[GeneralSettings] Listener failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishGeneralSettingsView(view) {
|
||||||
|
if (!view || !view.id || !view.label || !view.component) {
|
||||||
|
console.warn('[GeneralSettings] publishGeneralSettingsView() requires id, label, and component');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = generalSettingsViews.findIndex((item) => item.id === view.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
generalSettingsViews[existingIndex] = view;
|
||||||
|
} else {
|
||||||
|
generalSettingsViews.push(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildSnapshot();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retractGeneralSettingsView(viewId) {
|
||||||
|
const nextViews = generalSettingsViews.filter((view) => view.id !== viewId);
|
||||||
|
if (nextViews.length !== generalSettingsViews.length) {
|
||||||
|
generalSettingsViews.length = 0;
|
||||||
|
generalSettingsViews.push(...nextViews);
|
||||||
|
rebuildSnapshot();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGeneralSettingsViews() {
|
||||||
|
return cachedViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToGeneralSettingsViews(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeneralSettingsViews() {
|
||||||
|
return useSyncExternalStore(subscribeToGeneralSettingsViews, getGeneralSettingsViews, getGeneralSettingsViews);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Colorful Theme
|
||||||
|
* Vibrant, colorful design with light and dark variants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config as configBase } from '@tamagui/config/v3';
|
||||||
|
|
||||||
|
export const ColorfulTheme = {
|
||||||
|
...configBase,
|
||||||
|
name: 'colorful',
|
||||||
|
displayName: 'Colorful',
|
||||||
|
tokens: {
|
||||||
|
...configBase.tokens,
|
||||||
|
// Colorful palette (vibrant colors)
|
||||||
|
color: {
|
||||||
|
...configBase.tokens.color,
|
||||||
|
// Primary colors (vibrant blue)
|
||||||
|
primary: '#3b82f6',
|
||||||
|
primaryLight: '#60a5fa',
|
||||||
|
primaryDark: '#2563eb',
|
||||||
|
// Secondary colors (vibrant purple)
|
||||||
|
secondary: '#a855f7',
|
||||||
|
secondaryLight: '#c084fc',
|
||||||
|
secondaryDark: '#9333ea',
|
||||||
|
// Error, warning, info, success (vibrant)
|
||||||
|
error: '#ef4444',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
info: '#06b6d4',
|
||||||
|
success: '#10b981',
|
||||||
|
},
|
||||||
|
// Colorful spacing (8px base unit, generous)
|
||||||
|
space: {
|
||||||
|
...configBase.tokens.space,
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
5: 24,
|
||||||
|
6: 32,
|
||||||
|
7: 48,
|
||||||
|
8: 64,
|
||||||
|
},
|
||||||
|
// Colorful typography (bold, expressive)
|
||||||
|
size: {
|
||||||
|
...configBase.tokens.size,
|
||||||
|
xs: 10,
|
||||||
|
sm: 12,
|
||||||
|
md: 14,
|
||||||
|
base: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 22,
|
||||||
|
'2xl': 26,
|
||||||
|
'3xl': 32,
|
||||||
|
'4xl': 40,
|
||||||
|
'5xl': 52,
|
||||||
|
'6xl': 64,
|
||||||
|
},
|
||||||
|
// Colorful shadows (pronounced)
|
||||||
|
shadowColor: {
|
||||||
|
...configBase.tokens.shadowColor,
|
||||||
|
elevation0: 'transparent',
|
||||||
|
elevation1: 'rgba(0, 0, 0, 0.15)',
|
||||||
|
elevation2: 'rgba(0, 0, 0, 0.18)',
|
||||||
|
elevation4: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
elevation8: 'rgba(0, 0, 0, 0.22)',
|
||||||
|
elevation12: 'rgba(0, 0, 0, 0.24)',
|
||||||
|
elevation16: 'rgba(0, 0, 0, 0.26)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
...configBase.themes,
|
||||||
|
light: {
|
||||||
|
...configBase.themes.light,
|
||||||
|
background: '#ffffff',
|
||||||
|
backgroundHover: '#f1f5f9',
|
||||||
|
backgroundPress: '#e2e8f0',
|
||||||
|
backgroundFocus: '#e0ecff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceVariant: '#f8fafc',
|
||||||
|
accentBackground: '#dbeafe',
|
||||||
|
accentSurface: '#eef4ff',
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
accentBorder: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
accentHover: '#c7ddff',
|
||||||
|
accentPress: '#b4d1ff',
|
||||||
|
color: '#0f172a',
|
||||||
|
colorHover: '#0f172a',
|
||||||
|
colorSecondary: '#475569',
|
||||||
|
colorDisabled: '#94a3b8',
|
||||||
|
borderColor: '#cbd5e1',
|
||||||
|
borderColorHover: '#94a3b8',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...configBase.themes.dark,
|
||||||
|
background: '#0f172a',
|
||||||
|
backgroundHover: '#1e293b',
|
||||||
|
backgroundPress: '#334155',
|
||||||
|
backgroundFocus: '#1a305c',
|
||||||
|
surface: '#1e293b',
|
||||||
|
surfaceVariant: '#334155',
|
||||||
|
accentBackground: '#1d4ed8',
|
||||||
|
accentSurface: '#1e3a8a',
|
||||||
|
accentColor: '#bfdbfe',
|
||||||
|
accentBorder: 'rgba(147, 197, 253, 0.34)',
|
||||||
|
accentHover: '#2346b8',
|
||||||
|
accentPress: '#1f3f9f',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
colorHover: '#f1f5f9',
|
||||||
|
colorSecondary: '#cbd5e1',
|
||||||
|
colorDisabled: '#64748b',
|
||||||
|
borderColor: '#334155',
|
||||||
|
borderColorHover: '#475569',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
...configBase.settings,
|
||||||
|
styleCompat: 'web',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Material Theme
|
||||||
|
* Material Design-inspired theme with light and dark variants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config as configBase } from '@tamagui/config/v3';
|
||||||
|
|
||||||
|
export const MaterialTheme = {
|
||||||
|
...configBase,
|
||||||
|
name: 'material',
|
||||||
|
displayName: 'Material Design',
|
||||||
|
tokens: {
|
||||||
|
...configBase.tokens,
|
||||||
|
// Material UI color palette (base tokens - theme-agnostic)
|
||||||
|
color: {
|
||||||
|
...configBase.tokens.color,
|
||||||
|
// Primary colors (Material Blue)
|
||||||
|
primary: '#1976d2',
|
||||||
|
primaryLight: '#42a5f5',
|
||||||
|
primaryDark: '#1565c0',
|
||||||
|
// Secondary colors (Material Pink)
|
||||||
|
secondary: '#c2185b',
|
||||||
|
secondaryLight: '#f48fb1',
|
||||||
|
secondaryDark: '#880e4f',
|
||||||
|
// Error, warning, info, success
|
||||||
|
error: '#d32f2f',
|
||||||
|
warning: '#ed6c02',
|
||||||
|
info: '#0288d1',
|
||||||
|
success: '#2e7d32',
|
||||||
|
},
|
||||||
|
// Material UI spacing (8px base unit)
|
||||||
|
space: {
|
||||||
|
...configBase.tokens.space,
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
5: 24,
|
||||||
|
6: 32,
|
||||||
|
7: 48,
|
||||||
|
8: 64,
|
||||||
|
},
|
||||||
|
// Material UI typography scale
|
||||||
|
size: {
|
||||||
|
...configBase.tokens.size,
|
||||||
|
xs: 10,
|
||||||
|
sm: 12,
|
||||||
|
md: 14,
|
||||||
|
base: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
'2xl': 24,
|
||||||
|
'3xl': 30,
|
||||||
|
'4xl': 34,
|
||||||
|
'5xl': 48,
|
||||||
|
'6xl': 60,
|
||||||
|
},
|
||||||
|
// Material UI elevation shadows
|
||||||
|
shadowColor: {
|
||||||
|
...configBase.tokens.shadowColor,
|
||||||
|
elevation0: 'transparent',
|
||||||
|
elevation1: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
elevation2: 'rgba(0, 0, 0, 0.14)',
|
||||||
|
elevation4: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
elevation8: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
elevation12: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
elevation16: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
...configBase.themes,
|
||||||
|
light: {
|
||||||
|
...configBase.themes.light,
|
||||||
|
background: '#ffffff',
|
||||||
|
backgroundHover: '#f5f5f5',
|
||||||
|
backgroundPress: '#eeeeee',
|
||||||
|
backgroundFocus: '#e9f2fd',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceVariant: '#f5f5f5',
|
||||||
|
accentBackground: '#e3f2fd',
|
||||||
|
accentSurface: '#f4f8ff',
|
||||||
|
accentColor: '#1565c0',
|
||||||
|
accentBorder: 'rgba(25, 118, 210, 0.28)',
|
||||||
|
accentHover: '#d8ebfd',
|
||||||
|
accentPress: '#c8e2fb',
|
||||||
|
color: 'rgba(0, 0, 0, 0.87)',
|
||||||
|
colorHover: 'rgba(0, 0, 0, 0.87)',
|
||||||
|
colorSecondary: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
colorDisabled: 'rgba(0, 0, 0, 0.38)',
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
borderColorHover: 'rgba(0, 0, 0, 0.23)',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...configBase.themes.dark,
|
||||||
|
background: '#121212',
|
||||||
|
backgroundHover: '#1e1e1e',
|
||||||
|
backgroundPress: '#2c2c2c',
|
||||||
|
backgroundFocus: '#183148',
|
||||||
|
surface: '#1e1e1e',
|
||||||
|
surfaceVariant: '#2c2c2c',
|
||||||
|
accentBackground: '#17324d',
|
||||||
|
accentSurface: '#1b3c5b',
|
||||||
|
accentColor: '#90caf9',
|
||||||
|
accentBorder: 'rgba(144, 202, 249, 0.32)',
|
||||||
|
accentHover: '#204566',
|
||||||
|
accentPress: '#295373',
|
||||||
|
color: 'rgba(255, 255, 255, 0.87)',
|
||||||
|
colorHover: 'rgba(255, 255, 255, 0.87)',
|
||||||
|
colorSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
colorDisabled: 'rgba(255, 255, 255, 0.38)',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
borderColorHover: 'rgba(255, 255, 255, 0.23)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
...configBase.settings,
|
||||||
|
styleCompat: 'web',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialTheme;
|
||||||
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Minimal Theme
|
||||||
|
* Clean, minimal design with light and dark variants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config as configBase } from '@tamagui/config/v3';
|
||||||
|
|
||||||
|
export const MinimalTheme = {
|
||||||
|
...configBase,
|
||||||
|
name: 'minimal',
|
||||||
|
displayName: 'Minimal',
|
||||||
|
tokens: {
|
||||||
|
...configBase.tokens,
|
||||||
|
// Minimal color palette (neutral, monochromatic)
|
||||||
|
color: {
|
||||||
|
...configBase.tokens.color,
|
||||||
|
// Primary colors (neutral gray-blue)
|
||||||
|
primary: '#4a5568',
|
||||||
|
primaryLight: '#718096',
|
||||||
|
primaryDark: '#2d3748',
|
||||||
|
// Secondary colors (subtle accent)
|
||||||
|
secondary: '#667eea',
|
||||||
|
secondaryLight: '#818cf8',
|
||||||
|
secondaryDark: '#4f46e5',
|
||||||
|
// Error, warning, info, success (muted)
|
||||||
|
error: '#e53e3e',
|
||||||
|
warning: '#dd6b20',
|
||||||
|
info: '#3182ce',
|
||||||
|
success: '#38a169',
|
||||||
|
},
|
||||||
|
// Minimal spacing (8px base unit, tighter)
|
||||||
|
space: {
|
||||||
|
...configBase.tokens.space,
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
5: 20,
|
||||||
|
6: 24,
|
||||||
|
7: 32,
|
||||||
|
8: 48,
|
||||||
|
},
|
||||||
|
// Minimal typography (clean, readable)
|
||||||
|
size: {
|
||||||
|
...configBase.tokens.size,
|
||||||
|
xs: 11,
|
||||||
|
sm: 13,
|
||||||
|
md: 15,
|
||||||
|
base: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
'2xl': 22,
|
||||||
|
'3xl': 26,
|
||||||
|
'4xl': 32,
|
||||||
|
'5xl': 40,
|
||||||
|
'6xl': 48,
|
||||||
|
},
|
||||||
|
// Minimal shadows (subtle)
|
||||||
|
shadowColor: {
|
||||||
|
...configBase.tokens.shadowColor,
|
||||||
|
elevation0: 'transparent',
|
||||||
|
elevation1: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
elevation2: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
elevation4: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
elevation8: 'rgba(0, 0, 0, 0.14)',
|
||||||
|
elevation12: 'rgba(0, 0, 0, 0.16)',
|
||||||
|
elevation16: 'rgba(0, 0, 0, 0.18)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
...configBase.themes,
|
||||||
|
light: {
|
||||||
|
...configBase.themes.light,
|
||||||
|
background: '#fafafa',
|
||||||
|
backgroundHover: '#f0f0f0',
|
||||||
|
backgroundPress: '#e8e8e8',
|
||||||
|
backgroundFocus: '#e9edf6',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceVariant: '#f5f5f5',
|
||||||
|
accentBackground: '#eceff4',
|
||||||
|
accentSurface: '#f5f7fb',
|
||||||
|
accentColor: '#4f46e5',
|
||||||
|
accentBorder: 'rgba(79, 70, 229, 0.24)',
|
||||||
|
accentHover: '#e4e8f3',
|
||||||
|
accentPress: '#d8deef',
|
||||||
|
color: '#1a202c',
|
||||||
|
colorHover: '#1a202c',
|
||||||
|
colorSecondary: '#4a5568',
|
||||||
|
colorDisabled: '#a0aec0',
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
borderColorHover: '#cbd5e0',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...configBase.themes.dark,
|
||||||
|
background: '#0f1419',
|
||||||
|
backgroundHover: '#1a202c',
|
||||||
|
backgroundPress: '#2d3748',
|
||||||
|
backgroundFocus: '#223049',
|
||||||
|
surface: '#1a202c',
|
||||||
|
surfaceVariant: '#2d3748',
|
||||||
|
accentBackground: '#2c3650',
|
||||||
|
accentSurface: '#333f5d',
|
||||||
|
accentColor: '#c7d2fe',
|
||||||
|
accentBorder: 'rgba(129, 140, 248, 0.28)',
|
||||||
|
accentHover: '#354461',
|
||||||
|
accentPress: '#3c4c6d',
|
||||||
|
color: '#f7fafc',
|
||||||
|
colorHover: '#f7fafc',
|
||||||
|
colorSecondary: '#cbd5e0',
|
||||||
|
colorDisabled: '#718096',
|
||||||
|
borderColor: '#2d3748',
|
||||||
|
borderColorHover: '#4a5568',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
...configBase.settings,
|
||||||
|
styleCompat: 'web',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Style Themes Index
|
||||||
|
* Exports all available style themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MaterialTheme } from './MaterialTheme.js';
|
||||||
|
import { MinimalTheme } from './MinimalTheme.js';
|
||||||
|
import { ColorfulTheme } from './ColorfulTheme.js';
|
||||||
|
|
||||||
|
export const STYLE_THEMES = {
|
||||||
|
material: MaterialTheme,
|
||||||
|
minimal: MinimalTheme,
|
||||||
|
colorful: ColorfulTheme,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_STYLE_THEME = 'material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
|
||||||
|
* Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}.
|
||||||
|
* @param {string} [name]
|
||||||
|
* @returns {keyof typeof STYLE_THEMES}
|
||||||
|
*/
|
||||||
|
export function normalizeStyleThemeName(name) {
|
||||||
|
const key = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||||
|
if (key && STYLE_THEMES[key]) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return DEFAULT_STYLE_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a style theme by name
|
||||||
|
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful')
|
||||||
|
* @returns {Object} Theme configuration
|
||||||
|
*/
|
||||||
|
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||||
|
const key = normalizeStyleThemeName(themeName);
|
||||||
|
return STYLE_THEMES[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available style theme names
|
||||||
|
* @returns {string[]} Array of theme names
|
||||||
|
*/
|
||||||
|
export function getStyleThemeNames() {
|
||||||
|
return Object.keys(STYLE_THEMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MaterialTheme, MinimalTheme, ColorfulTheme };
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* Main Styles */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add your styles here */
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { getConfig, setConfig } from '../platform/env.js';
|
||||||
|
import { getSystemThemeMode } from '../platform/compat.js';
|
||||||
|
import { DEFAULT_STYLE_THEME, normalizeStyleThemeName } from './styles/index.js';
|
||||||
|
|
||||||
|
export const THEME_MODE_CONFIG_KEY = 'theme.mode';
|
||||||
|
export const THEME_NAME_CONFIG_KEY = 'theme.name';
|
||||||
|
|
||||||
|
export const THEME_MODES = {
|
||||||
|
LIGHT: 'light',
|
||||||
|
DARK: 'dark',
|
||||||
|
SYSTEM: 'system'
|
||||||
|
};
|
||||||
|
|
||||||
|
class ThemeController {
|
||||||
|
constructor() {
|
||||||
|
this._themeMode = THEME_MODES.SYSTEM;
|
||||||
|
this._systemScheme = getSystemThemeMode();
|
||||||
|
this._activeTheme = this._themeMode === THEME_MODES.SYSTEM ? this._systemScheme : this._themeMode;
|
||||||
|
this._styleThemeName = DEFAULT_STYLE_THEME;
|
||||||
|
this._listeners = new Set();
|
||||||
|
this._setThemeModeState = null;
|
||||||
|
this._setSystemScheme = null;
|
||||||
|
this._setStyleThemeName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(setThemeModeState, setSystemScheme, setStyleThemeName) {
|
||||||
|
this._setThemeModeState = setThemeModeState;
|
||||||
|
this._setSystemScheme = setSystemScheme;
|
||||||
|
this._setStyleThemeName = setStyleThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(themeMode, systemScheme, activeTheme, styleThemeName = this._styleThemeName) {
|
||||||
|
this._themeMode = themeMode;
|
||||||
|
this._systemScheme = systemScheme;
|
||||||
|
this._activeTheme = activeTheme;
|
||||||
|
this._styleThemeName = styleThemeName;
|
||||||
|
this._notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyListeners() {
|
||||||
|
this._listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener({
|
||||||
|
themeMode: this._themeMode,
|
||||||
|
activeTheme: this._activeTheme,
|
||||||
|
systemScheme: this._systemScheme,
|
||||||
|
styleThemeName: this._styleThemeName
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ThemeManager] Listener error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMode() {
|
||||||
|
return this._themeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveTheme() {
|
||||||
|
return this._activeTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemScheme() {
|
||||||
|
return this._systemScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
getModes() {
|
||||||
|
return { ...THEME_MODES };
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyleThemeName() {
|
||||||
|
return this._styleThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMode(mode) {
|
||||||
|
if (!Object.values(THEME_MODES).includes(mode)) {
|
||||||
|
console.warn('[ThemeManager] Invalid theme mode:', mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._setThemeModeState) {
|
||||||
|
this._setThemeModeState(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setConfig(THEME_MODE_CONFIG_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ThemeManager] Failed to save theme preference:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStyleTheme(styleThemeName) {
|
||||||
|
const resolved = normalizeStyleThemeName(styleThemeName);
|
||||||
|
if (this._setStyleThemeName) {
|
||||||
|
this._setStyleThemeName(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setConfig(THEME_NAME_CONFIG_KEY, resolved);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ThemeManager] Failed to save style theme preference:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
const nextMode =
|
||||||
|
this._themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK :
|
||||||
|
this._themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM :
|
||||||
|
THEME_MODES.LIGHT;
|
||||||
|
|
||||||
|
this.setMode(nextMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener) {
|
||||||
|
this._listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this._listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeManager = new ThemeController();
|
||||||
|
export default themeManager;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Tests for platform/env.js
|
||||||
|
* Uses Node.js built-in test runner (node --test)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import {
|
||||||
|
initEnv,
|
||||||
|
getConfig,
|
||||||
|
setConfig,
|
||||||
|
getConfigDict,
|
||||||
|
isDevelopment,
|
||||||
|
isProduction,
|
||||||
|
CONFIG_KEYS
|
||||||
|
} from '../src/platform/env.js';
|
||||||
|
|
||||||
|
describe('env.js', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset config before each test
|
||||||
|
initEnv({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initEnv', () => {
|
||||||
|
test('should initialize config with provided values', () => {
|
||||||
|
const appConfig = {
|
||||||
|
name: 'TestApp',
|
||||||
|
displayName: 'Test Application',
|
||||||
|
brandLogo: '/logo.png',
|
||||||
|
ui_shell: 'DashboardShell',
|
||||||
|
storage: { backend: 'indexedDB' },
|
||||||
|
api: { baseURL: '/api/v1' },
|
||||||
|
modules: ['core', 'dummy']
|
||||||
|
};
|
||||||
|
|
||||||
|
initEnv(appConfig);
|
||||||
|
|
||||||
|
const config = getConfigDict();
|
||||||
|
assert.strictEqual(config.APP_NAME, 'TestApp');
|
||||||
|
assert.strictEqual(config.APP_DISPLAY_NAME, 'Test Application');
|
||||||
|
assert.strictEqual(config.BRAND_LOGO, '/logo.png');
|
||||||
|
assert.strictEqual(config.UI_SHELL, 'DashboardShell');
|
||||||
|
assert.strictEqual(config.STORAGE_BACKEND, 'indexedDB');
|
||||||
|
assert.strictEqual(config.API_BASE_URL, '/api/v1');
|
||||||
|
assert.deepStrictEqual(config.MODULES, ['core', 'dummy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use defaults when values are missing', () => {
|
||||||
|
const appConfig = {
|
||||||
|
name: 'TestApp'
|
||||||
|
};
|
||||||
|
|
||||||
|
initEnv(appConfig);
|
||||||
|
|
||||||
|
const config = getConfigDict();
|
||||||
|
assert.strictEqual(config.APP_NAME, 'TestApp');
|
||||||
|
assert.strictEqual(config.APP_DISPLAY_NAME, 'TestApp'); // Falls back to name
|
||||||
|
assert.strictEqual(config.BRAND_LOGO, '/favicon.svg'); // Default
|
||||||
|
assert.strictEqual(config.UI_SHELL, 'EmptyShell'); // Default
|
||||||
|
assert.strictEqual(config.STORAGE_BACKEND, 'localStorage'); // Default
|
||||||
|
assert.strictEqual(config.API_BASE_URL, '/api'); // Default
|
||||||
|
assert.deepStrictEqual(config.MODULES, []); // Default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initEnv({
|
||||||
|
name: 'TestApp',
|
||||||
|
displayName: 'Test Display Name'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return config value from dictionary', async () => {
|
||||||
|
const value = await getConfig(CONFIG_KEYS.APP_NAME);
|
||||||
|
assert.strictEqual(value, 'TestApp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return altValue when key not found', async () => {
|
||||||
|
// Note: getConfig checks import.meta.env which may not be available in Node
|
||||||
|
// This test verifies the fallback behavior
|
||||||
|
const value = await getConfig('NON_EXISTENT_KEY', 'default');
|
||||||
|
assert.strictEqual(value, 'default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null when key not found and no altValue', async () => {
|
||||||
|
// Note: getConfig checks import.meta.env which may not be available in Node
|
||||||
|
// This test verifies the fallback behavior
|
||||||
|
const value = await getConfig('NON_EXISTENT_KEY');
|
||||||
|
// May return null or undefined depending on import.meta.env availability
|
||||||
|
assert.ok(value === null || value === undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initEnv({
|
||||||
|
name: 'TestApp'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update existing config key in dictionary', async () => {
|
||||||
|
await setConfig(CONFIG_KEYS.APP_NAME, 'UpdatedApp');
|
||||||
|
const value = await getConfig(CONFIG_KEYS.APP_NAME);
|
||||||
|
assert.strictEqual(value, 'UpdatedApp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle new keys (may attempt storage)', async () => {
|
||||||
|
// Since it's a new key, it should attempt to store in storage
|
||||||
|
// We just verify it doesn't throw
|
||||||
|
await assert.doesNotReject(async () => {
|
||||||
|
await setConfig('custom.key', 'customValue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConfigDict', () => {
|
||||||
|
test('should return a copy of config dictionary', () => {
|
||||||
|
initEnv({
|
||||||
|
name: 'TestApp',
|
||||||
|
displayName: 'Test Display'
|
||||||
|
});
|
||||||
|
|
||||||
|
const config1 = getConfigDict();
|
||||||
|
const config2 = getConfigDict();
|
||||||
|
|
||||||
|
// Should be equal but not the same object (copy)
|
||||||
|
assert.deepStrictEqual(config1, config2);
|
||||||
|
assert.notStrictEqual(config1, config2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return config with defaults when initialized with empty object', () => {
|
||||||
|
initEnv({});
|
||||||
|
const config = getConfigDict();
|
||||||
|
// initEnv sets defaults even with empty object
|
||||||
|
assert.ok('APP_NAME' in config);
|
||||||
|
assert.ok('BRAND_LOGO' in config);
|
||||||
|
assert.strictEqual(config.BRAND_LOGO, '/favicon.svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CONFIG_KEYS', () => {
|
||||||
|
test('should export all expected config keys', () => {
|
||||||
|
assert.ok('APP_NAME' in CONFIG_KEYS);
|
||||||
|
assert.ok('APP_DISPLAY_NAME' in CONFIG_KEYS);
|
||||||
|
assert.ok('BRAND_LOGO' in CONFIG_KEYS);
|
||||||
|
assert.ok('UI_SHELL' in CONFIG_KEYS);
|
||||||
|
assert.ok('STORAGE_BACKEND' in CONFIG_KEYS);
|
||||||
|
assert.ok('API_BASE_URL' in CONFIG_KEYS);
|
||||||
|
assert.ok('MODULES' in CONFIG_KEYS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDevelopment and isProduction', () => {
|
||||||
|
test('should be functions', () => {
|
||||||
|
assert.strictEqual(typeof isDevelopment, 'function');
|
||||||
|
assert.strictEqual(typeof isProduction, 'function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { GridDataModel } from '../src/ui/components/grid/model.js';
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ id: 1, name: 'Northwind', status: 'open', total: 1200 },
|
||||||
|
{ id: 2, name: 'Blue Harbor', status: 'review', total: 800 },
|
||||||
|
{ id: 3, name: 'Summit', status: 'open', total: 2400 },
|
||||||
|
{ id: 4, name: 'Lattice', status: 'closed', total: 400 }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GridDataModel', () => {
|
||||||
|
test('queryStructure infers columns from row data', async () => {
|
||||||
|
const model = new GridDataModel({ rows });
|
||||||
|
const result = await model.queryStructure();
|
||||||
|
|
||||||
|
assert.ok(result.columns.name);
|
||||||
|
assert.ok(result.columns.status);
|
||||||
|
assert.ok(result.columns.total);
|
||||||
|
assert.strictEqual(result.columns.total.align, 'right');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryRecords filters, sorts, and paginates', async () => {
|
||||||
|
const model = new GridDataModel({ rows });
|
||||||
|
const result = await model.queryRecords({
|
||||||
|
filter_by: { status: 'open' },
|
||||||
|
sort_by: [{ field: 'total', direction: 'desc' }],
|
||||||
|
offset: 0,
|
||||||
|
page_size: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.total, 2);
|
||||||
|
assert.strictEqual(result.rows.length, 1);
|
||||||
|
assert.strictEqual(result.rows[0].name, 'Summit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryRecords supports text search through search filter', async () => {
|
||||||
|
const model = new GridDataModel({ rows });
|
||||||
|
const result = await model.queryRecords({
|
||||||
|
filter_by: { search: 'harbor' }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.total, 1);
|
||||||
|
assert.strictEqual(result.rows[0].name, 'Blue Harbor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryAggregates supports count and sum metrics', async () => {
|
||||||
|
const model = new GridDataModel({ rows });
|
||||||
|
const result = await model.queryAggregates({
|
||||||
|
metrics: ['count', 'sum:total'],
|
||||||
|
filter_by: { status: 'open' }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.count, 2);
|
||||||
|
assert.strictEqual(result['sum:total'], 3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"paths": {
|
||||||
|
"@platform/*": ["./src/platform/*"],
|
||||||
|
"@ui/*": ["./src/ui/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import dts from 'vite-plugin-dts';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** Keep peer/runtime deps out of published dist (including subpaths like @tamagui/config/v3). */
|
||||||
|
function isExternal(id) {
|
||||||
|
if (id === 'react' || id === 'react/jsx-runtime' || id === 'react-dom' || id === 'react-dom/client') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (id === 'tamagui' || id.startsWith('tamagui/')) return true;
|
||||||
|
if (id.startsWith('@tamagui/')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
tsconfigPath: './tsconfig.json',
|
||||||
|
insertTypesEntry: true,
|
||||||
|
include: ['src/**/*.js', 'src/**/*.jsx'],
|
||||||
|
exclude: ['**/*.test.*', '**/test/**']
|
||||||
|
})
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/index.js'),
|
||||||
|
formats: ['es']
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: isExternal,
|
||||||
|
output: {
|
||||||
|
// Preserve module structure - automatically preserves directory structure
|
||||||
|
preserveModules: true,
|
||||||
|
preserveModulesRoot: 'src',
|
||||||
|
// Use original file names and paths
|
||||||
|
entryFileNames: '[name].js',
|
||||||
|
chunkFileNames: '[name].js'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user