Big performance increase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-09-19 13:07:30 +01:00
parent 24a3a03c65
commit e446098a76
18 changed files with 974 additions and 3382 deletions

View File

@ -4053,6 +4053,7 @@ body.objectKanbanColumnResizing * {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
content-visibility: hidden;
} }
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing { .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
@ -4062,6 +4063,7 @@ body.objectKanbanColumnResizing * {
pointer-events: none; pointer-events: none;
transform: translate(-100%, 0); transform: translate(-100%, 0);
z-index: 0; z-index: 0;
content-visibility: visible;
} }
.dashboard-tab-preview-capture-frame { .dashboard-tab-preview-capture-frame {

545
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -128,9 +128,6 @@
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electrobun": "1.18.1", "electrobun": "1.18.1",
"electron": "^38.7.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",

2153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -65,14 +65,7 @@ const getRouter = () => {
return BrowserRouter return BrowserRouter
} }
const AppContent = () => { const renderEmpty = () => (
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider
theme={themeConfig}
renderEmpty={() => (
<div style={{ margin: '32px' }}> <div style={{ margin: '32px' }}>
<MissingPlaceholder <MissingPlaceholder
message='No data.' message='No data.'
@ -80,8 +73,14 @@ const AppContent = () => {
hasBorder={false} hasBorder={false}
/> />
</div> </div>
)} )
>
const AppContent = () => {
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
<App> <App>
<Router> <Router>
<ElectronProvider> <ElectronProvider>

View File

@ -14,7 +14,6 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter' import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation' import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext' import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext' import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './context/ElectronContext' import { ElectronContext } from './context/ElectronContext'
@ -52,7 +51,6 @@ const DashboardLayout = ({ children }) => {
) )
return ( return (
<MessageProvider>
<Layout <Layout
style={{ height: 'var(--unit-100vh)' }} style={{ height: 'var(--unit-100vh)' }}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`} className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
@ -80,7 +78,6 @@ const DashboardLayout = ({ children }) => {
</DashboardSidebarSplitter> </DashboardSidebarSplitter>
{isMobile ? <MobileFooterNavigation /> : null} {isMobile ? <MobileFooterNavigation /> : null}
</Layout> </Layout>
</MessageProvider>
) )
} }

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react'
import { Button, Flex, Popover, Typography } from 'antd' import { Button, Flex, Popover, Typography } from 'antd'
import DashboardTabPreview from './DashboardTabPreview' import DashboardTabPreview from './DashboardTabPreview'
import classNames from 'classnames' import classNames from 'classnames'
@ -66,7 +66,7 @@ DashboardTabLabel.propTypes = {
onClose: PropTypes.func onClose: PropTypes.func
} }
const DashboardTabItem = ({ const DashboardTabItem = memo(function DashboardTabItem({
tab, tab,
tabIndex, tabIndex,
isSelected, isSelected,
@ -78,9 +78,9 @@ const DashboardTabItem = ({
onKeyDown, onKeyDown,
onDragOver, onDragOver,
onDrop, onDrop,
onClose onClose,
}) => { captureTabPreview
const { captureTabPreview } = useNavigationTabs() }) {
const { const {
src: previewSrc, src: previewSrc,
theme: previewTheme, theme: previewTheme,
@ -174,7 +174,9 @@ const DashboardTabItem = ({
{tabButton} {tabButton}
</Popover> </Popover>
) )
} })
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = { DashboardTabItem.propTypes = {
tab: PropTypes.shape({ tab: PropTypes.shape({
@ -193,7 +195,8 @@ DashboardTabItem.propTypes = {
onKeyDown: PropTypes.func.isRequired, onKeyDown: PropTypes.func.isRequired,
onDragOver: PropTypes.func.isRequired, onDragOver: PropTypes.func.isRequired,
onDrop: PropTypes.func.isRequired, onDrop: PropTypes.func.isRequired,
onClose: PropTypes.func onClose: PropTypes.func,
captureTabPreview: PropTypes.func
} }
const STICKY_EDGE_INSET = 20 const STICKY_EDGE_INSET = 20
@ -546,7 +549,8 @@ const DashboardTabs = () => {
reorderTabs, reorderTabs,
handleTabDragStart, handleTabDragStart,
handleTabDragEnd, handleTabDragEnd,
handleExternalTabDrop handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs() } = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll( const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId, activeTabId,
@ -720,7 +724,8 @@ const DashboardTabs = () => {
onKeyDown: handleKeyDown, onKeyDown: handleKeyDown,
onDragOver: handleItemDragOver, onDragOver: handleItemDragOver,
onDrop: handleItemDrop, onDrop: handleItemDrop,
onClose: closeTab onClose: closeTab,
captureTabPreview
} }
return ( return (

View File

@ -1,11 +1,11 @@
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd' import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { createElement } from 'react' import { createElement, memo } from 'react'
import Thumbnail from './Thumbnail' import Thumbnail from './Thumbnail'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
const ObjectCard = ({ const ObjectCard = memo(function ObjectCard({
isSkeleton = false, isSkeleton = false,
model, model,
modelProperties, modelProperties,
@ -16,7 +16,7 @@ const ObjectCard = ({
renderActions, renderActions,
lazyLoading = false, lazyLoading = false,
cardStyle = 'borderless' cardStyle = 'borderless'
}) => { }) {
const descriptionItems = [] const descriptionItems = []
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } }) const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
@ -169,7 +169,9 @@ const ObjectCard = ({
</Flex> </Flex>
</Card> </Card>
) )
} })
ObjectCard.displayName = 'ObjectCard'
ObjectCard.propTypes = { ObjectCard.propTypes = {
model: PropTypes.object.isRequired, model: PropTypes.object.isRequired,

View File

@ -14,6 +14,7 @@ import PropTypes from 'prop-types'
import classNames from 'classnames' import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ObjectCard from './ObjectCard' import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn' import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader' import ObjectKanbanHeader from './ObjectKanbanHeader'
@ -287,6 +288,7 @@ const ObjectKanban = forwardRef(
subscribeToObjectTypeUpdates subscribeToObjectTypeUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const isTabActive = useIsNavigationTabActive()
const columnRefs = useRef({}) const columnRefs = useRef({})
const headerTrackRef = useRef(null) const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null) const skeletonTrackRef = useRef(null)
@ -720,7 +722,7 @@ const ObjectKanban = forwardRef(
}, [connected, token]) }, [connected, token])
useEffect(() => { useEffect(() => {
if (connected !== true || !type || !token) return if (connected !== true || !type || !token || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -735,10 +737,10 @@ const ObjectKanban = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, token]) }, [type, connected, subscribeToAllObjectUpdates, token, isTabActive])
useEffect(() => { useEffect(() => {
if (connected !== true || !token) return if (connected !== true || !token || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -757,7 +759,7 @@ const ObjectKanban = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, token]) }, [type, connected, token, isTabActive])
useImperativeHandle( useImperativeHandle(
ref, ref,

View File

@ -1,5 +1,6 @@
import { import {
forwardRef, forwardRef,
memo,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useEffect, useEffect,
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ActionsIcon from '../../Icons/ActionsIcon' import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5 const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {} const EMPTY_MASTER_FILTER = {}
const EMPTY_VISIBLE_COLUMNS = {}
const getCardColSpan = (containerWidth) => { const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2 if (containerWidth >= 2980) return 2
@ -291,7 +294,8 @@ EditableRow.propTypes = {
onRegister: PropTypes.func onRegister: PropTypes.func
} }
const ObjectTable = forwardRef( const ObjectTable = memo(
forwardRef(
( (
{ {
type, type,
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
initialPage = 1, initialPage = 1,
viewMode: viewModeProp, viewMode: viewModeProp,
cards = false, cards = false,
visibleColumns = {}, visibleColumns = EMPTY_VISIBLE_COLUMNS,
masterFilter, masterFilter,
size = 'middle', size = 'middle',
onStateChange, onStateChange,
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
const { token, userProfile } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext) const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions() const { callAction } = useActions()
const isTabActive = useIsNavigationTabActive()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null const listViewFilter = objectListView?.listViewFilter ?? null
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
return {} return {}
}, []) }, [])
const rowActions = const rowActions = useMemo(
model.actions?.filter((action) => action.row == true) || [] () => model.actions?.filter((action) => action.row == true) || [],
[model]
)
const renderActions = useCallback(
(objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
},
[callAction, model, onRowAction, rowActions, type, userProfile]
)
const createSkeletonData = useCallback( const createSkeletonData = useCallback(
(pageNum) => { (pageNum) => {
@ -602,61 +667,6 @@ const ObjectTable = forwardRef(
) )
}, []) }, [])
const renderActions = (objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
}
const fetchPage = useCallback( const fetchPage = useCallback(
async (pageNum = 1, filter = null, sorter = null) => { async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) { if (filter == null) {
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
// Subscribe to all object updates for this type (list/cards only) // Subscribe to all object updates for this type (list/cards only)
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true || !type) return if (isKanban || connected !== true || !type || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, isKanban]) }, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true) return if (isKanban || connected !== true || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, isKanban]) }, [type, connected, isKanban, isTabActive])
const updateData = useCallback( const updateData = useCallback(
(id, updatedData) => { (id, updatedData) => {
@ -1706,7 +1716,8 @@ const ObjectTable = forwardRef(
return () => registerPageSorter({}) return () => registerPageSorter({})
}, [effectiveSorter, registerPageSorter, registerObjectListSorter]) }, [effectiveSorter, registerPageSorter, registerObjectListSorter])
const getFilterDropdown = ({ const getFilterDropdown = useCallback(
({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
confirm, confirm,
@ -1727,6 +1738,8 @@ const ObjectTable = forwardRef(
filter={effectiveFilter} filter={effectiveFilter}
masterFilter={resolvedMasterFilter} masterFilter={resolvedMasterFilter}
/> />
),
[effectiveFilter, resolvedMasterFilter, type]
) )
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
@ -1858,9 +1871,47 @@ const ObjectTable = forwardRef(
] ]
) )
const modelProperties = getModelProperties(type) const modelProperties = useMemo(() => getModelProperties(type), [type])
// Table columns from model properties
const columnsWithSkeleton = [ useEffect(() => {
pagesRef.current = pages
}, [pages])
useEffect(() => {
if (!expandHeight || isCards || isKanban) return
const findAntTableBody = (element) => {
if (!element) return null
if (element.classList?.contains('ant-table-body')) return element
for (const child of element.children) {
const found = findAntTableBody(child)
if (found) return found
}
return null
}
const root = tableRef.current?.nativeElement ?? tableRef.current
const tableBody = findAntTableBody(root)
if (!tableBody) return
tableBody.style.minHeight = adjustedScrollHeight
return () => {
tableBody.style.minHeight = ''
}
}, [
expandHeight,
adjustedScrollHeight,
isCards,
isKanban,
loading,
tableData
])
const hasRealTableRows = tableData.some((item) => !item?.isSkeleton)
const columnsWithSkeleton = useMemo(() => {
const columns = [
{ {
title: title:
loading || lazyLoading ? ( loading || lazyLoading ? (
@ -1919,51 +1970,14 @@ const ObjectTable = forwardRef(
} }
] ]
useEffect(() => {
pagesRef.current = pages
}, [pages])
useEffect(() => {
if (!expandHeight || isCards || isKanban) return
const findAntTableBody = (element) => {
if (!element) return null
if (element.classList?.contains('ant-table-body')) return element
for (const child of element.children) {
const found = findAntTableBody(child)
if (found) return found
}
return null
}
const root = tableRef.current?.nativeElement ?? tableRef.current
const tableBody = findAntTableBody(root)
if (!tableBody) return
tableBody.style.minHeight = adjustedScrollHeight
return () => {
tableBody.style.minHeight = ''
}
}, [
expandHeight,
adjustedScrollHeight,
isCards,
isKanban,
loading,
tableData
])
// Add columns in the order specified by model.columns
model.columns.forEach((colName) => { model.columns.forEach((colName) => {
const prop = modelProperties.find((p) => p.name === colName) const prop = modelProperties.find((p) => p.name === colName)
if (prop) { if (prop) {
// Check if column should be visible based on visibleColumns prop
if ( if (
Object.keys(visibleColumns).length > 0 && Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false visibleColumns[prop.name] === false
) { ) {
return // Skip this column if it's not visible return
} }
var fixed = prop.columnFixed || undefined var fixed = prop.columnFixed || undefined
@ -1988,10 +2002,8 @@ const ObjectTable = forwardRef(
default: default:
break break
} }
// Check if this property should be filterable based on model.filters
const isFilterable = model.filters && model.filters.includes(prop.name)
// Check if this property should be sortable based on model.sorters const isFilterable = model.filters && model.filters.includes(prop.name)
const isSortable = model.sorters && model.sorters.includes(prop.name) const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = { const columnConfig = {
@ -2056,16 +2068,12 @@ const ObjectTable = forwardRef(
) )
} }
columnsWithSkeleton.push(columnConfig) columns.push(columnConfig)
} }
}) })
if ( if (showActions && rowActions.length > 0 && hasRealTableRows) {
showActions && columns.push({
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
title: ( title: (
<Flex gap='small' align='center' justify='center'> <Flex gap='small' align='center' justify='center'>
<ActionsIcon /> <ActionsIcon />
@ -2073,13 +2081,39 @@ const ObjectTable = forwardRef(
), ),
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 20 + rowActions.length * 30, // Adjust width based on number of actions width: 20 + rowActions.length * 30,
render: (record) => { render: (record) => {
return renderActions(record) return renderActions(record)
} }
}) })
} }
return columns
}, [
bulkSelection,
canBulkSelect,
effectiveFilter,
getFilterDropdown,
hasRealTableRows,
isCards,
isEditing,
isMobile,
lazyLoading,
loading,
model,
modelProperties,
renderActions,
resolvedMasterFilter,
rowActions,
selectedRowIdSet,
selectedRowIds,
showActions,
tableSorter,
toggleRowSelection,
toggleSelectAll,
visibleColumns
])
// Card view rendering // Card view rendering
const [cardColSpan, setCardColSpan] = useState(24) const [cardColSpan, setCardColSpan] = useState(24)
const cardsContainerNodeRef = useRef(null) const cardsContainerNodeRef = useRef(null)
@ -2465,6 +2499,7 @@ const ObjectTable = forwardRef(
</ObjectTableFilterContext.Provider> </ObjectTableFilterContext.Provider>
) )
} }
)
) )
ObjectTable.displayName = 'ObjectTable' ObjectTable.displayName = 'ObjectTable'

View File

@ -4,6 +4,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -26,6 +27,8 @@ const ActionsContext = createContext()
const ActionsProvider = ({ children }) => { const ActionsProvider = ({ children }) => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const locationRef = useRef(location)
locationRef.current = location
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null) const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null) const [currentObjectType, setCurrentObjectType] = useState(null)
@ -38,13 +41,14 @@ const ActionsProvider = ({ children }) => {
const actionObjectType = searchParams.get('actionObjectType') const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => { const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search) const loc = locationRef.current
if (nextUrl !== location.pathname + location.search) { const nextUrl = stripModalActionParams(loc.pathname, loc.search)
if (nextUrl !== loc.pathname + loc.search) {
navigate(nextUrl, { replace: true }) navigate(nextUrl, { replace: true })
} }
setModalAction(null) setModalAction(null)
lastHandledAction.current = null lastHandledAction.current = null
}, [location.pathname, location.search, navigate]) }, [navigate])
const handleModalOk = useCallback(() => { const handleModalOk = useCallback(() => {
onModalOk?.() onModalOk?.()
@ -56,16 +60,17 @@ const ActionsProvider = ({ children }) => {
setCurrentObject(objectData) setCurrentObject(objectData)
setCurrentObjectType(objectType) setCurrentObjectType(objectType)
const model = getModelByName(objectType) const model = getModelByName(objectType)
const loc = locationRef.current
const url = buildActionUrl( const url = buildActionUrl(
model, model,
action, action,
objectData._id, objectData._id,
location.pathname, loc.pathname,
location.search loc.search
) )
navigate(url) navigate(url)
}, },
[location.pathname, location.search, navigate] [navigate]
) )
useEffect(() => { useEffect(() => {
@ -187,9 +192,8 @@ const ActionsProvider = ({ children }) => {
? {} ? {}
: modalObjectData : modalObjectData
return ( const value = useMemo(
<ActionsContext.Provider () => ({
value={{
currentObject, currentObject,
currentObjectType, currentObjectType,
setCurrentObject, setCurrentObject,
@ -197,8 +201,12 @@ const ActionsProvider = ({ children }) => {
callAction, callAction,
clearAction, clearAction,
setOnModalOk setOnModalOk
}} }),
> [callAction, clearAction, currentObject, currentObjectType]
)
return (
<ActionsContext.Provider value={value}>
<Modal <Modal
open={resolvedModalAction != null} open={resolvedModalAction != null}
destroyOnHidden={true} destroyOnHidden={true}

View File

@ -5,7 +5,8 @@ import {
useState, useState,
useContext, useContext,
useRef, useRef,
useCallback useCallback,
useMemo
} from 'react' } from 'react'
import io from 'socket.io-client' import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography, Flex } from 'antd' import { message, Modal, Space, Button, Typography, Flex } from 'antd'
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
const getObjectEndpoint = (type) => const getObjectEndpoint = (type) =>
getModelByName(type)?.endpoint || `${type.toLowerCase()}s` getModelByName(type)?.endpoint || `${type.toLowerCase()}s`
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
const normalized = cleaned.trim().replace(/\s+/g, '_')
return normalized.slice(0, 255)
}
const API_METHOD_KEYS = [
'getUserSettings',
'updateUserSettings',
'setObjectActivity',
'clearObjectActivity',
'fetchObjectActivities',
'updateObject',
'updateMultipleObjects',
'createObject',
'getObjectFunction',
'sendObjectFunction',
'deleteObject',
'deleteObjects',
'subscribeToObjectUpdates',
'subscribeToAllObjectUpdates',
'subscribeToObjectEvent',
'subscribeToObjectTypeUpdates',
'subscribeToObjectActivity',
'subscribeToModelStats',
'fetchObject',
'fetchObjects',
'getObjectNeighbors',
'fetchObjectsByProperty',
'searchObjects',
'fetchSpotlightData',
'getModelStats',
'getModelPropertyValues',
'getModelHistory',
'showError',
'fetchFileContent',
'fetchFileThumbnail',
'exportToExcel',
'exportToCsv',
'fetchTemplatePreview',
'fetchTemplateIntellisense',
'fetchTemplateFormat',
'fetchTemplatePDF',
'fetchTemplateDownload',
'fetchNotes',
'downloadTemplate',
'downloadTemplatePDF',
'fetchHostOTP',
'sendObjectAction',
'uploadFile',
'flushFile',
'formatFileName',
'createUserNotifier',
'deleteUserNotifier',
'fetchUserNotifiersForObject',
'fetchAllUserNotifiersForObject',
'toggleUserNotifier',
'editUserNotifier',
'fetchObjectViews',
'createObjectView',
'updateObjectView',
'deleteObjectView',
'fetchNotificationsApi',
'markNotificationAsReadApi',
'markAllNotificationsAsReadApi',
'deleteNotificationApi',
'deleteAllNotificationsApi',
'registerNotificationListener',
'unregisterNotificationListener',
'getMarketplaceAuthUrl',
'refreshMarketplaceAuth',
'completeAppLaunchSession',
'getAppLaunchSession',
'fetchAppUpdateBranches',
'fetchAppUpdateCurrent',
'fetchWsServerVersion',
'fetchApiServerVersion'
]
const createStableApiMethods = (methodsRef) => {
const bound = {}
for (const key of API_METHOD_KEYS) {
bound[key] = (...args) => methodsRef.current[key]?.(...args)
}
return bound
}
const LaunchSessionCompleter = ({ completeAppLaunchSession }) => {
const location = useLocation()
const navigate = useNavigate()
const { token, authenticated } = useContext(AuthContext)
const completedLaunchSessionsRef = useRef(new Set())
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
return null
}
LaunchSessionCompleter.propTypes = {
completeAppLaunchSession: PropTypes.func.isRequired
}
const ConnectionIssueModal = ({
open,
delayMs,
cycle,
isReconnecting,
onReconnect
}) => {
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(delayMs)
const [reconnectProgress, setReconnectProgress] = useState(0)
useEffect(() => {
if (!open || isReconnecting) return undefined
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
const intervalId = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
return () => clearInterval(intervalId)
}, [open, isReconnecting, delayMs, cycle])
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
return (
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={open}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button key='reconnect' loading={isReconnecting} onClick={onReconnect}>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{`Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
)
}
ConnectionIssueModal.propTypes = {
open: PropTypes.bool,
delayMs: PropTypes.number,
cycle: PropTypes.number,
isReconnecting: PropTypes.bool,
onReconnect: PropTypes.func
}
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
token, token,
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityCallbacksRef = useRef(new Map()) const subscribedActivityCallbacksRef = useRef(new Map())
const subscribedActivityServerSubscriptionsRef = useRef(new Set()) const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false) const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0) const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
const [reconnectRemainingMs, setReconnectRemainingMs] = useState( const [reconnectCycle, setReconnectCycle] = useState(0)
RECONNECT_DELAYS_MS[0]
)
const [isReconnecting, setIsReconnecting] = useState(false) const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0) const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null) const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false) const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false) const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false) const hasConnectedOnceRef = useRef(false)
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
clearTimeout(reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null reconnectTimerRef.current = null
} }
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, []) }, [])
const resetReconnectState = useCallback(() => { const resetReconnectState = useCallback(() => {
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
reconnectScheduledRef.current = false reconnectScheduledRef.current = false
isReconnectingRef.current = false isReconnectingRef.current = false
setIsReconnecting(false) setIsReconnecting(false)
setReconnectProgress(0) setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers]) }, [clearReconnectTimers])
const startReconnectCountdown = useCallback( const startReconnectCountdown = useCallback(
(delayMs) => { (delayMs) => {
clearReconnectTimers() clearReconnectTimers()
const startedAt = Date.now() setReconnectDelayMs(delayMs)
setReconnectRemainingMs(delayMs) setReconnectCycle((current) => current + 1)
setReconnectProgress(0)
setIsReconnecting(false) setIsReconnecting(false)
reconnectProgressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = setTimeout(() => {
attemptReconnectRef.current?.() attemptReconnectRef.current?.()
}, delayMs) }, delayMs)
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
isReconnectingRef.current = true isReconnectingRef.current = true
reconnectAttemptRef.current += 1 reconnectAttemptRef.current += 1
setIsReconnecting(true) setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true) setConnecting(true)
setConnectionIssue(true) setConnectionIssue(true)
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
[token] [token]
) )
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
const getAppLaunchSession = useCallback(async (launchSession) => { const getAppLaunchSession = useCallback(async (launchSession) => {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/applaunch/${launchSession}`, `${config.backendUrl}/applaunch/${launchSession}`,
@ -2924,36 +3104,8 @@ const ApiServerProvider = ({ children }) => {
} }
} }
const reconnectSecondsRemaining = Math.max( const apiMethodsRef = useRef({})
1, apiMethodsRef.current = {
Math.ceil(reconnectRemainingMs / 1000)
)
// Sanitize a string so it is safe to use as a filename on most file systems
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
// Remove characters that are problematic on most common file systems
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
// Normalize whitespace to single underscores
const normalized = cleaned.trim().replace(/\s+/g, '_')
// Most file systems limit filenames to 255 characters
return normalized.slice(0, 255)
}
return (
<ApiServerContext.Provider
value={{
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
getUserSettings, getUserSettings,
updateUserSettings, updateUserSettings,
setObjectActivity, setObjectActivity,
@ -2981,7 +3133,6 @@ const ApiServerProvider = ({ children }) => {
getModelStats, getModelStats,
getModelPropertyValues, getModelPropertyValues,
getModelHistory, getModelHistory,
fetchLoading,
showError, showError,
fetchFileContent, fetchFileContent,
fetchFileThumbnail, fetchFileThumbnail,
@ -3025,66 +3176,48 @@ const ApiServerProvider = ({ children }) => {
fetchAppUpdateCurrent, fetchAppUpdateCurrent,
fetchWsServerVersion, fetchWsServerVersion,
fetchApiServerVersion fetchApiServerVersion
}}
>
{contextHolder}
{children}
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
} }
open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: !isReconnecting ? 480 : 260 }} const stableApiRef = useRef(null)
zIndex={3000} if (stableApiRef.current == null) {
closable={false} stableApiRef.current = createStableApiMethods(apiMethodsRef)
height={isReconnecting ? 20 : undefined} }
centered
className={isReconnecting ? 'loading-modal' : undefined} const value = useMemo(
maskClosable={false} () => ({
getContainer={() => document.body} ...stableApiRef.current,
footer={ apiServer: socketRef.current,
!isReconnecting error,
? [ connecting,
<Button connected,
key='reconnect' userSettings,
loading={isReconnecting} userSettingsLoaded,
onClick={() => attemptReconnect()} fetchLoading
> }),
Reconnect [
</Button> connecting,
connected,
error,
fetchLoading,
userSettings,
userSettingsLoaded
] ]
: false )
}
> return (
{!isReconnecting ? ( <ApiServerContext.Provider value={value}>
<Flex vertical gap='middle'> {contextHolder}
<Text> <LaunchSessionCompleter
{isReconnecting completeAppLaunchSession={completeAppLaunchSession}
? 'Reconnecting to the API server...' />
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${ {children}
reconnectSecondsRemaining === 1 ? '' : 's' <ConnectionIssueModal
}...`} open={Boolean(token) && authenticated == true && connectionIssue}
</Text> delayMs={reconnectDelayMs}
<ProgressDisplay cycle={reconnectCycle}
percent={isReconnecting ? 0 : 100 - reconnectProgress} isReconnecting={isReconnecting}
showInfo={false} onReconnect={attemptReconnect}
status={'exception'}
/> />
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal <Modal
title={ title={
<Space size={'middle'}> <Space size={'middle'}>

View File

@ -5,7 +5,8 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useContext, useContext,
useRef useRef,
useMemo
} from 'react' } from 'react'
import axios from 'axios' import axios from 'axios'
import { import {
@ -112,6 +113,8 @@ const AuthProvider = ({ children }) => {
} = useContext(ElectronContext) } = useContext(ElectronContext)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const locationRef = useRef(location)
locationRef.current = location
const sessionRef = useRef({ const sessionRef = useRef({
token, token,
expiresAt, expiresAt,
@ -432,9 +435,11 @@ const AuthProvider = ({ children }) => {
// Login using query parameters // Login using query parameters
const loginWithSSO = useCallback( const loginWithSSO = useCallback(
(redirectUri = location.pathname + location.search) => { (redirectUri) => {
const loc = locationRef.current
const nextRedirectUri = redirectUri || loc.pathname + loc.search
messageApi.info('Logging in with tombutcher.work') messageApi.info('Logging in with tombutcher.work')
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}` const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(nextRedirectUri)}`
if (isElectron) { if (isElectron) {
logger.debug('Opening external url...') logger.debug('Opening external url...')
openExternalUrl(loginUrl) openExternalUrl(loginUrl)
@ -444,14 +449,7 @@ const AuthProvider = ({ children }) => {
window.location.href = loginUrl window.location.href = loginUrl
} }
}, },
[ [redirectType, messageApi, openExternalUrl, isElectron]
redirectType,
messageApi,
openExternalUrl,
isElectron,
location.search,
location.pathname
]
) )
const getLoginToken = useCallback( const getLoginToken = useCallback(
@ -461,7 +459,6 @@ const AuthProvider = ({ children }) => {
setShowSessionExpiredModal(false) setShowSessionExpiredModal(false)
setAuthError(null) setAuthError(null)
try { try {
// Make a call to your backend to check auth status
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/auth/${redirectType}/token?code=${code}` `${config.backendUrl}/auth/${redirectType}/token?code=${code}`
) )
@ -480,7 +477,6 @@ const AuthProvider = ({ children }) => {
setAuthenticated(true) setAuthenticated(true)
setShowUnauthorizedModal(false) setShowUnauthorizedModal(false)
// Persist session (cookies on web, electron storage on desktop)
const persisted = await persistSession({ const persisted = await persistSession({
token: nextToken, token: nextToken,
expiresAt: nextExpiresAt, expiresAt: nextExpiresAt,
@ -492,10 +488,11 @@ const AuthProvider = ({ children }) => {
) )
} }
const searchParams = new URLSearchParams(location.search) const loc = locationRef.current
const searchParams = new URLSearchParams(loc.search)
searchParams.delete('authCode') searchParams.delete('authCode')
const newSearch = searchParams.toString() const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '') const newPath = loc.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true }) navigate(newPath, { replace: true })
} else { } else {
setAuthenticated(false) setAuthenticated(false)
@ -520,14 +517,7 @@ const AuthProvider = ({ children }) => {
setRetreivedTokenFromCookies(true) setRetreivedTokenFromCookies(true)
} }
}, },
[ [navigate, messageApi, persistSession, redirectType]
navigate,
location.search,
location.pathname,
messageApi,
persistSession,
redirectType
]
) )
// Function to check if the user is logged in // Function to check if the user is logged in
@ -805,12 +795,8 @@ const AuthProvider = ({ children }) => {
retreivedTokenFromCookies retreivedTokenFromCookies
]) ])
return ( const value = useMemo(
<> () => ({
{contextHolder}
{notificationContextHolder}
<AuthContext.Provider
value={{
authenticated, authenticated,
authInitialized: retreivedTokenFromCookies, authInitialized: retreivedTokenFromCookies,
setUnauthenticated, setUnauthenticated,
@ -822,8 +808,26 @@ const AuthProvider = ({ children }) => {
setUserProfile, setUserProfile,
profileImageUrl, profileImageUrl,
logout logout
}} }),
> [
authenticated,
getLoginToken,
loading,
loginWithSSO,
logout,
profileImageUrl,
retreivedTokenFromCookies,
setUnauthenticated,
token,
userProfile
]
)
return (
<>
{contextHolder}
{notificationContextHolder}
<AuthContext.Provider value={value}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
<Modal <Modal

View File

@ -3,6 +3,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -28,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
setCurrentObjectToolsState(null) setCurrentObjectToolsState(null)
}, []) }, [])
return ( const value = useMemo(
<DashboardObjectToolsContext.Provider () => ({
value={{
currentObjectTools, currentObjectTools,
setCurrentObjectTools, setCurrentObjectTools,
clearCurrentObjectTools clearCurrentObjectTools
}} }),
> [clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
)
return (
<DashboardObjectToolsContext.Provider value={value}>
{children} {children}
</DashboardObjectToolsContext.Provider> </DashboardObjectToolsContext.Provider>
) )

View File

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { message } from 'antd' import { message } from 'antd'
import { isElectrobunDesktop } from '../../../electrobun-bridge' import { isElectrobunDesktop } from '../../../electrobun-bridge'
@ -12,36 +12,52 @@ const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
export const MessageProvider = ({ children }) => { export const MessageProvider = ({ children }) => {
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP }) const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
const showMessage = (type, content, options = {}) => { const showMessage = useCallback(
(type, content, options = {}) => {
return msgApi.open({ return msgApi.open({
type, type,
content, content,
...options ...options
}) })
} },
[msgApi]
)
const showSuccess = (content, options = {}) => const showSuccess = useCallback(
showMessage('success', content, options) (content, options = {}) => showMessage('success', content, options),
const showInfo = (content, options = {}) => [showMessage]
showMessage('info', content, options) )
const showWarning = (content, options = {}) => const showInfo = useCallback(
showMessage('warning', content, options) (content, options = {}) => showMessage('info', content, options),
const showError = (content, options = {}) => [showMessage]
showMessage('error', content, options) )
const showLoading = (content, options = {}) => const showWarning = useCallback(
showMessage('loading', content, options) (content, options = {}) => showMessage('warning', content, options),
[showMessage]
)
const showError = useCallback(
(content, options = {}) => showMessage('error', content, options),
[showMessage]
)
const showLoading = useCallback(
(content, options = {}) => showMessage('loading', content, options),
[showMessage]
)
return ( const value = useMemo(
<MessageContext.Provider () => ({
value={{
msgApi, msgApi,
showSuccess, showSuccess,
showInfo, showInfo,
showWarning, showWarning,
showError, showError,
showLoading showLoading
}} }),
> [msgApi, showError, showInfo, showLoading, showSuccess, showWarning]
)
return (
<MessageContext.Provider value={value}>
{contextHolder} {contextHolder}
{children} {children}
</MessageContext.Provider> </MessageContext.Provider>

View File

@ -183,7 +183,6 @@ export const NavigationTabsProvider = ({ children }) => {
const tabsRef = useRef(tabs) const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId) const activeTabIdRef = useRef(activeTabId)
const locationRef = useRef(location) const locationRef = useRef(location)
const previousActiveTabIdRef = useRef(null)
const isRestoringRef = useRef(null) const isRestoringRef = useRef(null)
const tabPaneElsRef = useRef(new Map()) const tabPaneElsRef = useRef(new Map())
const captureQueueRef = useRef(Promise.resolve()) const captureQueueRef = useRef(Promise.resolve())
@ -471,16 +470,6 @@ export const NavigationTabsProvider = ({ children }) => {
return next return next
}, []) }, [])
useEffect(() => {
const previousId = previousActiveTabIdRef.current
previousActiveTabIdRef.current = activeTabId
if (!hydrated || !previousId || !activeTabId || previousId === activeTabId) {
return
}
if (!tabsRef.current.some((tab) => tab.id === previousId)) return
void captureTabPreview(previousId)
}, [activeTabId, captureTabPreview, hydrated])
const setTabPage = useCallback( const setTabPage = useCallback(
({ title, modelName, iconKey, location: pageLocation } = {}) => { ({ title, modelName, iconKey, location: pageLocation } = {}) => {
const activeId = activeTabIdRef.current const activeId = activeTabIdRef.current

View File

@ -4,6 +4,7 @@ import {
useContext, useContext,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef useRef
} from 'react' } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@ -121,7 +122,14 @@ const NotificationProvider = ({ children }) => {
const unreadCount = notifications.filter((n) => !n.read).length const unreadCount = notifications.filter((n) => !n.read).length
// Initial load / when we become authenticated and connected const closeNotificationCenter = useCallback(() => {
setNotificationCenterVisible(false)
}, [])
useEffect(() => {
closeNotificationCenter()
}, [location.pathname, closeNotificationCenter])
useEffect(() => { useEffect(() => {
if (!authenticated || !connected) return if (!authenticated || !connected) return
fetchNotificationsRef.current() fetchNotificationsRef.current()
@ -134,10 +142,6 @@ const NotificationProvider = ({ children }) => {
fetchNotificationsRef.current() fetchNotificationsRef.current()
}, [notificationCenterVisible]) }, [notificationCenterVisible])
useEffect(() => {
setNotificationCenterVisible(false)
}, [location.pathname])
useEffect(() => { useEffect(() => {
if (!authenticated || !registerNotificationListener) return if (!authenticated || !registerNotificationListener) return
const handleNotification = (notif) => { const handleNotification = (notif) => {
@ -169,9 +173,8 @@ const NotificationProvider = ({ children }) => {
return unregister return unregister
}, [authenticated, registerNotificationListener, api, deleteNotification]) }, [authenticated, registerNotificationListener, api, deleteNotification])
return ( const value = useMemo(
<NotificationContext.Provider () => ({
value={{
notificationCenterVisible, notificationCenterVisible,
toggleNotificationCenter, toggleNotificationCenter,
notifications, notifications,
@ -182,15 +185,30 @@ const NotificationProvider = ({ children }) => {
deleteAllNotifications, deleteAllNotifications,
unreadCount, unreadCount,
notificationsLoading notificationsLoading
}} }),
> [
deleteAllNotifications,
deleteNotification,
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
notificationCenterVisible,
notifications,
notificationsLoading,
toggleNotificationCenter,
unreadCount
]
)
return (
<NotificationContext.Provider value={value}>
{contextHolder} {contextHolder}
{children} {children}
<Drawer <Drawer
title='Notifications' title='Notifications'
placement='right' placement='right'
width={isMobile ? '100%' : 460} width={isMobile ? '100%' : 460}
onClose={() => setNotificationCenterVisible(false)} onClose={closeNotificationCenter}
open={notificationCenterVisible} open={notificationCenterVisible}
> >
<NotificationCenter <NotificationCenter

View File

@ -2,6 +2,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useMemo,
useState, useState,
useEffect useEffect
} from 'react' } from 'react'
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
) )
}, [showNavigationLabels]) }, [showNavigationLabels])
const toggleTheme = () => { const toggleTheme = useCallback(() => {
if (isSystem) { setIsSystem((current) => {
setIsSystem(false) if (current) return false
} return current
setIsDarkMode(!isDarkMode) })
} setIsDarkMode((current) => !current)
}, [])
const toggleSystem = () => { const toggleSystem = useCallback(() => {
setIsSystem(!isSystem) setIsSystem((current) => {
if (!isSystem) { const next = !current
// When enabling system theme, update to match system preference if (next) {
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches) setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
} }
} return next
})
}, [])
const toggleCompact = () => { const toggleCompact = useCallback(() => {
setIsCompact(!isCompact) setIsCompact((current) => !current)
} }, [])
const setThemeMode = useCallback((value) => { const setThemeMode = useCallback((value) => {
if (value === 'system') { if (value === 'system') {
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
setShowNavigationLabelsState(Boolean(value)) setShowNavigationLabelsState(Boolean(value))
}, []) }, [])
const getThemeAlgorithm = () => { const getColors = useCallback(() => COLORS, [])
var baseAlgorithm
if (isDarkMode == true) {
baseAlgorithm = theme.darkAlgorithm
} else {
baseAlgorithm = theme.defaultAlgorithm
}
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
}
const getColors = () => { const themeConfig = useMemo(() => {
return COLORS const baseAlgorithm = isDarkMode
? theme.darkAlgorithm
: theme.defaultAlgorithm
return {
algorithm: isCompact
? [theme.compactAlgorithm, baseAlgorithm]
: [baseAlgorithm],
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
} }
}
}
}, [isDarkMode, isCompact, primaryColorOverride])
const value = useMemo(
() => ({
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}),
[
getColors,
isCompact,
isDarkMode,
isSystem,
setDensityMode,
setShowNavigationLabels,
setThemeMode,
showNavigationLabels,
themeConfig,
toggleCompact,
toggleSystem,
toggleTheme
]
)
// Set CSS custom properties for theme colors // Set CSS custom properties for theme colors
useEffect(() => { useEffect(() => {
@ -226,41 +274,8 @@ export const ThemeProvider = ({ children }) => {
) )
}, [isDarkMode, primaryColorOverride]) }, [isDarkMode, primaryColorOverride])
const themeConfig = {
algorithm: getThemeAlgorithm(),
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
return ( return (
<ThemeContext.Provider <ThemeContext.Provider value={value}>
value={{
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
) )