@tumbaland/frontend-core 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/SectionBar/SectionBar.d.ts +49 -0
- package/dist/components/SectionBar/SectionBar.js +143 -0
- package/dist/components/SectionBar/index.d.ts +2 -0
- package/dist/components/SectionBar/index.js +1 -0
- package/dist/components/tenant/TenantSelector.d.ts +9 -0
- package/dist/components/tenant/TenantSelector.js +52 -41
- package/dist/components/tenant/useTenant.d.ts +1 -1
- package/dist/components/tenant/useTenant.js +73 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/tokens.d.ts +18 -1
- package/dist/theme/tokens.js +21 -1
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { SvgIconProps } from '@mui/material/SvgIcon';
|
|
3
|
+
/** One section of a module: a page of it the navigation lists by name. */
|
|
4
|
+
export interface SectionBarItem {
|
|
5
|
+
label: string;
|
|
6
|
+
/** Absolute address, the same one in the shell and standalone (see each app's appBase). */
|
|
7
|
+
route: string;
|
|
8
|
+
icon?: ComponentType<SvgIconProps>;
|
|
9
|
+
}
|
|
10
|
+
export interface SectionBarProps {
|
|
11
|
+
/** The module the sections belong to. Named at the left of the bar. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Drawn in a tinted tile before the title. */
|
|
14
|
+
icon?: ComponentType<SvgIconProps>;
|
|
15
|
+
/** The module's accent. Tints the tile and the section being shown. */
|
|
16
|
+
accent?: string;
|
|
17
|
+
/** In menu order. A module with one section passes none and gets just a title. */
|
|
18
|
+
items?: SectionBarItem[];
|
|
19
|
+
currentRoute: string;
|
|
20
|
+
/** Phones get one dropdown in place of the row, and no title. */
|
|
21
|
+
compact?: boolean;
|
|
22
|
+
/** Pinned to the right of the bar — where the shell puts the tenant selector. */
|
|
23
|
+
end?: ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Drawn at the left in place of the title, for a page that has something
|
|
26
|
+
* better to put there than its own name — the shell's home page puts the
|
|
27
|
+
* wordmark here. Shown on the same terms as the title, so a module with
|
|
28
|
+
* sections still spends the room on them.
|
|
29
|
+
*/
|
|
30
|
+
lead?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The one bar above a module's content: what you are in, and where else you can
|
|
34
|
+
* go inside it.
|
|
35
|
+
*
|
|
36
|
+
* It lives here rather than in the shell because a module renders it in both
|
|
37
|
+
* the modes it runs in. Inside the shell one bar is drawn for whichever module
|
|
38
|
+
* is loaded; standalone, the module's own `AppLayout` draws the same bar from
|
|
39
|
+
* the same section list. The alternative — in-page MUI `Tabs` — is what had
|
|
40
|
+
* Profile and Billing looking nothing like Journal, Album and Money, and put a
|
|
41
|
+
* second row of navigation under the shell's own.
|
|
42
|
+
*
|
|
43
|
+
* Sections are laid out in full on a wide screen, since a row of five names is
|
|
44
|
+
* worth more than a button that hides four of them. Below `sm` there is no room
|
|
45
|
+
* for that, so the same list becomes a dropdown labelled with the section being
|
|
46
|
+
* shown.
|
|
47
|
+
*/
|
|
48
|
+
export declare function SectionBar({ title, icon: Icon, accent, items, currentRoute, compact, end, lead }: SectionBarProps): import("react").JSX.Element;
|
|
49
|
+
export default SectionBar;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Link } from 'react-router';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import ListItemIcon from '@mui/material/ListItemIcon';
|
|
7
|
+
import ListItemText from '@mui/material/ListItemText';
|
|
8
|
+
import Menu from '@mui/material/Menu';
|
|
9
|
+
import MenuItem from '@mui/material/MenuItem';
|
|
10
|
+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
11
|
+
import { brand, chrome, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
|
|
12
|
+
/** A section owns its own path and everything nested under it. */
|
|
13
|
+
const isSectionActive = (route, currentRoute) => currentRoute === route || currentRoute.startsWith(`${route}/`);
|
|
14
|
+
/**
|
|
15
|
+
* The one bar above a module's content: what you are in, and where else you can
|
|
16
|
+
* go inside it.
|
|
17
|
+
*
|
|
18
|
+
* It lives here rather than in the shell because a module renders it in both
|
|
19
|
+
* the modes it runs in. Inside the shell one bar is drawn for whichever module
|
|
20
|
+
* is loaded; standalone, the module's own `AppLayout` draws the same bar from
|
|
21
|
+
* the same section list. The alternative — in-page MUI `Tabs` — is what had
|
|
22
|
+
* Profile and Billing looking nothing like Journal, Album and Money, and put a
|
|
23
|
+
* second row of navigation under the shell's own.
|
|
24
|
+
*
|
|
25
|
+
* Sections are laid out in full on a wide screen, since a row of five names is
|
|
26
|
+
* worth more than a button that hides four of them. Below `sm` there is no room
|
|
27
|
+
* for that, so the same list becomes a dropdown labelled with the section being
|
|
28
|
+
* shown.
|
|
29
|
+
*/
|
|
30
|
+
export function SectionBar({ title, icon: Icon, accent = brand.pinkInk, items = [], currentRoute, compact = false, end, lead }) {
|
|
31
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
32
|
+
const open = Boolean(anchorEl);
|
|
33
|
+
const close = () => setAnchorEl(null);
|
|
34
|
+
const activeItem = items.find(({ route }) => isSectionActive(route, currentRoute));
|
|
35
|
+
const sections = items.length > 0 && (_jsx(Box, { component: "nav", "aria-label": `${title} sections`, sx: compact ? compactNav : sectionRow, children: compact ? (_jsx(Button
|
|
36
|
+
// No aria-label: the accessible name is the section the button
|
|
37
|
+
// names, which is the thing a reader needs read out. The nav around
|
|
38
|
+
// it already says which module's sections these are.
|
|
39
|
+
, { "aria-haspopup": "menu", "aria-expanded": open, onClick: (event) => setAnchorEl(event.currentTarget), startIcon: activeItem?.icon ? _jsx(activeItem.icon, {}) : undefined, endIcon: _jsx(ExpandMoreIcon, {}), sx: { ...pillBase, ...activePill(accent), maxWidth: '100%', fontSize: 15 }, children: _jsx(Box, { component: "span", sx: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: activeItem?.label ?? title }) })) : (items.map(({ label, route, icon: ItemIcon }) => {
|
|
40
|
+
const active = isSectionActive(route, currentRoute);
|
|
41
|
+
return (_jsx(Button, { component: Link, to: route, "aria-current": active ? 'page' : undefined, startIcon: ItemIcon ? _jsx(ItemIcon, {}) : undefined, sx: { ...pillBase, ...(active ? activePill(accent) : idlePill) }, children: label }, route));
|
|
42
|
+
})) }));
|
|
43
|
+
// The module is already named twice over on a page that has sections: the
|
|
44
|
+
// rail entry beside it is lit, and the section pill (or, on a phone, the
|
|
45
|
+
// dropdown) says which of its pages you are on. A third copy in the corner
|
|
46
|
+
// was just furniture. A module with no sections has neither, and a bar
|
|
47
|
+
// holding nothing but the tenant button reads as a mistake, so that one
|
|
48
|
+
// keeps its name.
|
|
49
|
+
const showTitle = items.length === 0;
|
|
50
|
+
return (_jsxs(Box, { component: "header", sx: { ...bar, minHeight: compact ? 56 : 68, px: compact ? 1.25 : 2 }, children: [showTitle &&
|
|
51
|
+
(lead ?? (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }, children: [Icon && (_jsx(Box, { sx: { ...tile, color: accent, backgroundColor: `${accent}14` }, children: _jsx(Icon, { fontSize: "small" }) })), _jsx(Box, { component: "span", sx: {
|
|
52
|
+
fontSize: 17,
|
|
53
|
+
fontWeight: 700,
|
|
54
|
+
color: ink.strong,
|
|
55
|
+
whiteSpace: 'nowrap',
|
|
56
|
+
overflow: 'hidden',
|
|
57
|
+
textOverflow: 'ellipsis'
|
|
58
|
+
}, children: title })] }))), sections, _jsx(Box, { sx: { flex: 1, minWidth: 0 } }), end, items.length > 0 && (_jsx(Menu, { anchorEl: anchorEl, open: open, onClose: close, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, slotProps: {
|
|
59
|
+
paper: {
|
|
60
|
+
sx: {
|
|
61
|
+
mt: 0.5,
|
|
62
|
+
minWidth: 220,
|
|
63
|
+
borderRadius: 3,
|
|
64
|
+
border: `1px solid ${surface.divider}`,
|
|
65
|
+
boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
list: { 'aria-label': `${title} sections` }
|
|
69
|
+
}, children: items.map(({ label, route, icon: ItemIcon }) => {
|
|
70
|
+
const active = isSectionActive(route, currentRoute);
|
|
71
|
+
return (_jsxs(MenuItem, { component: Link, to: route, selected: active, "aria-current": active ? 'page' : undefined, onClick: close, sx: {
|
|
72
|
+
mx: 0.5,
|
|
73
|
+
borderRadius: 2,
|
|
74
|
+
minHeight: TOUCH_TARGET,
|
|
75
|
+
color: active ? accent : ink.body,
|
|
76
|
+
'&.Mui-selected': { backgroundColor: `${accent}14`, color: accent },
|
|
77
|
+
'&.Mui-selected:hover': { backgroundColor: `${accent}20` },
|
|
78
|
+
'&:hover': { backgroundColor: surface.sunken }
|
|
79
|
+
}, children: [ItemIcon && (_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: _jsx(ItemIcon, { fontSize: "small" }) })), _jsx(ListItemText, { slotProps: { primary: { sx: { fontWeight: active ? 700 : 500 } } }, children: label })] }, route));
|
|
80
|
+
}) }))] }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Frosted, like the navigation rail beside it, so the two read as one piece of
|
|
84
|
+
* chrome with the page washing through both.
|
|
85
|
+
*/
|
|
86
|
+
const bar = {
|
|
87
|
+
flexShrink: 0,
|
|
88
|
+
position: 'sticky',
|
|
89
|
+
top: 0,
|
|
90
|
+
zIndex: 2,
|
|
91
|
+
display: 'flex',
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
gap: 1,
|
|
94
|
+
py: 1,
|
|
95
|
+
...chrome,
|
|
96
|
+
borderBottom: `1px solid ${surface.divider}`
|
|
97
|
+
};
|
|
98
|
+
const tile = {
|
|
99
|
+
width: 34,
|
|
100
|
+
height: 34,
|
|
101
|
+
borderRadius: 2.5,
|
|
102
|
+
display: 'grid',
|
|
103
|
+
placeItems: 'center'
|
|
104
|
+
};
|
|
105
|
+
const sectionRow = {
|
|
106
|
+
display: 'flex',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
gap: 0.5,
|
|
109
|
+
minWidth: 0,
|
|
110
|
+
overflowX: 'auto',
|
|
111
|
+
// The row scrolls when a module has more sections than fit; a scrollbar
|
|
112
|
+
// across the chrome would read as a seam.
|
|
113
|
+
scrollbarWidth: 'none',
|
|
114
|
+
'&::-webkit-scrollbar': { display: 'none' }
|
|
115
|
+
};
|
|
116
|
+
const compactNav = { minWidth: 0, display: 'flex' };
|
|
117
|
+
const pillBase = {
|
|
118
|
+
flexShrink: 0,
|
|
119
|
+
minWidth: 0,
|
|
120
|
+
height: 36,
|
|
121
|
+
px: 1.75,
|
|
122
|
+
borderRadius: 999,
|
|
123
|
+
textTransform: 'none',
|
|
124
|
+
fontSize: 14.5,
|
|
125
|
+
whiteSpace: 'nowrap',
|
|
126
|
+
transition: 'background-color .2s ease, color .2s ease',
|
|
127
|
+
'& .MuiButton-startIcon svg': { fontSize: 19 },
|
|
128
|
+
'& .MuiButton-startIcon, & .MuiButton-endIcon': { flexShrink: 0 }
|
|
129
|
+
};
|
|
130
|
+
const idlePill = {
|
|
131
|
+
fontWeight: 500,
|
|
132
|
+
color: ink.body,
|
|
133
|
+
'&:hover': { backgroundColor: surface.sunken, color: ink.strong }
|
|
134
|
+
};
|
|
135
|
+
/** The section being shown, and the compact dropdown that stands in for it. */
|
|
136
|
+
const activePill = (accent) => ({
|
|
137
|
+
fontWeight: 700,
|
|
138
|
+
color: accent,
|
|
139
|
+
backgroundColor: `${accent}14`,
|
|
140
|
+
boxShadow: `inset 0 0 0 1px ${accent}29`,
|
|
141
|
+
'&:hover': { backgroundColor: `${accent}20` }
|
|
142
|
+
});
|
|
143
|
+
export default SectionBar;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SectionBar, default } from './SectionBar';
|
|
@@ -17,8 +17,17 @@ export interface TenantSelectorProps {
|
|
|
17
17
|
loading?: boolean;
|
|
18
18
|
/** Error state */
|
|
19
19
|
error?: string | null;
|
|
20
|
+
/** Drops the "Viewing" caption and tightens the button, for a phone's bar. */
|
|
21
|
+
compact?: boolean;
|
|
20
22
|
/** Custom styling */
|
|
21
23
|
sx?: SxProps<Theme>;
|
|
22
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Whose data the page is showing: the person's own, or one of their groups.
|
|
27
|
+
*
|
|
28
|
+
* Drawn as the same rounded pill the section bar beside it uses, in the brand
|
|
29
|
+
* violet rather than MUI's default blue, so the one control that changes what
|
|
30
|
+
* every module shows reads as part of the chrome instead of a stray form field.
|
|
31
|
+
*/
|
|
23
32
|
declare const TenantSelector: React.FC<TenantSelectorProps>;
|
|
24
33
|
export default TenantSelector;
|
|
@@ -2,7 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { Box, Button, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Typography, Chip } from '@mui/material';
|
|
4
4
|
import { PersonOutlined, Groups, ExpandMore, Check } from '@mui/icons-material';
|
|
5
|
-
|
|
5
|
+
import { brand, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
|
|
6
|
+
/**
|
|
7
|
+
* Whose data the page is showing: the person's own, or one of their groups.
|
|
8
|
+
*
|
|
9
|
+
* Drawn as the same rounded pill the section bar beside it uses, in the brand
|
|
10
|
+
* violet rather than MUI's default blue, so the one control that changes what
|
|
11
|
+
* every module shows reads as part of the chrome instead of a stray form field.
|
|
12
|
+
*/
|
|
13
|
+
const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading = false, error = null, compact = false, sx = {} }) => {
|
|
6
14
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
7
15
|
const isOpen = Boolean(anchorEl);
|
|
8
16
|
const handleClick = (event) => {
|
|
@@ -17,7 +25,7 @@ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading
|
|
|
17
25
|
};
|
|
18
26
|
const getDisplayName = (tenant) => {
|
|
19
27
|
if (!tenant)
|
|
20
|
-
return '
|
|
28
|
+
return 'Choose context';
|
|
21
29
|
return tenant.type === 'personal' ? 'Personal' : tenant.name;
|
|
22
30
|
};
|
|
23
31
|
const getIcon = (type) => {
|
|
@@ -26,52 +34,55 @@ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading
|
|
|
26
34
|
if (error) {
|
|
27
35
|
return _jsx(Chip, { label: "Error loading contexts", color: "error", size: "small", sx: { ...sx } });
|
|
28
36
|
}
|
|
29
|
-
return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
|
|
37
|
+
return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, "aria-haspopup": "menu", "aria-expanded": isOpen, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
|
|
30
38
|
textTransform: 'none',
|
|
31
|
-
fontWeight:
|
|
32
|
-
borderRadius:
|
|
33
|
-
px:
|
|
34
|
-
|
|
35
|
-
minWidth:
|
|
39
|
+
fontWeight: 600,
|
|
40
|
+
borderRadius: 999,
|
|
41
|
+
px: compact ? 1.25 : 1.75,
|
|
42
|
+
minHeight: compact ? 38 : TOUCH_TARGET,
|
|
43
|
+
minWidth: 0,
|
|
36
44
|
justifyContent: 'flex-start',
|
|
37
|
-
color:
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
color: ink.strong,
|
|
46
|
+
backgroundColor: surface.paper,
|
|
47
|
+
border: `1px solid ${surface.divider}`,
|
|
48
|
+
transition: 'border-color .2s ease, box-shadow .2s ease',
|
|
49
|
+
'& .MuiButton-startIcon': { color: brand.violetInk, flexShrink: 0 },
|
|
50
|
+
'& .MuiButton-endIcon': { color: ink.muted, flexShrink: 0 },
|
|
40
51
|
'&:hover': {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fontSize: 10
|
|
48
|
-
}, children: "Context" }), _jsx(Typography, { variant: "body2", sx: {
|
|
52
|
+
backgroundColor: surface.paper,
|
|
53
|
+
borderColor: `${brand.violetInk}66`,
|
|
54
|
+
boxShadow: `0 8px 20px -14px ${brand.violetInk}`
|
|
55
|
+
},
|
|
56
|
+
'&.Mui-disabled': { color: ink.muted, border: `1px solid ${surface.divider}` }
|
|
57
|
+
}, children: _jsxs(Box, { sx: { flexGrow: 1, textAlign: 'left', overflow: 'hidden' }, children: [!compact && (_jsx(Typography, { variant: "caption", sx: { color: ink.muted, display: 'block', fontSize: 10, letterSpacing: 0.4, lineHeight: 1.2 }, children: "Viewing" })), _jsx(Typography, { variant: "body2", sx: {
|
|
49
58
|
display: 'block',
|
|
59
|
+
fontWeight: 600,
|
|
60
|
+
lineHeight: 1.3,
|
|
50
61
|
whiteSpace: 'nowrap',
|
|
51
62
|
overflow: 'hidden',
|
|
52
|
-
textOverflow: 'ellipsis'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
textOverflow: 'ellipsis'
|
|
64
|
+
}, children: loading ? 'Loading...' : getDisplayName(selectedTenant) })] }) }), _jsxs(Menu, { anchorEl: anchorEl, open: isOpen, onClose: handleClose, anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' }, slotProps: {
|
|
65
|
+
paper: {
|
|
66
|
+
sx: {
|
|
67
|
+
mt: 0.5,
|
|
68
|
+
minWidth: 248,
|
|
69
|
+
borderRadius: 3,
|
|
70
|
+
border: `1px solid ${surface.divider}`,
|
|
71
|
+
boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, children: [_jsx(MenuItem, { disabled: true, sx: { '&.Mui-disabled': { opacity: 1 } }, children: _jsx(Typography, { variant: "caption", sx: { color: ink.muted, fontWeight: 700, letterSpacing: 0.4 }, children: "Switch to" }) }), _jsx(Divider, {}), tenants.map((tenant) => {
|
|
64
75
|
const isSelected = selectedTenant?.id === tenant.id;
|
|
65
76
|
return (_jsxs(MenuItem, { onClick: () => handleTenantSelect(tenant), selected: isSelected, sx: {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
mx: 0.5,
|
|
78
|
+
py: 1.25,
|
|
79
|
+
borderRadius: 2,
|
|
80
|
+
minHeight: TOUCH_TARGET,
|
|
81
|
+
color: isSelected ? brand.violetInk : ink.body,
|
|
82
|
+
'&.Mui-selected': { backgroundColor: `${brand.violetInk}14`, color: brand.violetInk },
|
|
83
|
+
'&.Mui-selected:hover': { backgroundColor: `${brand.violetInk}20` },
|
|
84
|
+
'&:hover': { backgroundColor: surface.sunken }
|
|
85
|
+
}, children: [_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: getIcon(tenant.type) }), _jsx(ListItemText, { primary: _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }, children: [_jsx(Typography, { variant: "body2", noWrap: true, sx: { fontWeight: isSelected ? 700 : 500 }, children: tenant.type === 'personal' ? 'Personal' : tenant.name }), isSelected && _jsx(Check, { fontSize: "small", sx: { color: 'inherit' } })] }), secondary: _jsx(Typography, { variant: "caption", sx: { color: ink.muted }, children: tenant.type === 'personal' ? 'Your personal data' : 'Group workspace' }) })] }, tenant.id));
|
|
86
|
+
}), tenants.length === 0 && !loading && (_jsx(MenuItem, { disabled: true, children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: { color: ink.muted }, children: "No contexts available" }) }) }))] })] }));
|
|
76
87
|
};
|
|
77
88
|
export default TenantSelector;
|
|
@@ -12,4 +12,4 @@ export interface UseTenantResult {
|
|
|
12
12
|
* Custom hook for managing tenant selection
|
|
13
13
|
* Handles both personal account and group contexts
|
|
14
14
|
*/
|
|
15
|
-
export declare const useTenant: (groupApiUrl: string,
|
|
15
|
+
export declare const useTenant: (groupApiUrl: string, userId?: string) => UseTenantResult;
|
|
@@ -7,6 +7,47 @@ export { TenantProvider, useTenantListener, useTenantContext };
|
|
|
7
7
|
// Owned here, but declared in frontend-core so `logout()` can clear it — see
|
|
8
8
|
// the note on session-scoped storage there.
|
|
9
9
|
const STORAGE_KEY = TENANT_STORAGE_KEY;
|
|
10
|
+
/**
|
|
11
|
+
* Which context each account last worked in, keyed by user id.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately *not* in SESSION_SCOPED_KEYS: `logout()` clears the active
|
|
14
|
+
* selection above, which is right — `useTenantFilter` reads that key
|
|
15
|
+
* synchronously with no idea who is signed in, so leaving a group id there
|
|
16
|
+
* across a sign-out is what sent the next account's requests out with a
|
|
17
|
+
* groupId it is not a member of. This is the other half: a preference, per
|
|
18
|
+
* account, that survives a sign-out so signing back in returns you to the
|
|
19
|
+
* group you were working in rather than dropping you on Personal every time.
|
|
20
|
+
*
|
|
21
|
+
* Keyed by email rather than stamped with one, so a second account signing in
|
|
22
|
+
* on the same browser reads its own entry or none, never someone else's.
|
|
23
|
+
*/
|
|
24
|
+
const LAST_TENANT_KEY = 'lastTenantByUser';
|
|
25
|
+
const readLastTenants = () => {
|
|
26
|
+
try {
|
|
27
|
+
const raw = localStorage.getItem(LAST_TENANT_KEY);
|
|
28
|
+
return raw ? JSON.parse(raw) : {};
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
logger.warn('Failed to read the last tenant per account', { error: err });
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** The context this account was last in, or null for an account with no history. */
|
|
36
|
+
const loadLastTenant = (userId) => {
|
|
37
|
+
if (!userId)
|
|
38
|
+
return null;
|
|
39
|
+
return readLastTenants()[userId] ?? null;
|
|
40
|
+
};
|
|
41
|
+
const rememberLastTenant = (tenant, userId) => {
|
|
42
|
+
if (!userId)
|
|
43
|
+
return;
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(LAST_TENANT_KEY, JSON.stringify({ ...readLastTenants(), [userId]: tenant }));
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
logger.warn('Failed to remember the last tenant', { error: err });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
10
51
|
/** The stamp is storage bookkeeping; consumers see a plain TenantOption. */
|
|
11
52
|
const toOption = ({ id, name, type }) => ({ id, name, type });
|
|
12
53
|
// Global cache to prevent multiple simultaneous requests across all hook instances
|
|
@@ -19,12 +60,12 @@ const CACHE_DURATION = 30000; // 30 seconds
|
|
|
19
60
|
// user as well as URL: the cache outlives a sign-out, and handing the incoming
|
|
20
61
|
// account the previous one's group list is how a stale selection gets
|
|
21
62
|
// "validated" and survives.
|
|
22
|
-
const fetchGroupsGlobally = async (groupApiUrl,
|
|
63
|
+
const fetchGroupsGlobally = async (groupApiUrl, userId) => {
|
|
23
64
|
requestCounter++;
|
|
24
65
|
logger.debug('fetchGroupsGlobally called', { requestNumber: requestCounter, groupApiUrl });
|
|
25
66
|
// Check if we have a valid cached request for the same API URL and user
|
|
26
67
|
const now = Date.now();
|
|
27
|
-
const cacheKey = `${
|
|
68
|
+
const cacheKey = `${userId ?? ''}|${groupApiUrl}`;
|
|
28
69
|
if (globalGroupsFetchCache &&
|
|
29
70
|
globalGroupsCacheKey === cacheKey &&
|
|
30
71
|
now - globalGroupsCacheTimestamp < CACHE_DURATION) {
|
|
@@ -98,7 +139,7 @@ const fetchGroupsGlobally = async (groupApiUrl, userEmail) => {
|
|
|
98
139
|
* Custom hook for managing tenant selection
|
|
99
140
|
* Handles both personal account and group contexts
|
|
100
141
|
*/
|
|
101
|
-
export const useTenant = (groupApiUrl,
|
|
142
|
+
export const useTenant = (groupApiUrl, userId) => {
|
|
102
143
|
const [selectedTenant, setSelectedTenantState] = useState(null);
|
|
103
144
|
const [availableTenants, setAvailableTenants] = useState([]);
|
|
104
145
|
const [loading, setLoading] = useState(true);
|
|
@@ -120,20 +161,22 @@ export const useTenant = (groupApiUrl, userEmail) => {
|
|
|
120
161
|
// Save tenant selection to localStorage, stamped with the current account
|
|
121
162
|
const saveTenant = useCallback((tenant) => {
|
|
122
163
|
try {
|
|
123
|
-
const stored = { ...tenant,
|
|
164
|
+
const stored = { ...tenant, userId };
|
|
124
165
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
125
166
|
}
|
|
126
167
|
catch (err) {
|
|
127
168
|
logger.warn('Failed to save tenant selection', { error: err });
|
|
128
169
|
}
|
|
129
|
-
|
|
170
|
+
// Outlives the sign-out that clears the line above.
|
|
171
|
+
rememberLastTenant(tenant, userId);
|
|
172
|
+
}, [userId]);
|
|
130
173
|
// Fetch available groups from API using user authentication
|
|
131
174
|
const fetchGroups = useCallback(async () => {
|
|
132
|
-
return fetchGroupsGlobally(groupApiUrl,
|
|
133
|
-
}, [groupApiUrl,
|
|
175
|
+
return fetchGroupsGlobally(groupApiUrl, userId);
|
|
176
|
+
}, [groupApiUrl, userId]);
|
|
134
177
|
// Refresh tenants list
|
|
135
178
|
const refreshTenants = useCallback(async () => {
|
|
136
|
-
if (!
|
|
179
|
+
if (!userId) {
|
|
137
180
|
setLoading(false);
|
|
138
181
|
return;
|
|
139
182
|
}
|
|
@@ -155,22 +198,35 @@ export const useTenant = (groupApiUrl, userEmail) => {
|
|
|
155
198
|
// A selection made by a different account is not ours to keep, even if
|
|
156
199
|
// this account happens to belong to that group too — an unstamped one
|
|
157
200
|
// predates this check and is validated by membership alone.
|
|
158
|
-
const belongsToThisUser = !savedTenant?.
|
|
201
|
+
const belongsToThisUser = !savedTenant?.userId || savedTenant.userId === userId;
|
|
159
202
|
const stillAMember = allTenants.some((t) => t.id === savedTenant?.id);
|
|
160
203
|
if (savedTenant && belongsToThisUser && stillAMember) {
|
|
161
204
|
setSelectedTenantState(toOption(savedTenant));
|
|
162
205
|
}
|
|
163
206
|
else {
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
207
|
+
// No active selection this account can use. Before falling back to
|
|
208
|
+
// Personal, see where this account was last working: a sign-out clears
|
|
209
|
+
// the active key, and without this every sign-in landed on Personal
|
|
210
|
+
// however long you had been in a group.
|
|
211
|
+
const lastUsed = loadLastTenant(userId);
|
|
212
|
+
const restorable = lastUsed && lastUsed.type === 'group' && allTenants.some((tenant) => tenant.id === lastUsed.id)
|
|
213
|
+
? lastUsed
|
|
214
|
+
: null;
|
|
215
|
+
const next = restorable ?? personalTenant;
|
|
216
|
+
setSelectedTenantState(next);
|
|
217
|
+
saveTenant(next);
|
|
167
218
|
// Sibling components read the raw storage value synchronously on mount
|
|
168
219
|
// (useTenantFilter), so by now they have already fired their requests
|
|
169
|
-
// against
|
|
170
|
-
//
|
|
171
|
-
//
|
|
220
|
+
// against whatever storage said a moment ago — the discarded groupId,
|
|
221
|
+
// or nothing at all where we have just restored one. Reload for the
|
|
222
|
+
// same reason switching tenant by hand reloads: it is the only way
|
|
223
|
+
// this app re-reads the context. Storage now agrees with state, so the
|
|
172
224
|
// next pass takes the branch above and this does not loop.
|
|
173
|
-
if (
|
|
225
|
+
if (restorable) {
|
|
226
|
+
logger.debug('Restored the context this account last used', { name: restorable.name });
|
|
227
|
+
window.location.reload();
|
|
228
|
+
}
|
|
229
|
+
else if (savedTenant?.type === 'group') {
|
|
174
230
|
logger.debug('Discarded a tenant selection this account cannot use; reloading');
|
|
175
231
|
window.location.reload();
|
|
176
232
|
}
|
|
@@ -197,7 +253,7 @@ export const useTenant = (groupApiUrl, userEmail) => {
|
|
|
197
253
|
finally {
|
|
198
254
|
setLoading(false);
|
|
199
255
|
}
|
|
200
|
-
}, [
|
|
256
|
+
}, [userId, fetchGroups, loadSavedTenant, saveTenant]);
|
|
201
257
|
// Set selected tenant with page reload
|
|
202
258
|
const setSelectedTenant = useCallback((tenant) => {
|
|
203
259
|
logger.debug('Tenant changing', { to: tenant.type === 'personal' ? 'Personal' : tenant.name });
|
package/dist/index.d.ts
CHANGED
|
@@ -21,12 +21,14 @@ export { LoginButton } from './components/LoginButton';
|
|
|
21
21
|
export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
|
|
22
22
|
export { AuthHeader } from './components/AuthHeader';
|
|
23
23
|
export { AppLayout } from './components/AppLayout';
|
|
24
|
+
export { SectionBar } from './components/SectionBar';
|
|
24
25
|
export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
|
|
25
26
|
export { TenantSelector, TenantProvider, useTenant, useTenantListener, useTenantContext, useTenantFilter } from './components/tenant';
|
|
26
27
|
export type { LoaderProps } from './components/Loader';
|
|
27
28
|
export type { ErrorPageProps } from './components/ErrorPage';
|
|
28
29
|
export type { AppErrorBoundaryProps } from './components/AppErrorBoundary';
|
|
29
30
|
export type { AppLayoutProps } from './components/AppLayout';
|
|
31
|
+
export type { SectionBarProps, SectionBarItem } from './components/SectionBar';
|
|
30
32
|
export type { UnauthorizedPageProps } from './components/UnauthorizedPage';
|
|
31
33
|
export type { ProtectedRouteProps, ProtectedRouteAuthService } from './components/ProtectedRoute';
|
|
32
34
|
export type { LoginButtonProps } from './components/LoginButton';
|
|
@@ -37,4 +39,4 @@ export type { UsageMeterProps, UseEntitlementsResult } from './components/UsageM
|
|
|
37
39
|
export { createConfigProvider } from './config/createConfigProvider';
|
|
38
40
|
export type { CreateConfigProviderOptions } from './config/createConfigProvider';
|
|
39
41
|
export { toOrigin } from './routing';
|
|
40
|
-
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
|
42
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export { LoginButton } from './components/LoginButton';
|
|
|
29
29
|
export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
|
|
30
30
|
export { AuthHeader } from './components/AuthHeader';
|
|
31
31
|
export { AppLayout } from './components/AppLayout';
|
|
32
|
+
export { SectionBar } from './components/SectionBar';
|
|
32
33
|
// Entitlements: the usage bar and the hook that feeds it
|
|
33
34
|
export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
|
|
34
35
|
// Tenant selection: provider, selector, and hooks shared across domain frontends
|
|
@@ -39,4 +40,4 @@ export { createConfigProvider } from './config/createConfigProvider';
|
|
|
39
40
|
export { toOrigin } from './routing';
|
|
40
41
|
// Theme: the palette, the MUI theme built from it, and the raw tokens for the
|
|
41
42
|
// places that have to name a colour outside a `sx` prop.
|
|
42
|
-
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
|
43
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
package/dist/theme/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
|
|
1
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
|
|
2
2
|
export { createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS, gutterSx } from './createTumbalandTheme';
|
package/dist/theme/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
|
|
1
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
|
|
2
2
|
export { createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS, gutterSx } from './createTumbalandTheme';
|
package/dist/theme/tokens.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export declare const moduleAccent: {
|
|
|
79
79
|
readonly journal: "#e11d48";
|
|
80
80
|
readonly album: "#2563eb";
|
|
81
81
|
readonly money: "#15803d";
|
|
82
|
-
readonly profile: "#
|
|
82
|
+
readonly profile: "#d61f92";
|
|
83
83
|
readonly groups: "#7c3aed";
|
|
84
84
|
readonly billing: "#4f46e5";
|
|
85
85
|
};
|
|
@@ -114,6 +114,23 @@ export declare const glass: {
|
|
|
114
114
|
readonly border: "1px solid rgba(255,255,255,0.7)";
|
|
115
115
|
readonly boxShadow: "0 10px 30px -22px rgba(31,38,135,0.5)";
|
|
116
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* The frosted chrome around the content: the navigation rail, the tab bar under
|
|
119
|
+
* it on phones, and the header bar above it.
|
|
120
|
+
*
|
|
121
|
+
* Named once because the three meet at a corner. They each had their own
|
|
122
|
+
* rgba white and their own blur, so the seam between the rail and the bar it
|
|
123
|
+
* touches was visible as a faint step in the tint.
|
|
124
|
+
*
|
|
125
|
+
* Not `glass`: that is the card, which is rounded and has a light border of its
|
|
126
|
+
* own. A full-height rail with a 28px radius and a white outline is a card the
|
|
127
|
+
* page happens to be next to.
|
|
128
|
+
*/
|
|
129
|
+
export declare const chrome: {
|
|
130
|
+
readonly background: "rgba(255,255,255,0.78)";
|
|
131
|
+
readonly backdropFilter: "blur(14px) saturate(150%)";
|
|
132
|
+
readonly WebkitBackdropFilter: "blur(14px) saturate(150%)";
|
|
133
|
+
};
|
|
117
134
|
/** A card that responds to being pointed at. `accent` tints the lifted shadow. */
|
|
118
135
|
export declare const glassInteractive: (accent?: string) => {
|
|
119
136
|
readonly transition: "transform .2s ease, box-shadow .2s ease";
|
package/dist/theme/tokens.js
CHANGED
|
@@ -82,7 +82,10 @@ export const moduleAccent = {
|
|
|
82
82
|
journal: '#e11d48',
|
|
83
83
|
album: '#2563eb',
|
|
84
84
|
money: '#15803d',
|
|
85
|
-
|
|
85
|
+
// The identity pink, in its readable form. This was `ink.strong`, which is
|
|
86
|
+
// a text colour: as the tint behind Profile's current section it drew a grey
|
|
87
|
+
// pill in the one place the product's own colour belongs.
|
|
88
|
+
profile: brand.pinkInk,
|
|
86
89
|
groups: brand.violetInk,
|
|
87
90
|
billing: '#4f46e5'
|
|
88
91
|
};
|
|
@@ -117,6 +120,23 @@ export const glass = {
|
|
|
117
120
|
border: '1px solid rgba(255,255,255,0.7)',
|
|
118
121
|
boxShadow: '0 10px 30px -22px rgba(31,38,135,0.5)'
|
|
119
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* The frosted chrome around the content: the navigation rail, the tab bar under
|
|
125
|
+
* it on phones, and the header bar above it.
|
|
126
|
+
*
|
|
127
|
+
* Named once because the three meet at a corner. They each had their own
|
|
128
|
+
* rgba white and their own blur, so the seam between the rail and the bar it
|
|
129
|
+
* touches was visible as a faint step in the tint.
|
|
130
|
+
*
|
|
131
|
+
* Not `glass`: that is the card, which is rounded and has a light border of its
|
|
132
|
+
* own. A full-height rail with a 28px radius and a white outline is a card the
|
|
133
|
+
* page happens to be next to.
|
|
134
|
+
*/
|
|
135
|
+
export const chrome = {
|
|
136
|
+
background: 'rgba(255,255,255,0.78)',
|
|
137
|
+
backdropFilter: 'blur(14px) saturate(150%)',
|
|
138
|
+
WebkitBackdropFilter: 'blur(14px) saturate(150%)'
|
|
139
|
+
};
|
|
120
140
|
/** A card that responds to being pointed at. `accent` tints the lifted shadow. */
|
|
121
141
|
export const glassInteractive = (accent = brand.purple) => ({
|
|
122
142
|
...glass,
|