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:
Amer Agovic
2026-04-18 10:43:52 -05:00
commit 94a9f32969
87 changed files with 19750 additions and 0 deletions
Vendored
+44
View File
@@ -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
+343
View File
@@ -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 Nodes 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.
+4412
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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"
}
}
+62
View File
@@ -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;
+220
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export { DataModel, default as DataModelDefault } from './DataModel.js';
export { InMemoryDataModel, default as InMemoryDataModelDefault } from './InMemoryDataModel.js';
+22
View File
@@ -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';
+133
View File
@@ -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();
+731
View File
@@ -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);
};
}
+487
View File
@@ -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();
}
+43
View File
@@ -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
};
+1301
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -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 };
+233
View File
@@ -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');
}
}
}
+6
View File
@@ -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';
+10
View File
@@ -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();
}
}
+14
View File
@@ -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;
}
}
+9
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
}
+12
View File
@@ -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';
}
}
+15
View File
@@ -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;
}
}
+15
View File
@@ -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';
+57
View File
@@ -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;
}
+17
View File
@@ -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;
+270
View File
@@ -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;
+66
View File
@@ -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;
+165
View File
@@ -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;
+155
View File
@@ -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;
+5
View File
@@ -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';
+533
View File
@@ -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: []
};
}
}
+51
View File
@@ -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: []
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export { SecurityPolicy } from './SecurityPolicy.js';
export { BasicSecurityPolicy } from './BasicSecurityPolicy.js';
export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js';
+58
View File
@@ -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);
}
+12
View File
@@ -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;
};
}
+42
View File
@@ -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'
};
}
+371
View File
@@ -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
View File
@@ -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)
+72
View File
@@ -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;
+29
View File
@@ -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;
+27
View File
@@ -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;
+421
View File
@@ -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;
+292
View File
@@ -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;
+283
View File
@@ -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;
+145
View File
@@ -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;
+49
View File
@@ -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;
+324
View File
@@ -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;
+35
View File
@@ -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;
+31
View File
@@ -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;
+609
View File
@@ -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;
+110
View File
@@ -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;
+198
View File
@@ -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;
+82
View File
@@ -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;
+190
View File
@@ -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;
+269
View File
@@ -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
+359
View File
@@ -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;
+687
View File
@@ -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
+495
View File
@@ -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;
+142
View File
@@ -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;
+417
View File
@@ -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;
+290
View File
@@ -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;
+110
View File
@@ -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.
+12
View File
@@ -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;
}
+13
View File
@@ -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';
+115
View File
@@ -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>
);
}
+123
View File
@@ -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;
}
}
+313
View File
@@ -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>
);
}
+20
View File
@@ -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
};
}
+256
View File
@@ -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>
);
}
+259
View File
@@ -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);
}
+30
View File
@@ -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';
+143
View File
@@ -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;
+18
View File
@@ -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;
}
+66
View File
@@ -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);
}
+121
View File
@@ -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',
},
};
+123
View File
@@ -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;
+121
View File
@@ -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',
},
};
+51
View File
@@ -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 };
+21
View File
@@ -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 */
+123
View File
@@ -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;
+161
View File
@@ -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');
});
});
});
+57
View File
@@ -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);
});
});
+28
View File
@@ -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"]
}
+50
View File
@@ -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
}
});