Update bface UI and security work

This commit is contained in:
Amer Agovic
2026-05-31 12:30:02 -05:00
parent 6fe23fae86
commit c6f7240912
45 changed files with 4531 additions and 553 deletions
+54 -13
View File
@@ -126,6 +126,21 @@ function App({
setActiveStyleThemeName(styleThemeName);
}, [styleThemeName]);
useEffect(() => {
const resolvedDocumentBackground =
styleTheme?.themes?.[activeTheme]?.bgPage
|| styleTheme?.themes?.[activeTheme]?.background
|| null;
if (resolvedDocumentBackground) {
envModuleRef.setDocumentBackground(resolvedDocumentBackground);
}
if (typeof document !== 'undefined') {
document.documentElement.style.colorScheme = activeTheme === THEME_MODES.DARK ? 'dark' : 'light';
}
}, [activeTheme, styleTheme]);
// Load theme preferences from storage on mount
useEffect(() => {
if (initialThemePreferencesLoaded) {
@@ -232,11 +247,18 @@ function App({
const servicesTrace = startTrace('App', 'getPlatformServices');
const services = await getPlatformServices();
servicesTrace.end();
securityService.installAPIClient(services.api_client);
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);
const initialSecurityInitPromise = securityService.init(initialSecurityConfig)
.then((state) => {
securityTrace.end({ enabled: initialSecurityConfig.enabled === true });
return state;
})
.catch((error) => {
securityTrace.fail(error);
throw error;
});
// Call onInit callback if provided (handles profile, env, modules, SW)
let selectedProfile = null;
@@ -247,11 +269,14 @@ function App({
}
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 });
}
const finalizeSecurityInit = async () => {
await initialSecurityInitPromise;
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);
@@ -294,6 +319,9 @@ function App({
setSwStatus(await sw.getServiceWorkerStatus());
setInitialized(true);
finalizeSecurityInit().catch((error) => {
console.error('[Security] Background initialization failed:', error);
});
initTrace.end({
shell: shellName,
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
@@ -350,10 +378,26 @@ function App({
updateAccountProfile: (patch) => securityService.updateAccountProfile(patch),
changePassword: (passwordInput) => securityService.changePassword(passwordInput),
listUsers: () => securityService.listUsers(),
createUser: (userData) => securityService.createUser(userData),
updateUser: (userId, patch) => securityService.updateUser(userId, patch),
deleteUser: (userId) => securityService.deleteUser(userId),
listRoles: () => securityService.listRoles(),
listSubjects: () => securityService.listSubjects(),
createRole: (roleData) => securityService.createRole(roleData),
updateRole: (roleId, patch) => securityService.updateRole(roleId, patch),
deleteRole: (roleId) => securityService.deleteRole(roleId),
listRealms: () => securityService.listRealms(),
createRealm: (realmData) => securityService.createRealm(realmData),
updateRealm: (realmId, patch) => securityService.updateRealm(realmId, patch),
deleteRealm: (realmId) => securityService.deleteRealm(realmId),
listResources: () => securityService.listResources(),
listPermits: () => securityService.listPermits()
createResource: (resource) => securityService.createResource(resource),
updateResource: (path, patch) => securityService.updateResource(path, patch),
deleteResource: (path) => securityService.deleteResource(path),
listPermits: () => securityService.listPermits(),
createPermit: (permit) => securityService.createPermit(permit),
updatePermit: (permitId, patch) => securityService.updatePermit(permitId, patch),
deletePermit: (permitId) => securityService.deletePermit(permitId)
},
system: {
locale: envModuleRef.getLocaleSync(),
@@ -366,7 +410,6 @@ function App({
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;
@@ -395,9 +438,7 @@ function App({
}
}
if (shouldRenderLoginGate) {
appContent = <LoginPage />;
} else if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
appContent = (
<Router initialPath={initialRoute}>
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
+131
View File
@@ -0,0 +1,131 @@
import React from 'react';
import { Checkbox, Text, XStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
function toEntries(codeMap = {}, labels = null) {
return Object.entries(codeMap).map(([key, code]) => ({
key,
code,
label: labels?.[key] || key
}));
}
export function normalizeCodedValue(codeMap = {}, value = 0) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.reduce((mask, key) => mask | (codeMap[key] || 0), 0);
}
if (Array.isArray(value)) {
return value.reduce((mask, key) => mask | (codeMap[key] || 0), 0);
}
if (value && typeof value === 'object') {
return Object.keys(codeMap).reduce((mask, key) => (value[key] ? mask | codeMap[key] : mask), 0);
}
return 0;
}
export function codedValueToArray(codeMap = {}, value = 0) {
const mask = normalizeCodedValue(codeMap, value);
return toEntries(codeMap)
.filter((entry) => (mask & entry.code) !== 0)
.map((entry) => entry.key);
}
export function formatCodedValue(codeMap = {}, value = 0, labels = null) {
const selected = new Set(codedValueToArray(codeMap, value));
return toEntries(codeMap, labels)
.filter((entry) => selected.has(entry.key))
.map((entry) => entry.label);
}
export function CodedCheckboxGroup({
codeMap = {},
labels = null,
value = 0,
onChange,
disabled = false
}) {
const mask = normalizeCodedValue(codeMap, value);
const entries = toEntries(codeMap, labels);
const CheckIcon = getIcon('check');
return (
<XStack flexWrap="wrap" gap="$3">
{entries.map((entry) => {
const checked = (mask & entry.code) !== 0;
return (
<XStack key={entry.key} gap="$2" alignItems="center">
<Checkbox
checked={checked}
disabled={disabled}
borderColor="$lineStrong"
backgroundColor="$bgPanel"
focusStyle={{ borderColor: '$accent' }}
onCheckedChange={(nextChecked) => {
const nextMask = nextChecked === true ? (mask | entry.code) : (mask & ~entry.code);
onChange?.(nextMask);
}}
>
<Checkbox.Indicator>
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
</Checkbox.Indicator>
</Checkbox>
<Text>{entry.label}</Text>
</XStack>
);
})}
</XStack>
);
}
export function CodedValueBadges({
codeMap = {},
labels = null,
value = 0,
compact = false
}) {
const items = formatCodedValue(codeMap, value, labels);
if (compact) {
return (
<XStack flexWrap="nowrap" gap="$1.5" alignItems="center">
{items.map((item) => (
<XStack
key={item}
minWidth={20}
justifyContent="center"
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius="$radiusMd"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor="$bgPage"
>
<Text fontSize="$1" fontWeight="700" color="$textPrimary">{item}</Text>
</XStack>
))}
</XStack>
);
}
return (
<XStack flexWrap="wrap" gap="$2">
{items.map((item) => (
<XStack
key={item}
paddingHorizontal="$2.5"
paddingVertical="$1"
borderRadius="$radiusLg"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor="$bgPage"
>
<Text fontSize="$2" color="$textPrimary">{item}</Text>
</XStack>
))}
</XStack>
);
}
+43 -73
View File
@@ -1,6 +1,7 @@
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 { PageNavBar } from './PageNavBar.jsx';
import { normalizeColumnsArray } from './grid/utils.js';
import { getTypographyRoleProps } from '../styles/index.js';
@@ -67,6 +68,9 @@ function HeaderCell({ column, orderBy, order, onSort }) {
const CaretUp = getIcon('caret-up');
const CaretDown = getIcon('caret-down');
const justifyContent = getColumnJustify(column.align);
const nextSortDirection = !isActive ? 'ascending' : order === 'asc' ? 'descending' : 'ascending';
const ChevronIcon = isActive ? (order === 'asc' ? CaretUp : CaretDown) : CaretDown;
const iconColor = isActive ? '$textSecondary' : '$textMuted';
return (
<XStack
@@ -75,32 +79,24 @@ function HeaderCell({ column, orderBy, order, onSort }) {
minWidth={column.minWidth || 120}
alignItems="center"
justifyContent={justifyContent}
gap="$2"
>
<Button
chromeless
disabled={!sortable}
onPress={() => sortable && onSort(column.id)}
padding={0}
justifyContent={justifyContent}
width="100%"
hoverStyle={sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
{column.label}
</Text>
{sortable ? (
isActive ? (
order === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
<Text flex={1} minWidth={0} {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
{column.label}
</Text>
{sortable ? (
<Button
chromeless
circular
size="$2"
flexShrink={0}
aria-label={`Sort ${column.label} ${nextSortDirection}`}
onPress={() => onSort(column.id)}
hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
icon={ChevronIcon ? <ChevronIcon size="xs" color={iconColor} /> : undefined}
/>
) : null}
</XStack>
);
}
@@ -170,10 +166,6 @@ export function DirView({
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');
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
useEffect(() => {
@@ -209,15 +201,18 @@ export function DirView({
filter_by: filterBy
};
const [recordResult, summaryResult] = await Promise.all([
dataModel.queryRecords(query),
dataModel.querySummary(query, resolvedSummaryDefinitions)
]);
const recordPromise = dataModel.queryRecords(query);
const summaryPromise = (
showSummary && typeof dataModel.querySummary === 'function'
? dataModel.querySummary(query, resolvedSummaryDefinitions)
: Promise.resolve(null)
);
const [recordResult, summaryResult] = await Promise.all([recordPromise, summaryPromise]);
if (!cancelled) {
setRecords(recordResult.rows || []);
setTotalRecords(recordResult.total || 0);
setSummary(summaryResult || null);
setSummary(showSummary ? (summaryResult || null) : null);
}
} catch (loadError) {
if (!cancelled) {
@@ -280,12 +275,13 @@ export function DirView({
}
const IconComponent = action?.icon ? getIcon(action.icon) : null;
const useChromeless = Boolean(action?.chromeless && !action?.label);
return (
<Button
key={action?.id || action?.label || index}
size="$3"
theme={action?.theme}
chromeless={action?.chromeless}
chromeless={useChromeless}
disabled={loading || action?.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
onPress={action?.onPress}
@@ -401,47 +397,21 @@ export function DirView({
{bodyFooterContent}
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
<XStack justifyContent="space-between" alignItems="center" gap="$1" flexWrap="wrap">
<Text color="$textMuted">
Rows: {totalRecords}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
aria-label="First page"
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(1)}
disabled={currentPage === 1 || loading}
/>
<Button
size="$3"
chromeless
aria-label="Previous page"
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1 || loading}
/>
<Text color="$textSecondary">
Page {currentPage} of {totalPages}
</Text>
<Button
size="$3"
chromeless
aria-label="Next page"
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : 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="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(totalPages)}
disabled={currentPage >= totalPages || loading}
/>
</XStack>
<PageNavBar
label={`Page ${currentPage} of ${totalPages}`}
onFirstPage={() => setCurrentPage(1)}
onPreviousPage={() => setCurrentPage((value) => Math.max(1, value - 1))}
onNextPage={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
onLastPage={() => setCurrentPage(totalPages)}
firstDisabled={currentPage === 1 || loading}
previousDisabled={currentPage === 1 || loading}
nextDisabled={currentPage >= totalPages || loading}
lastDisabled={currentPage >= totalPages || loading}
/>
</XStack>
</YStack>
);
+3 -3
View File
@@ -33,7 +33,7 @@ function fieldChrome(orientation, error, border) {
position: embedded ? 'relative' : 'static',
borderWidth: embedded && !borderless ? 1 : 0,
borderColor: error ? '$danger' : '$lineSubtle',
borderRadius: embedded ? '$4' : '$0',
borderRadius: embedded ? '$radiusMd' : '$0',
backgroundColor: embedded && !borderless ? '$bgPanel' : 'transparent',
paddingTop: embedded && !borderless ? '$3.5' : '$0',
paddingRight: embedded && !borderless ? '$3' : '$0',
@@ -197,7 +197,7 @@ function ColorSwatch({ value }) {
<YStack
width={28}
height={28}
borderRadius="$3"
borderRadius="$radiusSm"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor={isHex ? normalized : '$bgPanelElev'}
@@ -541,7 +541,7 @@ export function FieldControl({
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$4"
borderRadius="$radiusMd"
elevation="$2"
shadowColor="$shadowColor"
>
+7 -7
View File
@@ -236,7 +236,7 @@ function ColorField({ color, onPick }) {
minWidth={0}
minHeight={192}
overflow="hidden"
borderRadius="$3"
borderRadius="$radiusMd"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor={color}
@@ -260,7 +260,7 @@ function PickerFrame({ width = 320, children }) {
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$4"
borderRadius="$radiusMd"
shadowColor="$shadowColor"
elevation="$3"
>
@@ -299,7 +299,7 @@ export function ColorPickerPopup({ value, onChange, onClose }) {
<YStack
marginTop="$1"
height={44}
borderRadius="$3"
borderRadius="$radiusSm"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor={value || '#000000'}
@@ -308,7 +308,7 @@ export function ColorPickerPopup({ value, onChange, onClose }) {
</XStack>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$2" color="$textSecondary">{String(value || '#000000').toUpperCase()}</Text>
<Button size="$2" chromeless onPress={onClose}>Close</Button>
<Button size="$2" onPress={onClose}>Close</Button>
</XStack>
</PickerFrame>
);
@@ -334,7 +334,7 @@ function DateSection({ value, onChange }) {
<YStack
gap="$2"
minHeight={184}
borderRadius="$3"
borderRadius="$radiusMd"
borderWidth={1}
borderColor="$lineSubtle"
padding="$4"
@@ -381,7 +381,7 @@ function DateSection({ value, onChange }) {
minWidth={0}
height={34}
paddingHorizontal="$0"
borderRadius="$3"
borderRadius="$radiusSm"
borderWidth={isSelected ? 1 : (isToday ? 1 : 0)}
borderColor={isSelected ? '$accent' : (isToday ? '$lineStrong' : 'transparent')}
backgroundColor={isSelected ? '$accentBg' : 'transparent'}
@@ -472,7 +472,7 @@ export function DateTimePickerPopup({ type = 'date-time', value, onChange, onClo
<Text fontSize="$2" color="$textSecondary">
{String(composeDateTimeValue(type, parts) || value || '').toUpperCase() || '—'}
</Text>
<Button size="$2" chromeless onPress={onClose}>Close</Button>
<Button size="$2" onPress={onClose}>Close</Button>
</XStack>
</PickerFrame>
);
+16
View File
@@ -15,6 +15,7 @@ import {
YStack,
} from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { CodedCheckboxGroup } from './CodedValues.jsx';
import { pickFile } from '../../platform/compat.js';
import { getTypographyRoleProps } from '../styles/index.js';
@@ -224,6 +225,21 @@ export function FormField({
);
}
if (type === 'coded-checkboxes') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<CodedCheckboxGroup
key={`${id}:${String(fieldValue)}`}
codeMap={props.codeMap || {}}
labels={props.labels || null}
value={fieldValue}
disabled={disabled}
onChange={(nextValue) => onChange?.(id, nextValue)}
/>
</FieldShell>
);
}
// True boolean control instead of a Button-pretending-to-be-a-checkbox.
// Switch is the right semantic for an on/off setting.
if (type === 'checkbox') {
+52 -1
View File
@@ -22,7 +22,7 @@
import React from 'react';
import { useTheme } from '@tamagui/core';
import { SizableText } from 'tamagui';
import { Avatar, SizableText } from 'tamagui';
import {
// navigation & shell
House,
@@ -152,6 +152,7 @@ import {
Printer,
Circle,
Lightning,
Gift,
} from '@phosphor-icons/react';
import { themeManager } from '../theme-controller.js';
@@ -375,6 +376,7 @@ const iconMap = {
'star': wrap(Star, 'Star'),
'star-border': wrap(Star, 'Star'),
'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
'gift': wrap(Gift, 'Gift'),
// ── Time & Calendar ────────────────────────────────────────────────────
'calendar': wrap(Calendar, 'Calendar'),
@@ -467,6 +469,55 @@ export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimar
return null;
}
/**
* Pictogram-style icon that renders either an image or a compact fallback glyph
* while honoring the same semantic icon sizes used elsewhere in the shell.
*/
export function PictIcon({
size = DEFAULT_SIZE,
color = '$accentColor',
image_url = '',
label = '',
fallback = '',
...props
}) {
const theme = useTheme();
const pixelSize = resolveSize(size);
const backgroundColor = resolveColor(color, theme) || resolveColor('$accentColor', theme) || 'currentColor';
const source = String(label || fallback || '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('') || 'A';
return (
<Avatar
circular
size={pixelSize}
width={pixelSize}
height={pixelSize}
backgroundColor={backgroundColor}
overflow="hidden"
{...props}
>
{image_url ? (
<Avatar.Image
src={image_url}
width={pixelSize}
height={pixelSize}
style={{ width: pixelSize, height: pixelSize, objectFit: 'cover' }}
/>
) : null}
<Avatar.Fallback backgroundColor={backgroundColor}>
<SizableText size={Math.max(Math.round(pixelSize * 0.48), 10)} color="white" fontWeight="700">
{source}
</SizableText>
</Avatar.Fallback>
</Avatar>
);
}
/**
* Get the px value for a semantic size token. Useful when laying out
* non-icon children (avatars, badges) alongside icons.
+22 -1
View File
@@ -5,7 +5,7 @@
*/
import React, { useState, useRef, useEffect } from 'react';
import { Button, XStack, YStack, Text } from 'tamagui';
import { Button, Separator, XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { NotificationManager } from './Shell.jsx';
import {
@@ -90,6 +90,27 @@ export function MenuItemButton({
return null;
}
const isSeparator = menuItem.type === 'separator' || menuItem.kind === 'separator';
if (isSeparator) {
return (
<YStack
width={width !== undefined ? width : '100%'}
paddingVertical={orientation === 'horizontal' ? '$1' : '$2'}
paddingHorizontal={orientation === 'horizontal' ? '$1' : 0}
style={style}
testID={testID}
{...otherProps}
>
<Separator
vertical={orientation === 'horizontal'}
borderColor="$lineSubtle"
alignSelf={orientation === 'horizontal' ? 'stretch' : undefined}
/>
</YStack>
);
}
// 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
+76
View File
@@ -0,0 +1,76 @@
import React from 'react';
import { Button, Text, XStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
export function PageNavBar({
label = 'Page 1 of 1',
labelControl = null,
onFirstPage = undefined,
onPreviousPage = undefined,
onNextPage = undefined,
onLastPage = undefined,
firstDisabled = false,
previousDisabled = false,
nextDisabled = false,
lastDisabled = false,
outlined = true,
size = '$3'
}) {
const FirstPageIcon = getIcon('first-page');
const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page');
return (
<XStack
gap="$1"
alignItems="center"
flexWrap="wrap"
padding="$1"
borderWidth={outlined ? 1 : 0}
borderColor={outlined ? '$lineSubtle' : 'transparent'}
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
>
<Button
size={size}
chromeless
aria-label="First page"
disabled={firstDisabled}
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={onFirstPage}
/>
<Button
size={size}
chromeless
aria-label="Previous page"
disabled={previousDisabled}
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={onPreviousPage}
/>
{labelControl || (
<Text color="$textSecondary">
{label}
</Text>
)}
<Button
size={size}
chromeless
aria-label="Next page"
disabled={nextDisabled}
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={onNextPage}
/>
<Button
size={size}
chromeless
aria-label="Last page"
disabled={lastDisabled}
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={onLastPage}
/>
</XStack>
);
}
export default PageNavBar;
-4
View File
@@ -21,22 +21,18 @@ function getHeaderSizeStyles(headerSize) {
const sizeMap = {
1: {
padding: '$3',
borderRadius: '$4',
minHeight: 64
},
2: {
padding: '$2',
borderRadius: '$3',
minHeight: 48
},
3: {
padding: '$1.5',
borderRadius: '$2',
minHeight: 44
},
4: {
padding: '$1',
borderRadius: '$2',
minHeight: 36
}
};
+34 -3
View File
@@ -2,6 +2,7 @@ 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';
import { PictIcon } from './IconMapper.jsx';
function createSecurityRenderContext(securityState) {
return {
@@ -49,16 +50,46 @@ export function PersonalMenuItem({
}
}
const displayLabel = String(
securityState.profile?.display_name ||
securityState.user?.display_name ||
securityState.user?.username ||
'Account'
).trim() || 'Account';
const imageUrl = securityState.profile?.image_url || securityState.user?.image_url || '';
const avatarLabel = securityState.profile?.display_name || securityState.user?.display_name || securityState.user?.username || 'Account';
const PersonalAvatarIcon = (props) => (
<PictIcon
{...props}
color="$accentColor"
image_url={imageUrl}
label={avatarLabel}
fallback="A"
/>
);
PersonalAvatarIcon.displayName = 'PersonalAvatarIcon';
return new MenuItem({
id: 'personal-smart',
label: 'Account',
icon: 'account',
label: displayLabel,
icon: PersonalAvatarIcon,
style: 'both',
invoke_type: 'page',
invoke_target: '/account',
items: visibleChildren
});
}, [personalRoot, security, securityState.config?.login_route, securityState.enabled, securityState.isAuthenticated]);
}, [
personalRoot,
security,
securityState.config?.login_route,
securityState.enabled,
securityState.isAuthenticated,
securityState.profile?.display_name,
securityState.profile?.image_url,
securityState.user?.display_name,
securityState.user?.image_url,
securityState.user?.username
]);
if (!resolvedMenuItem) {
return null;
+3 -3
View File
@@ -90,7 +90,7 @@ export function ProgressBar({
position="relative"
width="100%"
height={trackHeight}
borderRadius="$1"
borderRadius="$radiusSm"
backgroundColor="$lineSubtle"
overflow="hidden"
>
@@ -101,7 +101,7 @@ export function ProgressBar({
left={0}
height="100%"
width={determinateWidth}
borderRadius="$1"
borderRadius="$radiusSm"
backgroundColor="$accent"
/>
) : (
@@ -110,7 +110,7 @@ export function ProgressBar({
top={0}
height="100%"
width="42%"
borderRadius="$1"
borderRadius="$radiusSm"
backgroundColor="$accent"
left={`${offset}%`}
/>
+36 -33
View File
@@ -8,7 +8,7 @@ import React, { Suspense, createContext, useContext, useState, useEffect, useCal
import { InvokeHandlers } from '../../platform/menu.js';
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
import { LoginPage } from '../../security/pages/LoginPage.jsx';
import { LoginDialog, LoginPage } from '../../security/pages/LoginPage.jsx';
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
@@ -394,6 +394,7 @@ function SelectedComponent({ placement, fallback }) {
evaluating: true,
allowed: true,
requires_login: false,
pending: false,
reason: ''
});
@@ -413,6 +414,7 @@ function SelectedComponent({ placement, fallback }) {
evaluating: false,
allowed: true,
requires_login: false,
pending: false,
reason: ''
});
return () => {
@@ -425,6 +427,7 @@ function SelectedComponent({ placement, fallback }) {
evaluating: false,
allowed: true,
requires_login: false,
pending: false,
reason: securityState.enabled ? 'Route has no security requirements' : 'Security disabled'
});
return () => {
@@ -463,9 +466,13 @@ function SelectedComponent({ placement, fallback }) {
return null;
}
if (guardState.pending) {
return null;
}
if (!guardState.allowed) {
if (guardState.requires_login) {
return <LoginPage compact subtitle="This route requires an authenticated user." />;
return <LoginDialog subtitle="This route requires an authenticated user." />;
}
return (
@@ -890,38 +897,34 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
const unsubscribe = subscribeToPathChanges((path, state) => {
console.log('[Router] Browser URL changed (popstate):', path);
// Find matching route
const match = findRoute(path);
if (match) {
// Update internal state to match browser URL
setNavigationState((prevState) => {
const existingIndex = prevState.history.findIndex(entry => entry.path === path);
if (existingIndex >= 0) {
return {
...prevState,
historyIndex: existingIndex,
currentPath: path,
routeState: state
};
} else {
const newHistory = [...prevState.history, { path, state, timestamp: Date.now() }];
return {
...prevState,
history: newHistory,
historyIndex: newHistory.length - 1,
currentPath: path,
routeState: state
};
}
});
// Call onRouteChange callback
if (onRouteChangeRef.current) {
onRouteChangeRef.current(path, match.component, match.params, state);
// Always adopt the browser path first. Route registration can lag
// behind URL changes during boot or auth redirects.
setNavigationState((prevState) => {
const existingIndex = prevState.history.findIndex(entry => entry.path === path);
if (existingIndex >= 0) {
return {
...prevState,
historyIndex: existingIndex,
currentPath: path,
routeState: state
};
}
} else {
console.warn('[Router] Browser URL changed to unknown path:', path);
const newHistory = [...prevState.history, { path, state, timestamp: Date.now() }];
return {
...prevState,
history: newHistory,
historyIndex: newHistory.length - 1,
currentPath: path,
routeState: state
};
});
const match = findRoute(path);
if (match && onRouteChangeRef.current) {
onRouteChangeRef.current(path, match.component, match.params, state);
} else if (!match) {
console.warn('[Router] Browser URL changed to path pending route registration:', path);
}
});
+48 -3
View File
@@ -5,10 +5,11 @@
*/
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { XStack, YStack, Text, Button } from 'tamagui';
import { XStack, YStack, Text, Button, Spinner } from 'tamagui';
import RecordsModel from '../../data/RecordsModel.js';
import { getIcon } from './IconMapper.jsx';
import { SidePanelShell } from './SidePanelShell.jsx';
import { networkActivityManager } from '../../platform/api.js';
// ============================================================================
// Shell Context
@@ -627,6 +628,7 @@ export function ShellProvider({
// Toast state
const [toasts, setToasts] = useState([]);
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
const [networkActivity, setNetworkActivity] = useState(networkActivityManager.getState());
// Initialize shell manager with setters
useEffect(() => {
@@ -647,6 +649,8 @@ export function ShellProvider({
notificationCenterManager._init(setNotificationsOpen);
}, []);
useEffect(() => networkActivityManager.subscribe(setNetworkActivity), []);
// Update shell manager state when it changes
useEffect(() => {
shellManager._updateState({
@@ -711,6 +715,7 @@ export function ShellProvider({
return (
<ShellContext.Provider value={value}>
{children}
<NetworkActivityOverlay visible={networkActivity.visible} />
<NotificationCenterPanel open={notificationsOpen} />
</ShellContext.Provider>
);
@@ -769,7 +774,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
backgroundColor={style.backgroundColor}
borderWidth={1}
borderColor={style.borderColor}
borderRadius="$4"
borderRadius="$radiusMd"
padding="$3"
minWidth={300}
maxWidth={400}
@@ -833,7 +838,7 @@ function NotificationRecord({ record, onDismiss }) {
backgroundColor={style.backgroundColor}
borderWidth={1}
borderColor={style.borderColor}
borderRadius="$4"
borderRadius="$radiusMd"
padding="$3"
gap="$2"
alignItems="flex-start"
@@ -1023,6 +1028,46 @@ export function ToastViewport() {
);
}
function NetworkActivityOverlay({ visible = false }) {
if (!visible) {
return null;
}
return (
<YStack
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
zIndex={21500}
alignItems="center"
justifyContent="center"
pointerEvents="none"
>
<YStack
padding="$4"
gap="$2"
alignItems="center"
justifyContent="center"
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$radiusLg"
shadowColor="$shadowColor"
shadowOpacity={0.15}
shadowRadius={12}
elevation={6}
>
<Spinner size="large" color="$accentColor" />
<Text color="$textSecondary" fontSize="$3">
Working...
</Text>
</YStack>
</YStack>
);
}
// ============================================================================
// Shell Placement
// ============================================================================
+2 -1
View File
@@ -9,11 +9,12 @@ import { getIcon } from './IconMapper.jsx';
*/
function ActionButton({ action }) {
const IconComponent = action?.icon ? getIcon(action.icon) : null;
const useChromeless = Boolean(action?.chromeless && !action?.label);
return (
<Button
size="$3"
theme={action?.theme}
chromeless={action?.chromeless}
chromeless={useChromeless}
disabled={action?.disabled}
onPress={action?.onPress}
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
+33 -2
View File
@@ -242,7 +242,7 @@ function TopBarWide({
</XStack>
)}
{/* Right Side — secondary actions get a hairline separator + tighter gap */}
{/* Right Side */}
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
<XStack
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
@@ -256,7 +256,38 @@ function TopBarWide({
borderLeftColor="$lineSubtle"
style={{ flexShrink: 0 }}
>
{organizedChildren.sections.rightSide}
{organizedChildren.secondaryMenuItems.length > 0 && (
<XStack 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>
)}
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
<XStack
alignItems="center"
paddingLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$2" : undefined}
marginLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$1" : undefined}
borderLeftWidth={organizedChildren.secondaryMenuItems.length > 0 ? 1 : 0}
borderLeftColor="$lineSubtle"
>
<PersonalMenuItem
key="personal-menu"
personalRoot={organizedChildren.personalRoot}
orientation="horizontal"
expand_mode="popup"
stateVersion={organizedChildren.menuVersion}
/>
</XStack>
)}
</XStack>
)}
</XStack>
+2 -1
View File
@@ -16,12 +16,13 @@ function renderToolbarItem(item) {
if (item.kind === 'button') {
const IconComponent = item.icon ? getIcon(item.icon) : null;
const useChromeless = Boolean(item.chromeless && !item.label);
return (
<Button
key={item.key || item.label}
size="$3"
theme={item.theme}
chromeless={item.chromeless}
chromeless={useChromeless}
disabled={item.disabled}
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
onPress={item.onClick || item.onPress}
+3 -4
View File
@@ -11,10 +11,9 @@ export function createPanelGridViewProps(overrides = {}) {
export function createTableGridViewProps(overrides = {}) {
return {
direction: 'vertical',
headerSize: 3.25,
bodySize: 20,
footerSize: 3.5,
headerSize: 'auto',
bodySize: 'auto',
footerSize: 'auto',
...overrides
};
}
+64 -71
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.jsx';
import { PageNavBar } from '../PageNavBar.jsx';
import { useGridView } from './context.js';
import { getTypographyRoleProps } from '../../styles/index.js';
import {
@@ -98,6 +99,16 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
const selectedCount = allIds.filter((id) => grid.selectedIds.has(id)).length;
const allSelected = allIds.length > 0 && selectedCount === allIds.length;
const someSelected = selectedCount > 0 && !allSelected;
const getNextSortDirection = (column) => {
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
if (!activeSort) {
return 'ascending';
}
if (activeSort.direction === 'asc') {
return 'descending';
}
return 'none';
};
return (
<XStack
@@ -105,8 +116,11 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
borderTopWidth={showTopBorder ? 1 : 0}
borderBottomWidth={1}
borderColor="$lineSubtle"
backgroundColor="transparent"
backgroundColor="$bgPanel"
paddingHorizontal="$2"
paddingTop="$1"
paddingBottom="$1"
gap="$1"
>
{grid.selectable || grid.nested ? (
<XStack width={36} alignItems="center" justifyContent="center">
@@ -132,41 +146,46 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
const isActive = Boolean(activeSort);
const direction = activeSort?.direction;
const sortButtonLabel = `Sort ${column.label} ${getNextSortDirection(column)}`;
const ChevronIcon = isActive
? (direction === 'asc' ? CaretUp : CaretDown)
: CaretDown;
const iconColor = isActive ? '$textSecondary' : '$textMuted';
return (
<Button
<XStack
key={column.field}
chromeless
disabled={!column.sortable}
onPress={() => column.sortable && grid.toggleSort(column.field)}
justifyContent={getColumnJustify(column.align)}
alignItems="center"
paddingVertical="$3"
minHeight={44}
paddingHorizontal="$2"
paddingVertical="$2"
gap="$2"
{...getColumnLayoutStyle(column)}
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
<Text
width="auto"
textAlign={column.align || 'left'}
numberOfLines={1}
{...getTypographyRoleProps('tableHeader')}
>
{column.label}
</Text>
{column.sortable ? (
isActive ? (
direction === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
<Text
flex={1}
minWidth={0}
textAlign={column.align || 'left'}
numberOfLines={1}
{...getTypographyRoleProps('tableHeader')}
>
{column.label}
</Text>
{column.sortable ? (
<Button
chromeless
circular
size="$2"
flexShrink={0}
aria-label={sortButtonLabel}
onPress={() => grid.toggleSort(column.field)}
hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
icon={ChevronIcon ? <ChevronIcon size="xs" color={iconColor} /> : undefined}
/>
) : null}
</XStack>
);
})}
</XStack>
@@ -247,10 +266,6 @@ export function TableBodyView({ visible = true }) {
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;
@@ -261,8 +276,11 @@ export function TableFooter({ visible = true }) {
alignItems="center"
justifyContent="space-between"
gap="$3"
padding="$3"
minHeight={56}
paddingTop="$1"
paddingRight="$3"
paddingBottom="$1"
paddingLeft="$3"
minHeight={64}
borderTopWidth={1}
borderTopColor="$lineSubtle"
backgroundColor="$bgPanel"
@@ -272,43 +290,18 @@ export function TableFooter({ visible = true }) {
{grid.total} records
</Text>
<XStack gap="$1" alignItems="center" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage - 1)}
/>
<Text color="$textSecondary">
Page {grid.currentPage} of {grid.pageCount}
</Text>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage + 1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.pageCount)}
/>
</XStack>
<PageNavBar
outlined={false}
label={`Page ${grid.currentPage} of ${grid.pageCount}`}
onFirstPage={() => grid.setPage(1)}
onPreviousPage={() => grid.setPage(grid.currentPage - 1)}
onNextPage={() => grid.setPage(grid.currentPage + 1)}
onLastPage={() => grid.setPage(grid.pageCount)}
firstDisabled={grid.currentPage <= 1}
previousDisabled={grid.currentPage <= 1}
nextDisabled={grid.currentPage >= grid.pageCount}
lastDisabled={grid.currentPage >= grid.pageCount}
/>
</XStack>
);
}
+4
View File
@@ -21,10 +21,14 @@ export { ColorPickerPopup, DateTimePickerPopup } from './FieldControlPickers.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 { PageNavBar, default as PageNavBarDefault } from './PageNavBar.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 { StorageAdapter } from './storage/StorageAdapter.js';
export { OpfsStorageAdapter, default as OpfsStorageAdapterDefault } from './storage/OpfsStorageAdapter.js';
export { registerStorageFileView, default as StorageBrowser, default as StorageBrowserDefault } from './storage/StorageBrowser.jsx';
export { registerShell, unregisterShell, resolveRegisteredShell, listRegisteredShells, clearRegisteredShells } from './shell-registry.js';
export * from './grid/index.js';
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
@@ -0,0 +1,299 @@
import { StorageAdapter } from './StorageAdapter.js';
function normalizePath(path = '/') {
const trimmed = String(path || '/').trim();
if (!trimmed || trimmed === '.') return '/';
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
const collapsed = withLeading.replace(/\/+/g, '/');
return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed;
}
function splitPath(path = '/') {
return normalizePath(path).split('/').filter(Boolean);
}
function joinPath(...parts) {
const joined = parts
.filter((part) => part != null && part !== '')
.join('/');
return normalizePath(joined.startsWith('/') ? joined : `/${joined}`);
}
function entryName(path = '/') {
const segments = splitPath(path);
return segments[segments.length - 1] || '/';
}
async function fileHandleToMetadata(path, handle) {
const file = await handle.getFile();
return {
path,
is_folder: false,
mime_type: file.type || 'application/octet-stream',
size: file.size,
modified: Number.isFinite(file.lastModified) ? new Date(file.lastModified).toISOString() : null
};
}
function directoryMetadata(path) {
return {
path,
is_folder: true,
mime_type: 'application/folder',
size: null,
modified: null
};
}
async function readBlobAsText(blob) {
return blob.text();
}
export class OpfsStorageAdapter extends StorageAdapter {
constructor({ basePath = '/' } = {}) {
super();
this.basePath = normalizePath(basePath);
this.rootPromise = null;
}
async getOpfsRoot() {
if (!globalThis.navigator?.storage?.getDirectory) {
throw new Error('Origin Private File System is not available in this browser.');
}
if (!this.rootPromise) {
this.rootPromise = (async () => {
let directory = await globalThis.navigator.storage.getDirectory();
for (const segment of splitPath(this.basePath)) {
directory = await directory.getDirectoryHandle(segment, { create: true });
}
return directory;
})();
}
return this.rootPromise;
}
async getDirectoryHandle(path = '/', { create = false } = {}) {
let directory = await this.getOpfsRoot();
for (const segment of splitPath(path)) {
directory = await directory.getDirectoryHandle(segment, { create });
}
return directory;
}
async getParentDirectory(path, { create = false } = {}) {
const normalized = normalizePath(path);
if (normalized === '/') {
throw new Error('The root path has no parent directory.');
}
const segments = splitPath(normalized);
const name = segments.pop();
const parentPath = segments.length ? `/${segments.join('/')}` : '/';
const directory = await this.getDirectoryHandle(parentPath, { create });
return { directory, name, parentPath };
}
async tryGetHandle(path) {
const normalized = normalizePath(path);
if (normalized === '/') {
return { kind: 'directory', handle: await this.getOpfsRoot() };
}
const { directory, name } = await this.getParentDirectory(normalized);
try {
const handle = await directory.getDirectoryHandle(name);
return { kind: 'directory', handle };
} catch {
}
try {
const handle = await directory.getFileHandle(name);
return { kind: 'file', handle };
} catch {
}
return null;
}
async ensurePathMissing(path) {
const existing = await this.tryGetHandle(path);
if (existing) {
throw new Error(`Path already exists: ${normalizePath(path)}`);
}
}
async list(path = '/') {
const directory = await this.getDirectoryHandle(path);
const rows = [];
for await (const [name, handle] of directory.entries()) {
const childPath = joinPath(path, name);
if (handle.kind === 'directory') {
rows.push(directoryMetadata(childPath));
} else {
rows.push(await fileHandleToMetadata(childPath, handle));
}
}
rows.sort((left, right) => {
if (left.is_folder !== right.is_folder) {
return left.is_folder ? -1 : 1;
}
return entryName(left.path).localeCompare(entryName(right.path), undefined, {
numeric: true,
sensitivity: 'base'
});
});
return rows;
}
async info(path = '/') {
const normalized = normalizePath(path);
const found = await this.tryGetHandle(normalized);
if (!found) {
throw new Error(`Path not found: ${normalized}`);
}
if (found.kind === 'directory') {
return directoryMetadata(normalized);
}
return fileHandleToMetadata(normalized, found.handle);
}
async remove(path) {
const normalized = normalizePath(path);
if (normalized === '/') {
throw new Error('Removing the adapter root path is not supported.');
}
const { directory, name } = await this.getParentDirectory(normalized);
await directory.removeEntry(name, { recursive: true });
}
async rename(oldPath, newPath) {
const from = normalizePath(oldPath);
const to = normalizePath(newPath);
if (from === '/' || to === '/') {
throw new Error('Renaming the adapter root path is not supported.');
}
if (from === to) {
return;
}
const source = await this.tryGetHandle(from);
if (!source) {
throw new Error(`Path not found: ${from}`);
}
await this.ensurePathMissing(to);
const { directory: destinationParent, name: destinationName } = await this.getParentDirectory(to, { create: true });
if (source.kind === 'directory') {
await this.copyDirectoryRecursive(source.handle, destinationParent, destinationName);
} else {
await this.copyFileHandle(source.handle, destinationParent, destinationName);
}
await this.remove(from);
}
async upload(pathScope, itemType, itemName, file) {
const scope = normalizePath(pathScope || '/');
const name = String(itemName || '').trim();
if (!name) {
throw new Error('A file or folder name is required.');
}
const destinationPath = joinPath(scope, name);
await this.ensurePathMissing(destinationPath);
if (itemType === 'Folder') {
const { directory, name: folderName } = await this.getParentDirectory(destinationPath, { create: true });
await directory.getDirectoryHandle(folderName, { create: true });
return this.info(destinationPath);
}
if (!file) {
throw new Error('A file payload is required for file uploads.');
}
const { directory, name: fileName } = await this.getParentDirectory(destinationPath, { create: true });
const fileHandle = await directory.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
try {
await writable.write(file);
} finally {
await writable.close();
}
return this.info(destinationPath);
}
async downloadBlob(path) {
const normalized = normalizePath(path);
const found = await this.tryGetHandle(normalized);
if (!found) {
throw new Error(`Path not found: ${normalized}`);
}
if (found.kind === 'directory') {
const manifest = await this.describeTree(normalized);
return new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
}
return found.handle.getFile();
}
async downloadText(path) {
const blob = await this.downloadBlob(path);
return readBlobAsText(blob);
}
async copyFileHandle(sourceHandle, destinationDirectory, destinationName) {
const file = await sourceHandle.getFile();
const nextHandle = await destinationDirectory.getFileHandle(destinationName, { create: true });
const writable = await nextHandle.createWritable();
try {
await writable.write(file);
} finally {
await writable.close();
}
}
async copyDirectoryRecursive(sourceDirectory, destinationParent, destinationName) {
const nextDirectory = await destinationParent.getDirectoryHandle(destinationName, { create: true });
for await (const [childName, childHandle] of sourceDirectory.entries()) {
if (childHandle.kind === 'directory') {
await this.copyDirectoryRecursive(childHandle, nextDirectory, childName);
} else {
await this.copyFileHandle(childHandle, nextDirectory, childName);
}
}
}
async describeTree(path = '/') {
const found = await this.tryGetHandle(path);
if (!found) {
throw new Error(`Path not found: ${normalizePath(path)}`);
}
if (found.kind === 'file') {
return await fileHandleToMetadata(normalizePath(path), found.handle);
}
const children = await this.list(path);
return {
...directoryMetadata(normalizePath(path)),
children: await Promise.all(children.map(async (child) => (
child.is_folder ? this.describeTree(child.path) : child
)))
};
}
}
export default OpfsStorageAdapter;
@@ -0,0 +1,29 @@
export class StorageAdapter {
async list(_path) {
throw new Error('StorageAdapter.list() is not implemented');
}
async info(_path) {
throw new Error('StorageAdapter.info() is not implemented');
}
async remove(_path) {
throw new Error('StorageAdapter.remove() is not implemented');
}
async rename(_oldPath, _newPath) {
throw new Error('StorageAdapter.rename() is not implemented');
}
async upload(_pathScope, _itemType, _itemName, _file) {
throw new Error('StorageAdapter.upload() is not implemented');
}
async downloadBlob(_path, _disposition = 'attachment') {
throw new Error('StorageAdapter.downloadBlob() is not implemented');
}
async downloadText(_path, _disposition = 'inline') {
throw new Error('StorageAdapter.downloadText() is not implemented');
}
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
/**
* Fluent Flat Theme
* Square, enterprise look inspired by modern Microsoft product surfaces.
*
* Aesthetic intent
* ----------------
* - Zero-radius surfaces for a crisp, grid-aligned shell
* - Border-led separation with restrained shadow use
* - Neutral page background with bright white working surfaces
* - Azure-like accent reserved for focus, primary action, and selection
*/
import { config as configBase } from '@tamagui/config/v3';
export const FluentFlatTheme = {
...configBase,
name: 'fluent-flat',
displayName: 'Fluent Flat',
iconWeight: 'regular',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
panelTitle: { fontWeight: '600', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
true: 0,
0: 0,
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
7: 0,
8: 0,
9: 0,
10: 0,
11: 0,
12: 0,
radiusSm: 0,
radiusMd: 0,
radiusLg: 0,
},
color: {
...configBase.tokens.color,
primary: '#0f6cbd',
primaryLight: '#2886de',
primaryDark: '#0c5ea6',
secondary: '#5b6572',
secondaryLight: '#7a8594',
secondaryDark: '#404854',
error: '#c42b1c',
warning: '#986f0b',
info: '#0f6cbd',
success: '#107c10',
},
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
7: 32,
8: 40,
},
size: {
...configBase.tokens.size,
xs: 11,
sm: 12,
md: 14,
base: 14,
lg: 16,
xl: 18,
'2xl': 20,
'3xl': 24,
'4xl': 28,
'5xl': 36,
'6xl': 48,
},
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(15, 23, 42, 0.03)',
elevation2: 'rgba(15, 23, 42, 0.05)',
elevation4: 'rgba(15, 23, 42, 0.08)',
elevation8: 'rgba(15, 23, 42, 0.10)',
elevation12: 'rgba(15, 23, 42, 0.12)',
elevation16: 'rgba(15, 23, 42, 0.14)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
background: '#ffffff',
backgroundHover: '#f5f6f8',
backgroundPress: '#eceef2',
backgroundFocus: '#edf5fd',
surface: '#ffffff',
surfaceVariant: '#f7f8fa',
accentBackground: '#dff0ff',
accentSurface: '#eef6fd',
accentColor: '#0f6cbd',
accentBorder: 'rgba(15, 108, 189, 0.3)',
accentHover: '#d3e8fb',
accentPress: '#c3ddf6',
color: '#1b1b1b',
colorHover: '#1b1b1b',
colorSecondary: '#454545',
colorDisabled: '#8a8886',
borderColor: '#d1d1d1',
borderColorHover: '#bdbdbd',
bgPage: '#f7f8fa',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#1b1b1b',
scrim: 'rgba(0, 0, 0, 0.28)',
lineSubtle: '#d1d1d1',
lineStrong: '#b3b0ad',
textPrimary: '#1b1b1b',
textSecondary: '#454545',
textMuted: '#605e5c',
textOnAccent: '#ffffff',
accent: '#0f6cbd',
accentBg: '#eef6fd',
accentBgHover: '#dff0ff',
danger: '#c42b1c',
dangerBg: '#fde7e9',
warning: '#986f0b',
warningBg: '#fff4ce',
success: '#107c10',
successBg: '#dff6dd',
info: '#0f6cbd',
infoBg: '#deecf9',
},
dark: {
...configBase.themes.dark,
background: '#11100f',
backgroundHover: '#1b1a19',
backgroundPress: '#252423',
backgroundFocus: '#16324f',
surface: '#1b1a19',
surfaceVariant: '#252423',
accentBackground: '#103a5f',
accentSurface: '#16324f',
accentColor: '#7dc3ff',
accentBorder: 'rgba(125, 195, 255, 0.32)',
accentHover: '#17446e',
accentPress: '#1c4f80',
color: '#f3f2f1',
colorHover: '#f3f2f1',
colorSecondary: '#d2d0ce',
colorDisabled: '#8a8886',
borderColor: '#3b3a39',
borderColorHover: '#605e5c',
bgPage: '#11100f',
bgPanel: '#1b1a19',
bgPanelElev: '#252423',
bgInverse: '#f3f2f1',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: '#3b3a39',
lineStrong: '#605e5c',
textPrimary: '#f3f2f1',
textSecondary: '#d2d0ce',
textMuted: '#a19f9d',
textOnAccent: '#081f33',
accent: '#7dc3ff',
accentBg: '#16324f',
accentBgHover: '#103a5f',
danger: '#ff99a4',
dangerBg: 'rgba(255, 153, 164, 0.14)',
warning: '#fce100',
warningBg: 'rgba(252, 225, 0, 0.14)',
success: '#6ccb5f',
successBg: 'rgba(108, 203, 95, 0.14)',
info: '#7dc3ff',
infoBg: 'rgba(125, 195, 255, 0.14)',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
export default FluentFlatTheme;
+7 -5
View File
@@ -46,9 +46,9 @@
*
* Radii (preset overrides via tokens.radius)
* ------------------------------------------
* radiusSm tight controls (4-8)
* radiusMd cards, inputs (6-12)
* radiusLg large containers (8-16)
* radiusSm tight controls (0-8)
* radiusMd cards, inputs (0-12)
* radiusLg large containers (0-16)
*
* Font weights (preset overrides via tokens.weight)
* -------------------------------------------------
@@ -78,6 +78,7 @@ import { MinimalTheme } from './MinimalTheme.js';
import { ColorfulTheme } from './ColorfulTheme.js';
import { AzureTheme } from './AzureTheme.js';
import { AppleTheme } from './AppleTheme.js';
import { FluentFlatTheme } from './FluentFlatTheme.js';
/**
* Theme keys that every preset MUST define in both light and dark variants.
@@ -108,6 +109,7 @@ export const ICON_WEIGHTS = Object.freeze([
export const STYLE_THEMES = {
azure: AzureTheme,
'fluent-flat': FluentFlatTheme,
apple: AppleTheme,
material: MaterialTheme,
minimal: MinimalTheme,
@@ -214,7 +216,7 @@ export function normalizeStyleThemeName(name) {
/**
* Get a style theme by name.
* @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
* @param {string} themeName - Theme name ('azure', 'fluent-flat', 'apple', 'material', 'minimal', 'colorful')
* @returns {Object} Theme configuration
*/
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
@@ -270,4 +272,4 @@ export function getIconWeight(themeName = DEFAULT_STYLE_THEME) {
return ICON_WEIGHTS.includes(weight) ? weight : 'regular';
}
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme };
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme, FluentFlatTheme };