Compare commits

..

38 Commits

Author SHA1 Message Date
d5096dbba3 Refactor Dashboard Tabs and Enhance CSS Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated CSS for dashboard tabs to improve layout and overflow handling, ensuring better visibility and usability.
- Replaced Typography's Text component with a custom ElipsisText component for improved text overflow management.
- Simplified tab selection logic in NavigationTabsContext, enhancing scrolling behavior when selecting tabs.
- Introduced a new utility function to scroll tabs into view, improving user experience during navigation.
2026-09-21 01:58:03 +01:00
e7f3e2a6d4 Enhance Keyboard Navigation in Dashboard Contexts
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced key handling improvements in ActionsContext to manage control key states more effectively.
- Added keyboard navigation functionality in NavigationTabsContext, allowing users to cycle through tabs using Ctrl+Tab and select tabs with number keys.
- Implemented cleanup for key event listeners to ensure proper state management and prevent memory leaks.
2026-09-21 00:22:51 +01:00
4f6f7cb881 Improve State Management and Location Handling in Dashboard Tabs
- Updated state handling in DashboardTabPanes to ensure null safety for tab state properties.
- Introduced a new utility function to determine if a location belongs to a leaving tab, enhancing tab navigation logic.
- Refactored location comparison logic to improve accuracy when checking for equality between tab states.
- Enhanced NavigationTabsContext to manage same-path query cleanup, improving tab restoration behavior.
2026-09-21 00:20:32 +01:00
d348d6f2b3 Enhance Navigation Context and Table State Management
- Introduced NavigationTabIdContext to improve tab identification and management within the Dashboard.
- Updated TableStateProvider to utilize the new context for better handling of tab-specific state, particularly in Electron environments.
- Enhanced ObjectListViewProvider to integrate persisted view handling based on the new context, improving user experience across different navigation scenarios.
- Refactored various components to streamline state management and improve overall performance.
2026-09-21 00:05:14 +01:00
44c8729787 Refactor DashboardTabs and ObjectDisplay for Enhanced Navigation and Scrolling
- Removed unused state references and optimized scrolling logic in DashboardTabs for improved performance.
- Updated ObjectDisplay to utilize action context for navigation, enhancing user interaction with object details.
- Introduced new utility functions for handling tab entries and navigation, streamlining tab management and improving user experience.
2026-09-20 23:36:22 +01:00
4c1766044f Update DashboardTabs for Improved ID Handling and Scrolling Logic
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added `htmlId` prop to `DashboardTabItem` for better accessibility and unique identification of tab items.
- Adjusted scrolling logic to account for offset, enhancing the user experience when navigating through tabs.
2026-09-20 22:38:52 +01:00
b54669fd26 Enhance DashboardTabs Interaction and Scrolling Behavior
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added unique IDs to dashboard tab items for improved accessibility and interaction.
- Implemented handleSelectTab function to manage tab selection and ensure proper scrolling behavior when tabs are selected, enhancing user experience.
2026-09-20 21:50:21 +01:00
396327f6ef Refactor DashboardTabs and Clean Up CSS Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed unnecessary CSS rules from App.css to streamline styles.
- Refactored DashboardTabs component to improve readability and maintainability by restructuring imports and simplifying the tab addition logic.
- Enhanced the handleAddTab function to ensure proper scrolling behavior when new tabs are added.
2026-09-20 21:31:17 +01:00
e446098a76 Big performance increase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-09-19 13:07:30 +01:00
24a3a03c65 Enhance Dashboard Functionality and Icon Integration
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated DashboardLayout to utilize Electron context for dynamic spacing adjustments.
- Enhanced About and Settings components to include icon keys for improved navigation clarity.
- Refined DashboardOverviewPage to generate dynamic icon keys based on page names.
- Improved DashboardTabs to support sidebar icons, enhancing visual consistency.
- Expanded NavigationTabsContext to manage icon keys for tabs, streamlining tab interactions.
- Updated sidebarIconMap to utilize component references for better performance and maintainability.
2026-09-19 01:57:42 +01:00
2b0565e69e Refine Dashboard Layout and Styles for Improved User Experience
- Adjusted the top positioning of the dashboard tab pane for better alignment.
- Updated the flex gap in the main content layout to enhance spacing consistency.
- Modified mouse enter delay for dashboard tab items to improve interaction responsiveness.
2026-09-19 01:38:28 +01:00
999c960339 Update Dashboard Tab Hover and Color Styles for Enhanced Visual Feedback
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted hover background color for inactive dashboard tab items to improve visibility.
- Refined color transition for dashboard tab close button, enhancing user interaction and visual consistency.
2026-09-18 23:57:13 +01:00
ae2ea0dc13 Refine Dashboard Tab Hover Styles for Improved Visual Feedback
- Updated hover styles for inactive dashboard tab items to enhance visual clarity and user interaction.
- Adjusted background and border properties for both light and dark modes, ensuring consistent appearance across themes.
2026-09-18 23:52:37 +01:00
877db10c09 Refactor Dashboard Tab Functionality and Enhance Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new store for managing active tab states, improving the responsiveness of tab interactions.
- Updated DashboardTabPanes and DashboardTabPreview components to utilize the new active state management, enhancing user experience.
- Enhanced CSS styles for dashboard tab previews and panes, ensuring better layout handling and visual consistency across themes.
- Improved tab preview capture logic to support dynamic theme changes, refining the overall rendering process.
- Adjusted various components to streamline the integration of tab previews and active state management.
2026-09-18 23:45:50 +01:00
0aee5b410f Enhance Dashboard Tab Styles and Capture Logic
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added isolation and improved z-index management in CSS for active and inactive dashboard tab panes, enhancing visual clarity.
- Updated styles for inactive tabs to improve layout handling and visibility transitions.
- Modified tab preview capture styles to ensure consistent display and layout during rendering, improving user experience.
2026-09-18 22:29:06 +01:00
969f5fece2 Add SnapDOM Integration and Refactor Dashboard Tab Functionality
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced @zumer/snapdom for enhanced tab preview capture, replacing html2canvas-pro for improved performance and reliability.
- Refactored DashboardTabPanes and DashboardTabs components to utilize the new tab preview logic, streamlining the rendering process.
- Updated CSS styles for dashboard tab previews, enhancing visual feedback and layout consistency.
- Removed deprecated preview capture logic and associated worker, simplifying the codebase.
- Enhanced NavigationTabsContext to manage tab previews and capturing state more effectively.
2026-09-18 21:57:35 +01:00
fcad667516 Add Tab Preview Functionality and Enhance Dashboard Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardTabPreview component for displaying tab previews, improving user interaction and visual feedback.
- Implemented tab preview capture logic using html2canvas-pro, allowing for dynamic rendering of tab content.
- Enhanced CSS styles for dashboard tab panes and previews, ensuring better layout management and visual consistency.
- Updated NavigationTabsContext to manage tab pane registration and preview capture layout, streamlining the tab interaction process.
- Refactored DashboardTabPanes and DashboardTabs components to integrate preview functionality and improve overall code structure.
2026-09-18 17:55:29 +01:00
f56646f906 Enhance Dashboard Navigation Fade Logic and Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new hook for managing delayed visibility of navigation elements, improving fade transitions.
- Updated CSS transitions for smoother visibility changes, ensuring better user experience during interactions.
- Refactored NavigationFade component to accept a className prop for enhanced styling flexibility.
- Adjusted logic for rendering window titles and dashboard items based on Electron context, improving overall navigation behavior.
2026-09-18 16:19:33 +01:00
89fc70a8ea Refactor Dashboard Component Styles for Consistency and Readability
- Updated various dashboard components to ensure consistent styling, including setting height to 100% for better layout management.
- Reformatted import statements for improved readability.
- Adjusted master filter formatting in ObjectTable components for enhanced clarity and consistency across the codebase.
- Improved overall code structure for better maintainability and visual alignment.
2026-09-18 16:10:08 +01:00
d9f3bb185f Update Dashboard Tab Pointer Events for Enhanced Interaction
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed pointer events for dashboard tab elements to improve user interaction, ensuring that the close button is responsive while maintaining a consistent experience across the dashboard.
- Adjusted CSS properties to refine the visual behavior of tab elements during user interactions.
2026-09-18 15:54:29 +01:00
e44f0b2ead Update Dashboard Tab Transition for Improved Visual Feedback
- Modified the opacity transition timing for dashboard elements to enhance visual responsiveness during interactions.
- Adjusted CSS to ensure smoother appearance and disappearance of elements, contributing to a more polished user experience.
2026-09-18 15:48:02 +01:00
48d2b0d15a Refine Dashboard Styles and Tab Functionality for Enhanced User Experience
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted CSS styles for dashboard elements, including margin and positioning tweaks for improved layout and visual consistency.
- Updated tab masking logic to enhance scrolling behavior and visual integration.
- Removed unnecessary divider components to streamline the dashboard navigation structure.
- Implemented new logic for handling tab scrolling, ensuring better responsiveness and user interaction.
2026-09-18 15:34:56 +01:00
0b4b8128de Enhance Dashboard Styles and Navigation with Improved Layout Adjustments
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added new CSS styles for dashboard menu container and tabs, improving visual alignment and spacing.
- Introduced logic to manage scroll edge fades for better user experience during tab navigation.
- Adjusted padding and margin properties for dashboard elements to enhance overall aesthetics and usability.
- Reformatted component code for improved readability and consistency.
2026-09-18 15:03:19 +01:00
abe183c82a Adjust Dashboard Tab Corner Positioning for Improved Alignment
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Modified the bottom positioning of dashboard tab corner elements for better visual alignment.
- Updated CSS styles to ensure consistent spacing across left and right corner elements, enhancing overall aesthetics.
2026-09-18 10:32:46 +01:00
7ab05c8c2d Update Window Configuration and Code Formatting
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the development server port from 5780 to 5173 for updated local development settings.
- Reformatted import statements for better readability and consistency.
- Simplified the timeout function call in handleWindowControl for improved code clarity.
2026-09-18 10:22:42 +01:00
9ca872e589 Enhance Dashboard Tabs with Sticky Visuals and Improved Masking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added CSS styles for sticky tab behavior, including new masking effects for better visual integration during scrolling.
- Updated JavaScript logic to manage sticky visuals and tab metrics, improving user interaction and responsiveness.
- Adjusted padding and layout properties for dashboard tab items to enhance overall aesthetics and usability.
2026-09-18 04:31:13 +01:00
84db1f19b7 Enhance Dashboard Tabs with Sticky Behavior and Improved Layout
- Introduced a new DashboardTabItem component to manage tab interactions, including drag-and-drop functionality.
- Added CSS styles for sticky tab behavior, ensuring better visibility and interaction during scrolling.
- Updated existing styles to improve layout consistency and responsiveness across dashboard components.
- Implemented metrics measurement for tab dimensions to enhance the user experience during tab interactions.
2026-09-18 03:24:56 +01:00
5498aa3368 Enhance Dashboard Tab Close Button with Transition Effect
- Added a transition effect to the opacity of the dashboard tab close button for smoother visual feedback on hover.
- Updated CSS styles to improve user interaction and visual consistency within the dashboard component.
2026-09-18 02:48:20 +01:00
251bb716cf Update Dashboard Sidebar Splitter Dimensions for Improved Layout
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted the default and maximum sidebar widths to enhance the layout and user experience within the Dashboard component.
- Set DEFAULT_SIDEBAR_PX to 285 and MAX_SIDEBAR_PX to 305 for better responsiveness.
2026-09-18 02:45:01 +01:00
d52eea3a41 Add Dashboard Development Controls and Update Navigation Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardDevControls component to manage API server connection states and provide visual feedback.
- Enhanced DashboardNavigation with improved layout and styling, including new transition effects for navigation elements.
- Adjusted CSS styles for better spacing and visibility of dashboard components, ensuring a more cohesive user experience.
2026-09-18 02:35:15 +01:00
f3af883bd2 Refine Dashboard Tab Styles and Adjust Margins
- Reduced the left margin for dashboard tab close buttons for improved spacing.
- Removed hover styles for the close button to streamline the design and enhance visual consistency.
2026-09-18 01:47:37 +01:00
5cc74bca18 Enhance Dashboard Styles with New Background and Corner Adjustments
- Added a new background style for the main content layout in light mode under macOS vibrancy for improved visual depth.
- Introduced additional styling for active dashboard tab items, including a new after pseudo-element for better visual distinction.
- Refined corner radius properties for dashboard tab items to enhance alignment and aesthetics.
- Simplified corner fill positioning using the inset property for more consistent layout behavior across components.
2026-09-18 01:42:15 +01:00
65417f75b8 Enhance Dashboard Navigation with New Modal Visibility Handling and Styling
- Introduced state management for modal visibility in the DashboardNavigation component to improve user interaction and layout responsiveness.
- Added new CSS styles for dashboard navigation elements, including transitions and layout adjustments for better visual clarity.
- Refactored the rendering logic to conditionally display navigation elements based on modal visibility, enhancing the overall user experience.
2026-09-18 01:42:07 +01:00
c39051a891 Refactor Dashboard Styles and Enhance Layout Consistency
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated CSS styles for macOS vibrancy modes, adjusting background colors and adding new layout properties for improved visual clarity.
- Enhanced dashboard tab components with new corner fill elements and refined dimensions for better alignment and responsiveness.
- Modified DashboardNavigation and DashboardTabs components to improve layout handling and user interaction, including adjustments to padding and flex properties.
- Introduced transitions for non-active dashboard tab items to enhance user experience during interactions.
2026-09-18 01:16:26 +01:00
d6c72c21e8 Refactor Dashboard Navigation and Tabs for Enhanced User Experience
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Restructured the DashboardNavigation component to separate leading and trailing navigation elements, improving layout and clarity.
- Updated CSS styles for navigation and dashboard tabs to enhance responsiveness and visual consistency.
- Implemented drag-and-drop functionality for dashboard tabs, allowing users to reorder tabs seamlessly.
- Introduced new styles for tab items and improved handling of drag states, enhancing the overall interaction experience.
2026-09-17 23:36:44 +01:00
7fef25d911 Enhance Dashboard Navigation with Object Navigation Hook
- Introduced `useObjectNavigationTabPage` hook to streamline navigation tab management for various dashboard components, improving consistency in object handling.
- Updated multiple components (DocumentTemplateDesign, EmailTemplateDesign, NoteInfo, GCodeFilePreview, ControlPrinter) to utilize the new hook for better object navigation and state management.
- Adjusted CSS styles for dashboard tabs to improve layout and responsiveness, enhancing user experience across devices.
2026-09-17 21:39:04 +01:00
2f9f42c9e2 Refactor tab management in NavigationTabsContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Simplified the logic for adding new tabs by directly appending the next tab to the current tabs array, improving code readability and maintainability.
- Removed unnecessary calculations for determining the insertion index, streamlining the tab addition process.
2026-09-17 20:46:26 +01:00
d16aa373be Implement Navigation Tabs and Dashboard Enhancements
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced NavigationTabsContext to manage tab state and navigation within the dashboard.
- Added DashboardTabs component for improved tabbed navigation, allowing users to add, close, and reorder tabs.
- Enhanced Dashboard layout to conditionally render tab panes based on the active tab, improving user experience.
- Updated various components to utilize navigation tab context, ensuring consistent title management and tab interactions.
- Implemented drag-and-drop functionality for tabs, enabling users to rearrange their workspace effectively.
- Refactored CSS styles for dashboard tabs and panes to ensure proper layout and responsiveness across devices.
2026-09-17 20:39:50 +01:00
108 changed files with 6797 additions and 4183 deletions

View File

@ -112,6 +112,98 @@
--webkit-user-select: none;
}
.dashboard-navigation-center {
position: relative;
flex: 1;
min-width: 0;
align-self: stretch;
display: flex;
align-items: center;
}
.dashboard-navigation-fade {
opacity: 1;
visibility: visible;
/* Incoming chrome waits for the outgoing fade, then fades in. */
transition:
opacity 0.2s ease 0.2s,
visibility 0s linear 0.2s;
}
.dashboard-navigation-fade-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
/*
Visibility must use a non-zero duration on hide. A 0s visibility
transition makes WebKit apply hidden immediately, so opacity never
animates and the chrome glitches out instead of fading.
*/
transition:
opacity 0.2s ease 0s,
visibility 0.2s linear 0s;
}
.dashboard-navigation-window-title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 16px;
font-size: 14px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.dashboard-navigation-center-border {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--color-header-border);
pointer-events: none;
opacity: 1;
/* Wait for outgoing chrome to finish fading before this line appears. */
transition: opacity 0.2s ease 0s;
}
.dashboard-navigation-center-border-hidden {
opacity: 0;
/* Stay through the gap, then fade out as incoming chrome fades in. */
transition: opacity 0.2s ease 0.2s;
}
.dashboard-menu-container {
position: relative;
height: 42px;
padding-top: 2px;
margin-left: 8px;
}
.dashboard-menu-container:after {
border-bottom: 1px solid var(--color-header-border);
content: '';
position: absolute;
left: -8px;
right: 0px;
bottom: 0;
}
.electron-navigation-leading,
.electron-navigation-trailing {
align-self: stretch;
height: 42px;
border-bottom: 1px solid var(--color-header-border);
}
.electron-navigation-trailing {
flex: 0 1 auto;
justify-content: flex-end;
}
.electron-body * {
-webkit-user-select: none;
-khtml-user-select: none;
@ -145,18 +237,50 @@ html.macos-vibrancy .main-layout {
background: transparent !important;
}
html.macos-vibrancy .light-mode {
--color-layout-background: rgba(255, 255, 255, 0.3);
}
html.macos-vibrancy .dark-mode {
--color-layout-background: rgba(0, 0, 0, 0.3);
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
background: rgba(255, 255, 255, 0.88) !important;
background: var(--color-layout-background) !important;
position: relative;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
background: rgba(0, 0, 0, 0.88) !important;
background: var(--color-layout-background) !important;
position: relative;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(10, 10, 10, 0.85) !important;
z-index: -1;
}
html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(244, 244, 244, 0.82) !important;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85) !important;
z-index: -1;
}
html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.82) !important;
}
@ -193,11 +317,11 @@ html.macos-vibrancy
}
html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
background: rgba(10, 10, 10, 0.9) !important;
background: rgba(10, 10, 10, 0.85) !important;
}
html.macos-vibrancy .light-mode .electron-navigation-wrapper {
background: rgba(255, 255, 255, 0.9) !important;
background: rgba(255, 255, 255, 0.85) !important;
}
html.macos-vibrancy .electron-navigation {
@ -367,6 +491,7 @@ code {
:root,
.light-mode,
html:has(.light-mode) {
--color-layout-background: #ffffff;
--kbd-color: #363636;
--kbd-bg: #efefef;
--kbd-inset: #e8e8e8;
@ -378,6 +503,7 @@ html:has(.light-mode) {
.dark-mode,
html:has(.dark-mode) {
--color-layout-background: #000000;
--kbd-color: #ececec;
--kbd-bg: #3a3a3a;
--kbd-inset: #323232;
@ -1891,7 +2017,7 @@ body.objectKanbanColumnResizing * {
.electron-body .ant-modal-wrap,
.electron-body .ant-modal-mask,
.electron-body .ant-drawer {
top: 41px;
top: 42px;
}
@keyframes h-progress-macos-wave {
@ -3346,6 +3472,732 @@ body.objectKanbanColumnResizing * {
min-width: 20px;
}
.dashboard-tabs {
flex: 1;
min-width: 0;
align-self: stretch;
height: 42px;
position: relative;
--tab-mask-fade: 14px;
}
.dashboard-tabs-wrap {
flex: 1;
min-width: 0;
height: 100%;
position: relative;
--tab-mask-left: 4px;
--tab-mask-right: 8px;
--tab-mask-fade-left: var(--tab-mask-fade);
--tab-mask-fade-right: var(--tab-mask-fade);
}
.dashboard-tabs-wrap .simplebar-content-wrapper {
overscroll-behavior-x: none;
}
.dashboard-tabs-wrap .simplebar-mask {
-webkit-clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left));
clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left));
-webkit-mask-image: linear-gradient(
to right,
transparent var(--tab-mask-left),
#000 calc(var(--tab-mask-left) + var(--tab-mask-fade-left)),
#000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade-right)),
transparent calc(100% - var(--tab-mask-right))
);
mask-image: linear-gradient(
to right,
transparent var(--tab-mask-left),
#000 calc(var(--tab-mask-left) + var(--tab-mask-fade-left)),
#000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade-right)),
transparent calc(100% - var(--tab-mask-right))
);
}
.dashboard-tabs[data-at-start] .dashboard-tabs-wrap {
--tab-mask-left: 0px;
--tab-mask-fade-left: 0px;
}
.dashboard-tabs[data-at-end] .dashboard-tabs-wrap {
--tab-mask-right: 0px;
--tab-mask-fade-right: 0px;
}
.dashboard-tabs-wrap::before,
.dashboard-tabs-wrap::after {
content: '';
position: absolute;
bottom: 0;
height: 1px;
pointer-events: none;
z-index: 2;
background: var(--color-header-border);
}
.dashboard-tabs-wrap::before {
left: 0;
width: calc(var(--tab-mask-left) + var(--tab-mask-fade-left));
-webkit-mask-image: linear-gradient(
to right,
#000 var(--tab-mask-left),
transparent calc(var(--tab-mask-left) + var(--tab-mask-fade-left))
);
mask-image: linear-gradient(
to right,
#000 var(--tab-mask-left),
transparent calc(var(--tab-mask-left) + var(--tab-mask-fade-left))
);
}
.dashboard-tabs-wrap::after {
right: 0.2px;
width: calc(var(--tab-mask-right) + var(--tab-mask-fade-right));
-webkit-mask-image: linear-gradient(
to left,
#000 var(--tab-mask-right),
transparent calc(var(--tab-mask-right) + var(--tab-mask-fade-right))
);
mask-image: linear-gradient(
to left,
#000 var(--tab-mask-right),
transparent calc(var(--tab-mask-right) + var(--tab-mask-fade-right))
);
}
.dashboard-tabs-wrap .simplebar-track.simplebar-vertical {
display: none;
}
.dashboard-tabs-inner {
width: max-content;
height: 100%;
min-width: fit-content;
width: 100%;
overflow: hidden;
align-items: flex-start;
box-sizing: border-box;
}
.dashboard-tabs-list {
position: relative;
display: flex;
align-items: flex-start;
gap: 4px;
padding-left: 20px;
height: 100%;
max-height: 42px;
}
.dashboard-tab-item-container {
height: 42px;
position: relative;
flex-shrink: 0;
}
.dashboard-tab-item-container-active {
border-bottom: none;
height: 42px;
}
.dashboard-tab-item-container:before {
content: '';
position: absolute;
top: 0;
left: -2px;
right: -2px;
bottom: 0;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tab-item-container:first-child:before {
left: -20px;
}
.dashboard-tab-item-container:last-child:before {
right: -20px;
}
.dashboard-tab-item-container-active + .dashboard-tab-item-container:before {
left: 16px;
}
.dashboard-tab-item-container:has(
+ .dashboard-tab-item-container-active
):before {
right: 16px;
}
.dashboard-tab-item-container-active:before {
content: none;
display: none;
}
.dashboard-tab-item {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 240px;
overflow: visible;
margin: 5px 0 0 0;
padding: 0 10px 0 10px;
border: 1px solid
color-mix(in srgb, var(--color-header-border) 55%, transparent);
border-radius: 12px;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
box-shadow: none;
outline: none;
}
.dashboard-tab-item-body {
display: flex;
align-items: center;
min-width: 0;
}
.dashboard-tab-item-icon {
display: flex;
align-items: center;
min-height: 30px;
line-height: 30px;
margin-right: 6px;
flex-shrink: 0;
}
.dashboard-tab-item:focus-visible {
outline: 2px solid
color-mix(in srgb, var(--color-primary, #1677ff) 45%, transparent);
outline-offset: 2px;
}
.dashboard-tab-item:not(.dashboard-tab-item-active) {
transition:
background 0.3s ease,
border-color 0.3s ease;
}
.dashboard-tab-item:not(.dashboard-tab-item-active):hover {
background: color-mix(in srgb, #ffffff 45%, transparent);
border: 1px solid var(--color-header-border);
}
.dark-mode .dashboard-tab-item:not(.dashboard-tab-item-active):hover {
background: color-mix(in srgb, #ffffff 2.75%, transparent);
}
.dashboard-tab-item-active {
z-index: 2;
align-self: stretch;
margin-bottom: -1px;
padding-bottom: 5px;
border-color: transparent;
position: relative;
border-radius: 12px 12px 0 0;
}
.dashboard-tab-item-active:before {
content: '';
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 21px;
border-radius: 11px 11px 0 0;
z-index: -2;
background: var(--color-layout-background);
}
.dashboard-tab-item-active:after {
content: '';
position: absolute;
top: 14px;
left: 1px;
right: 1px;
bottom: -1px;
z-index: -2;
background: var(--color-layout-background);
}
.dashboard-tab-item-outline-container {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: 0;
}
.dashboard-tab-item-outline {
position: absolute;
inset: 0 0 21px 0;
border: 1px solid var(--color-header-border);
border-bottom: none;
border-radius: 12px 12px 0 0;
pointer-events: none;
opacity: 0;
}
.dashboard-tab-item-corner {
--tab-corner-size: 22px;
--tab-corner-radius: 16px;
--tab-corner-inset: 1px;
--tab-corner-center: calc(
var(--tab-corner-size) - var(--tab-corner-inset) - var(--tab-corner-radius)
);
--tab-fill-nudge-x: 3px;
--tab-fill-nudge-y: 4px;
--tab-fill-circle-x: calc(var(--tab-corner-center) + var(--tab-fill-nudge-x));
--tab-fill-circle-y: calc(var(--tab-corner-size) - var(--tab-corner-radius));
position: absolute;
bottom: -1px;
width: var(--tab-corner-size);
height: var(--tab-corner-size);
overflow: hidden;
pointer-events: none;
opacity: 0;
}
.dashboard-tab-item-corner-left {
left: -21px;
}
.dashboard-tab-item-corner-right {
right: -21px;
}
.dashboard-tab-item-corner-fill {
position: absolute;
width: 26px;
height: 26px;
bottom: calc(-1 * var(--tab-fill-nudge-y));
background: var(--color-layout-background);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
.dashboard-tab-item-corner-left .dashboard-tab-item-corner-fill {
left: calc(-1 * var(--tab-fill-nudge-x));
-webkit-mask-image: radial-gradient(
circle at var(--tab-fill-circle-x) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
mask-image: radial-gradient(
circle at var(--tab-fill-circle-x) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
}
.dashboard-tab-item-corner-right .dashboard-tab-item-corner-fill {
right: calc(-1 * var(--tab-fill-nudge-x));
-webkit-mask-image: radial-gradient(
circle at calc(100% - var(--tab-fill-circle-x)) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
mask-image: radial-gradient(
circle at calc(100% - var(--tab-fill-circle-x)) var(--tab-fill-circle-y),
transparent var(--tab-corner-radius),
#000 var(--tab-corner-radius)
);
}
.dashboard-tab-item-corner-inner {
position: absolute;
width: 48px;
height: 48px;
box-sizing: border-box;
border-radius: var(--tab-corner-radius);
padding: 1px;
background: var(--color-header-border);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.dashboard-tab-item-corner-left .dashboard-tab-item-corner-inner {
bottom: 0;
right: var(--tab-corner-inset);
}
.dashboard-tab-item-corner-right .dashboard-tab-item-corner-inner {
bottom: 0;
left: var(--tab-corner-inset);
}
.dashboard-tab-item-active .dashboard-tab-item-outline,
.dashboard-tab-item-active .dashboard-tab-item-corner {
opacity: 1;
}
.dashboard-tabs-label {
max-width: 186px;
min-width: 0;
overflow: hidden;
}
.dashboard-tabs-label .elipsis-text-wrapper.ant-typography {
min-width: 0;
max-width: 100%;
}
.dashboard-tab-item-container[data-sticky-placeholder='true']
> .dashboard-tab-item {
visibility: hidden;
pointer-events: none;
}
.dashboard-tabs[data-sticky-edge] .dashboard-tab-item-container:before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-add-button-container:before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::before,
.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::after,
.dashboard-tabs[data-at-start] .dashboard-tabs-wrap::before,
.dashboard-tabs[data-at-end] .dashboard-tabs-wrap::after {
content: none;
display: none;
}
.dashboard-tabs-sticky-line {
display: none;
position: absolute;
bottom: 0;
pointer-events: none;
z-index: 3;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tabs-sticky-line[data-edge] {
display: block;
}
.dashboard-tab-sticky-clone {
position: absolute;
top: 0;
height: 42px;
z-index: 5;
visibility: hidden;
pointer-events: none;
box-sizing: border-box;
flex: none;
overflow: visible;
}
.dashboard-tab-sticky-clone[data-edge] {
visibility: visible;
width: var(--tab-sticky-width);
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item {
pointer-events: none;
box-sizing: border-box;
width: var(--tab-sticky-width);
min-width: var(--tab-sticky-width);
max-width: var(--tab-sticky-width);
justify-content: flex-start;
overflow: visible;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-body {
width: 100%;
overflow: hidden;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-icon {
margin-right: calc(6px * var(--tab-label-opacity, 1));
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label {
flex: 1 1 auto;
min-width: 0;
opacity: var(--tab-label-opacity, 1);
overflow: hidden;
white-space: nowrap;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label .ant-typography {
max-width: 100% !important;
min-width: 0;
overflow: hidden;
}
.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-close {
pointer-events: auto;
}
.dashboard-tabs-close {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 1px;
margin-right: -2px;
border-radius: 8px;
color: color-mix(
in srgb,
var(--color-text-secondary, inherit) 70%,
transparent
);
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s ease;
}
.dashboard-tabs-close:hover {
color: var(--color-text-secondary, inherit);
}
.dashboard-tabs-close::before {
content: '';
position: absolute;
inset: -8px -8px -8px -6px;
}
.dashboard-tabs-add-button {
padding-inline: 5px;
min-width: 22px;
margin-top: 5px;
margin-right: 5px;
}
.dashboard-tabs-add-button-container {
flex-grow: 1;
height: 42px;
position: relative;
}
.dashboard-tabs-add-button-container:before {
content: '';
position: absolute;
left: 15px;
right: 0;
bottom: 0;
border-bottom: 1px solid var(--color-header-border);
}
.dashboard-tab-item-dragging {
opacity: 0.4;
}
.dashboard-tab-item-drop-before::before,
.dashboard-tab-item-drop-after::after {
content: '';
position: absolute;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--color-primary, #1677ff);
pointer-events: none;
}
.dashboard-tab-item-drop-before::before {
left: -5px;
}
.dashboard-tab-item-drop-after::after {
right: -5px;
}
.dashboard-tabs-reordering
.dashboard-tab-item:not(.dashboard-tab-item-dragging) {
cursor: default;
}
.dashboard-tab-panes {
position: relative;
isolation: isolate;
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: hidden;
}
.dashboard-tab-pane {
position: absolute;
top: 10px;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
.dashboard-tab-pane-active {
z-index: 2;
isolation: isolate;
display: flex;
visibility: visible;
opacity: 1;
transform: none;
}
.dashboard-tab-pane-inactive {
z-index: 0;
overflow: hidden;
visibility: hidden;
opacity: 0;
pointer-events: none;
content-visibility: hidden;
}
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
/* Preview render: keep layout, but park the pane off-screen. */
visibility: visible;
opacity: 1;
pointer-events: none;
transform: translate(-100%, 0);
z-index: 0;
content-visibility: visible;
}
.dashboard-tab-preview-capture-frame {
position: absolute;
inset: 0;
z-index: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 24px;
background: var(--tab-preview-render-background);
transform: translate(-100%, 0);
pointer-events: none;
}
.dashboard-tab-preview-capture-frame > .dashboard-tab-pane {
position: relative;
inset: auto;
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 0;
transform: none;
}
.dashboard-tab-preview-capture-frame
> .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
transform: none;
}
.dashboard-tab-preview-popover {
padding-top: 6px;
}
.dashboard-tab-preview-popover .ant-popover-inner {
padding: 0;
overflow: hidden;
background: transparent;
}
.dashboard-tab-preview {
position: relative;
width: 320px;
background: var(--layout-header-bg);
overflow: hidden;
padding: 6px;
border: 1px solid transparent;
border-radius: 12px;
}
.dark-mode.dashboard-tab-preview {
border: 1px solid var(--color-header-border);
}
.dark-mode.dashboard-tab-preview:before {
content: '';
position: absolute;
inset: 1px;
background: var(--tab-preview-render-background);
}
.dashboard-tab-preview-stack {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 7px;
}
.dashboard-tab-preview-svg {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.dashboard-tab-preview-svg-current {
position: relative;
z-index: 0;
}
.dashboard-tab-preview-svg-previous {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
filter: blur(8px);
opacity: 1;
transition:
opacity 280ms ease,
filter 280ms ease;
}
.dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not(
:has(.dashboard-tab-preview-svg-current)
) {
min-height: 160px;
}
.dashboard-tab-preview-svg-previous.is-revealing {
opacity: 0;
filter: blur(0);
}
.dashboard-tab-preview-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.dashboard-tab-preview-loading-icon {
font-size: 16px;
color: var(--color-text-secondary, rgba(255, 255, 255, 0.92));
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.45));
}
.dashboard-tab-preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(128, 128, 128, 0.18);
}
.state-tag-loading svg path {
stroke: currentColor;
stroke-width: 12px;

546
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"@tsparticles/slim": "^3.9.1",
"@uiw/react-codemirror": "^4.25.1",
"@vscode/sudo-prompt": "^9.3.2",
"@zumer/snapdom": "^3.0.0",
"antd": "^5.27.1",
"antd-style": "^3.7.1",
"axios": "^1.11.0",
@ -127,9 +128,6 @@
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"electrobun": "1.18.1",
"electron": "^38.7.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",

2157
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@ import AppError from './components/App/AppError'
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { NavigationTabsProvider } from './components/Dashboard/context/NavigationTabsContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
@ -64,14 +65,7 @@ const getRouter = () => {
return BrowserRouter
}
const AppContent = () => {
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider
theme={themeConfig}
renderEmpty={() => (
const renderEmpty = () => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
@ -79,11 +73,18 @@ const AppContent = () => {
hasBorder={false}
/>
</div>
)}
>
)
const AppContent = () => {
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
<App>
<Router>
<ElectronProvider>
<NavigationTabsProvider>
<AuthProvider>
<PrintServerProvider>
<ApiServerProvider>
@ -183,6 +184,7 @@ const AppContent = () => {
</ApiServerProvider>
</PrintServerProvider>
</AuthProvider>
</NavigationTabsProvider>
</ElectronProvider>
</Router>
</App>

View File

@ -22,22 +22,22 @@ const {
const {
createMainWindow,
handleDeepLinkFromArgv,
persistSessionNow,
setupDevAuthServer,
setupNavigationGestures,
setupWindowsDeepLinkHandling
} = await import('../desktop/window.js')
setupWindowsDeepLinkHandling()
const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc)
await createMainWindow(rpc)
setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc)
setupDevAuthServer()
handleDeepLinkFromArgv(launchUrl)
process.on('exit', () => {
persistSessionNow()
unregisterGlobalShortcuts()
closeSingleInstanceServer()
})

View File

@ -1,13 +1,12 @@
// Dashboard.js
import Layout from './Layout'
import { Outlet } from 'react-router-dom'
import { useNavigationTabs } from './context/NavigationTabsContext'
import DashboardTabPanes from './common/DashboardTabPanes'
const Dashboard = () => {
return (
<Layout>
<Outlet />
</Layout>
)
const { isElectron } = useNavigationTabs()
return <Layout>{isElectron ? <DashboardTabPanes /> : <Outlet />}</Layout>
}
export default Dashboard

View File

@ -27,7 +27,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import OrderItemIcon from '../../../Icons/OrderItemIcon.jsx'
import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx'
@ -91,7 +94,7 @@ const InvoiceInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -100,6 +103,7 @@ const InvoiceInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -257,7 +261,9 @@ const InvoiceInfo = () => {
) : (
<ObjectTable
type='payment'
masterFilter={{ invoice: getModelByName('invoice').prefix + ':' + invoiceId }}
masterFilter={{
invoice: getModelByName('invoice').prefix + ':' + invoiceId
}}
visibleColumns={{ invoice: false }}
/>
)}
@ -291,10 +297,7 @@ const InvoiceInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('invoice').prefix +
':' +
invoiceId
parent: getModelByName('invoice').prefix + ':' + invoiceId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -93,7 +93,7 @@ const PaymentPolicyInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -84,7 +84,7 @@ const PaymentInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -93,6 +93,7 @@ const PaymentInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -77,7 +77,7 @@ const TaxRecordInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -85,7 +85,7 @@ const TaxRecordInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -213,9 +213,7 @@ const TaxRecordInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('taxRecord').prefix +
':' +
taxRecordId
getModelByName('taxRecord').prefix + ':' + taxRecordId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -87,7 +87,7 @@ const FilamentStockInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -197,7 +197,12 @@ const FilamentStockInfo = () => {
) : (
<ObjectTable
type='stockEvent'
masterFilter={{ parent: getModelByName('filamentStock').prefix + ':' + filamentStockId }}
masterFilter={{
parent:
getModelByName('filamentStock').prefix +
':' +
filamentStockId
}}
visibleColumns={{ parent: false }}
/>
)}

View File

@ -78,7 +78,7 @@ const OrderItemInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -88,6 +88,7 @@ const OrderItemInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -224,9 +225,7 @@ const OrderItemInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('orderItem').prefix +
':' +
orderItemId
getModelByName('orderItem').prefix + ':' + orderItemId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -93,6 +93,7 @@ const PartStockInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -214,7 +215,10 @@ const PartStockInfo = () => {
) : (
<ObjectTable
type='stockEvent'
masterFilter={{ parent: getModelByName('partStock').prefix + ':' + partStockId }}
masterFilter={{
parent:
getModelByName('partStock').prefix + ':' + partStockId
}}
visibleColumns={{ parent: false }}
/>
)}
@ -272,9 +276,7 @@ const PartStockInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('partStock').prefix +
':' +
partStockId
getModelByName('partStock').prefix + ':' + partStockId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -109,6 +109,7 @@ const ProductStockInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -102,7 +102,7 @@ const PurchaseOrderInfo = () => {
orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -111,6 +111,7 @@ const PurchaseOrderInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -230,7 +231,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='orderItem'
masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
@ -249,7 +253,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='shipment'
masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
@ -271,7 +278,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='invoice'
masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
@ -293,7 +303,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='stockEvent'
masterFilter={{
owner: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId
owner:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId
}}
/>
)}

View File

@ -121,6 +121,7 @@ const PurchaseOrderInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -233,7 +234,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='orderItem'
masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
@ -252,7 +256,10 @@ const PurchaseOrderInfo = () => {
<ObjectTable
type='shipment'
masterFilter={{
order: getModelByName('purchaseOrder').prefix + ':' + purchaseOrderId,
order:
getModelByName('purchaseOrder').prefix +
':' +
purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}

View File

@ -96,6 +96,7 @@ const ShipmentInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -26,7 +26,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('StockAuditInfo')
@ -92,6 +95,7 @@ const StockAuditInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -180,7 +184,9 @@ const StockAuditInfo = () => {
title='Stock Audit Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
@ -262,9 +268,7 @@ const StockAuditInfo = () => {
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
@ -274,9 +278,7 @@ const StockAuditInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('stockAudit').prefix +
':' +
stockAuditId
getModelByName('stockAudit').prefix + ':' + stockAuditId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -98,6 +98,7 @@ const StockLocationInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -8,7 +8,10 @@ import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
@ -89,7 +92,7 @@ const StockTransferInfo = () => {
finishEdit: () => {
objectFormRef?.current.handleUpdate()
return true
},
}
}
return (
@ -98,6 +101,7 @@ const StockTransferInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -1,6 +1,7 @@
// DashboardLayout.js
import PropTypes from 'prop-types'
import { Layout, Flex } from 'antd'
import { useContext } from 'react'
import { useLocation } from 'react-router-dom'
import ProductionSidebar from './Production/ProductionSidebar'
import InventorySidebar from './Inventory/InventorySidebar'
@ -13,15 +14,16 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './context/ElectronContext'
const { Content } = Layout
const DashboardLayout = ({ children }) => {
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { isElectron } = useContext(ElectronContext)
const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isFinance = location.pathname.startsWith('/dashboard/finance')
@ -49,7 +51,6 @@ const DashboardLayout = ({ children }) => {
)
return (
<MessageProvider>
<Layout
style={{ height: 'var(--unit-100vh)' }}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
@ -61,19 +62,22 @@ const DashboardLayout = ({ children }) => {
className='main-content-layout'
>
<Content>
<Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex
vertical
style={{ height: '100%' }}
gap={isElectron ? '10px' : '20px'}
>
<Flex justify='space-between' align='center'>
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
{currentObjectTools}
</Flex>
{children}
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</Flex>
</Content>
</Layout>
</DashboardSidebarSplitter>
{isMobile ? <MobileFooterNavigation /> : null}
</Layout>
</MessageProvider>
)
}

View File

@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive'
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
import ExternalLink from '../common/ExternalLink'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const { Title, Text } = Typography
const About = () => {
useNavigationTabPage({ title: 'About', iconKey: 'infoCircle' })
const [collapseState, updateCollapseState] = useCollapseState('About', {
updater: true
})

View File

@ -87,7 +87,7 @@ const AppPasswordInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -197,9 +197,7 @@ const AppPasswordInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('appPassword').prefix +
':' +
appPasswordId
getModelByName('appPassword').prefix + ':' + appPasswordId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('CourierServiceInfo')
@ -82,7 +85,7 @@ const CourierServiceInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -90,7 +93,7 @@ const CourierServiceInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -75,7 +75,7 @@ const CourierInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -83,7 +83,7 @@ const CourierInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -193,7 +193,9 @@ const CourierInfo = () => {
) : (
<ObjectTable
type='courierService'
masterFilter={{ courier: getModelByName('courier').prefix + ':' + courierId }}
masterFilter={{
courier: getModelByName('courier').prefix + ':' + courierId
}}
visibleColumns={{
courier: false
}}
@ -231,10 +233,7 @@ const CourierInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('courier').prefix +
':' +
courierId
parent: getModelByName('courier').prefix + ':' + courierId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -87,7 +87,7 @@ const DocumentJobInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -216,9 +216,7 @@ const DocumentJobInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('documentJob').prefix +
':' +
documentJobId
getModelByName('documentJob').prefix + ':' + documentJobId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -91,6 +91,7 @@ const DocumentPrinterInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -87,7 +87,7 @@ const DocumentSizeInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -19,6 +19,7 @@ import { useDashboardObjectTools } from '../../context/DashboardObjectToolsConte
import ScrollBox from '../../common/ScrollBox.jsx'
import TemplateEditor from '../../common/TemplateEditor.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('DocumentTemplateDesign')
log.setLevel(config.logLevel)
@ -30,6 +31,11 @@ const DocumentTemplateDesign = () => {
const documentTemplateId = new URLSearchParams(location.search).get(
'documentTemplateId'
)
useObjectNavigationTabPage({
modelName: 'documentTemplate',
pageName: 'design',
objectId: documentTemplateId
})
const [collapseState, updateCollapseState] = useCollapseState(
'DocumentTemplateDesign',
{

View File

@ -97,6 +97,7 @@ const DocumentTemplateInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>

View File

@ -88,7 +88,7 @@ const EmailAccountInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js'
import {
getModelByName,
getModelProperty
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
import TemplatePreview from '../../common/TemplatePreview.jsx'
@ -74,7 +77,7 @@ const EmailMessageInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -16,6 +16,7 @@ import ObjectActions from '../../common/ObjectActions'
import TemplateEditor from '../../common/TemplateEditor'
import ScrollBox from '../../common/ScrollBox'
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const EmailTemplateDesign = () => {
const location = useLocation()
@ -24,6 +25,11 @@ const EmailTemplateDesign = () => {
const emailTemplateId = new URLSearchParams(location.search).get(
'emailTemplateId'
)
useObjectNavigationTabPage({
modelName: 'emailTemplate',
pageName: 'design',
objectId: emailTemplateId
})
const [collapseState, updateCollapseState] = useCollapseState(
'EmailTemplateDesign',
{ preview: true, editor: true, notes: true }

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js'
import {
getModelByName,
getModelProperty
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('EmailTemplateInfo')
@ -92,7 +95,7 @@ const EmailTemplateInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -74,7 +74,7 @@ const FilamentSkuInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -82,7 +82,7 @@ const FilamentSkuInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -210,9 +210,7 @@ const FilamentSkuInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('filamentSku').prefix +
':' +
filamentSkuId
getModelByName('filamentSku').prefix + ':' + filamentSkuId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -92,7 +92,7 @@ const FilamentInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -101,6 +101,7 @@ const FilamentInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -220,7 +221,9 @@ const FilamentInfo = () => {
<ObjectTable
ref={filamentSkusTableRef}
type='filamentSku'
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }}
masterFilter={{
filament: getModelByName('filament').prefix + ':' + filamentId
}}
visibleColumns={{ filament: false }}
/>
)}
@ -238,7 +241,9 @@ const FilamentInfo = () => {
) : (
<ObjectTable
type='filamentStock'
masterFilter={{ filament: getModelByName('filament').prefix + ':' + filamentId }}
masterFilter={{
filament: getModelByName('filament').prefix + ':' + filamentId
}}
visibleColumns={{
filament: false,
startingWeight: false
@ -277,10 +282,7 @@ const FilamentInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('filament').prefix +
':' +
filamentId
parent: getModelByName('filament').prefix + ':' + filamentId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -98,7 +98,7 @@ const FileInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -87,7 +87,7 @@ const HostInfo = () => {
finishEdit: () => {
objectFormRef?.current.handleUpdate()
return true
},
}
}
return (
@ -96,6 +96,7 @@ const HostInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -213,7 +214,9 @@ const HostInfo = () => {
) : (
<ObjectTable
type='printer'
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }}
masterFilter={{
host: getModelByName('host').prefix + ':' + hostId
}}
visibleColumns={{
host: false
}}
@ -234,7 +237,9 @@ const HostInfo = () => {
) : (
<ObjectTable
type='documentPrinter'
masterFilter={{ host: getModelByName('host').prefix + ':' + hostId }}
masterFilter={{
host: getModelByName('host').prefix + ':' + hostId
}}
visibleColumns={{
host: false
}}
@ -272,10 +277,7 @@ const HostInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('host').prefix +
':' +
hostId
parent: getModelByName('host').prefix + ':' + hostId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -77,7 +77,7 @@ const MaterialInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -85,7 +85,7 @@ const MaterialInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -212,10 +212,7 @@ const MaterialInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('material').prefix +
':' +
materialId
parent: getModelByName('material').prefix + ':' + materialId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -77,7 +77,7 @@ const NoteTypeInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -188,10 +188,7 @@ const NoteTypeInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('noteType').prefix +
':' +
noteTypeId
parent: getModelByName('noteType').prefix + ':' + noteTypeId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -21,12 +21,16 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
const log = loglevel.getLogger('NoteInfo')
log.setLevel(config.logLevel)
@ -36,6 +40,11 @@ const NoteInfo = () => {
const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null)
const noteId = new URLSearchParams(location.search).get('noteId')
useObjectNavigationTabPage({
modelName: 'note',
pageName: 'info',
objectId: noteId
})
const [collapseState, updateCollapseState] = useCollapseState('NoteInfo', {
info: true,
content: true,
@ -77,7 +86,7 @@ const NoteInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -85,7 +94,7 @@ const NoteInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -239,10 +248,7 @@ const NoteInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('note').prefix +
':' +
noteId
parent: getModelByName('note').prefix + ':' + noteId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -69,7 +69,7 @@ const PartSkuInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -77,7 +77,7 @@ const PartSkuInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -204,10 +204,7 @@ const PartSkuInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('partSku').prefix +
':' +
partSkuId
parent: getModelByName('partSku').prefix + ':' + partSkuId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -88,7 +88,7 @@ const PartInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -197,7 +197,9 @@ const PartInfo = () => {
<ObjectTable
ref={partSkusTableRef}
type='partSku'
masterFilter={{ part: getModelByName('part').prefix + ':' + partId }}
masterFilter={{
part: getModelByName('part').prefix + ':' + partId
}}
visibleColumns={{ part: false }}
/>
)}
@ -232,10 +234,7 @@ const PartInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('part').prefix +
':' +
partId
parent: getModelByName('part').prefix + ':' + partId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -80,7 +80,7 @@ const PermissionSettingInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -88,7 +88,7 @@ const PermissionSettingInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -23,7 +23,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const ProductCategoryInfo = () => {
@ -77,7 +80,7 @@ const ProductCategoryInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -85,7 +88,7 @@ const ProductCategoryInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -23,7 +23,10 @@ import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import PartIcon from '../../../Icons/PartIcon.jsx'
const ProductSkuInfo = () => {
@ -75,7 +78,7 @@ const ProductSkuInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -83,7 +86,7 @@ const ProductSkuInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -237,9 +240,7 @@ const ProductSkuInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('productSku').prefix +
':' +
productSkuId
getModelByName('productSku').prefix + ':' + productSkuId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -88,7 +88,7 @@ const ProductInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -200,7 +200,10 @@ const ProductInfo = () => {
<ObjectTable
ref={productSkusTableRef}
type='productSku'
masterFilter={{ product: getModelByName('product').prefix + ':' + productId }}
masterFilter={{
product:
getModelByName('product').prefix + ':' + productId
}}
visibleColumns={{ product: false }}
/>
)}
@ -239,10 +242,7 @@ const ProductInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('product').prefix +
':' +
productId
parent: getModelByName('product').prefix + ':' + productId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -23,6 +23,7 @@ import settingsSchema, {
getVisibleSettingsSections,
pickSettings
} from '../../../database/Settings'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native'
@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [
]
const Settings = () => {
useNavigationTabPage({ title: 'Settings', iconKey: 'settings' })
const {
isDarkMode,
isCompact,

View File

@ -22,7 +22,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const StockAuditLevelInfo = () => {
@ -82,7 +85,7 @@ const StockAuditLevelInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -232,9 +235,7 @@ const StockAuditLevelInfo = () => {
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
onToggle={(expanded) => updateCollapseState('auditLogs', expanded)}
collapseKey='auditLogs'
>
{objectFormState.loading ? (

View File

@ -25,7 +25,10 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('TaxRateInfo')
@ -77,7 +80,7 @@ const TaxRateInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -85,7 +88,7 @@ const TaxRateInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -236,10 +239,7 @@ const TaxRateInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('taxRate').prefix +
':' +
taxRateId
parent: getModelByName('taxRate').prefix + ':' + taxRateId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -80,14 +80,14 @@ const UserGroupInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -86,7 +86,7 @@ const UserInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -74,7 +74,7 @@ const VendorInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -82,7 +82,7 @@ const VendorInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -209,10 +209,7 @@ const VendorInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('vendor').prefix +
':' +
vendorId
parent: getModelByName('vendor').prefix + ':' + vendorId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -111,14 +111,14 @@ const FilamentProfileInfo = () => {
finishEdit: () => {
objectFormRef.current?.handleUpdate?.()
return true
},
}
}
return (
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify='space-between'>
<Space size='small'>

View File

@ -25,7 +25,10 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import PartIcon from '../../../Icons/PartIcon.jsx'
const log = loglevel.getLogger('GCodeFileInfo')
@ -91,6 +94,7 @@ const GCodeFileInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -251,9 +255,7 @@ const GCodeFileInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('gcodeFile').prefix +
':' +
gcodeFileId
getModelByName('gcodeFile').prefix + ':' + gcodeFileId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -21,6 +21,7 @@ import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('GCodeFilePreview')
log.setLevel(config.logLevel)
@ -29,6 +30,11 @@ const GCodeFilePreview = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
useObjectNavigationTabPage({
modelName: 'gcodeFile',
pageName: 'preview',
objectId: gcodeFileId
})
const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFilePreview',
{

View File

@ -97,6 +97,7 @@ const JobInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -242,10 +243,7 @@ const JobInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('job').prefix +
':' +
jobId
parent: getModelByName('job').prefix + ':' + jobId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -27,7 +27,10 @@ import UserNotifierToggle from '../../common/UserNotifierToggle'
import ViewButton from '../../common/ViewButton'
import useCollapseState from '../../hooks/useCollapseState'
import ObjectProperty from '../../common/ObjectProperty'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels'
import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import SettingsIcon from '../../../Icons/SettingsIcon'
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
@ -89,14 +92,14 @@ const PrinterProfileInfo = () => {
finishEdit: () => {
objectFormRef.current?.handleUpdate?.()
return true
},
}
}
return (
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify='space-between'>
<Space size='small'>

View File

@ -37,6 +37,7 @@ import ObjectTableNavigationButtons from '../../common/ObjectTableNavigationButt
import PrinterControlButtons from '../../common/PrinterControlButtons.jsx'
import PrinterQueue from '../../common/PrinterQueue.jsx'
import ListIcon from '../../../Icons/ListIcon.jsx'
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
const log = loglevel.getLogger('ControlPrinter')
log.setLevel(config.logLevel)
@ -47,6 +48,11 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
const actionHandlerRef = useRef(null)
const isMobile = useMediaQuery({ maxWidth: 768 })
const printerId = new URLSearchParams(location.search).get('printerId')
useObjectNavigationTabPage({
modelName: 'printer',
pageName: 'control',
objectId: printerId
})
const [collapseState, updateCollapseState] = useCollapseState(
'ControlPrinter',
{

View File

@ -98,6 +98,7 @@ const PrinterInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -223,7 +224,9 @@ const PrinterInfo = () => {
<ObjectTable
ref={profilesTableRef}
type='printerProfile'
masterFilter={{ printer: getModelByName('printer').prefix + ':' + printerId }}
masterFilter={{
printer: getModelByName('printer').prefix + ':' + printerId
}}
visibleColumns={{
printer: false,
'printer._id': false
@ -262,10 +265,7 @@ const PrinterInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('printer').prefix +
':' +
printerId
parent: getModelByName('printer').prefix + ':' + printerId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -77,7 +77,7 @@ const SubJobInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -183,7 +183,9 @@ const SubJobInfo = () => {
) : (
<ObjectTable
type='stockEvent'
masterFilter={{ owner: getModelByName('subJob').prefix + ':' + subJobId }}
masterFilter={{
owner: getModelByName('subJob').prefix + ':' + subJobId
}}
visibleColumns={{
owner: false,
'owner.type': false
@ -222,10 +224,7 @@ const SubJobInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('subJob').prefix +
':' +
subJobId
parent: getModelByName('subJob').prefix + ':' + subJobId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -74,7 +74,7 @@ const ClientInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -82,7 +82,7 @@ const ClientInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
@ -209,10 +209,7 @@ const ClientInfo = () => {
<ObjectTable
type='auditLog'
masterFilter={{
parent:
getModelByName('client').prefix +
':' +
clientId
parent: getModelByName('client').prefix + ':' + clientId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -93,7 +93,7 @@ const FulfillmentPolicyInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -96,14 +96,14 @@ const ListingVarientInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -101,7 +101,7 @@ const ListingInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -162,7 +162,7 @@ const MarketplaceInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -93,7 +93,7 @@ const ReturnPolicyInfo = () => {
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
style={{ maxHeight: '100%', height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>

View File

@ -100,7 +100,7 @@ const SalesOrderInfo = () => {
orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.()
return true
},
}
}
return (
@ -109,6 +109,7 @@ const SalesOrderInfo = () => {
vertical='true'
style={{
maxHeight: '100%',
height: '100%',
minHeight: 0
}}
>
@ -228,7 +229,10 @@ const SalesOrderInfo = () => {
<ObjectTable
type='orderItem'
masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder'
}}
visibleColumns={{ order: false }}
@ -247,7 +251,10 @@ const SalesOrderInfo = () => {
<ObjectTable
type='shipment'
masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder'
}}
visibleColumns={{ order: false }}
@ -269,7 +276,10 @@ const SalesOrderInfo = () => {
<ObjectTable
type='invoice'
masterFilter={{
order: getModelByName('salesOrder').prefix + ':' + salesOrderId,
order:
getModelByName('salesOrder').prefix +
':' +
salesOrderId,
orderType: 'salesOrder'
}}
visibleColumns={{ order: false }}
@ -291,7 +301,10 @@ const SalesOrderInfo = () => {
<ObjectTable
type='stockEvent'
masterFilter={{
owner: getModelByName('salesOrder').prefix + ':' + salesOrderId
owner:
getModelByName('salesOrder').prefix +
':' +
salesOrderId
}}
/>
)}
@ -330,9 +343,7 @@ const SalesOrderInfo = () => {
type='auditLog'
masterFilter={{
parent:
getModelByName('salesOrder').prefix +
':' +
salesOrderId
getModelByName('salesOrder').prefix + ':' + salesOrderId
}}
visibleColumns={{ _id: false, parent: false }}
/>

View File

@ -1,11 +1,14 @@
// DashboardBreadcrumb.js
import { useContext } from 'react'
import { Breadcrumb, Button, Flex, Space } from 'antd'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
import FilterIcon from '../../Icons/FilterIcon'
import { getModelByPluralName } from '../../../database/ObjectModels'
import { ElectronContext } from '../context/ElectronContext'
import { useTableState } from '../context/TableStateContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
const breadcrumbNameMap = {
production: 'Production',
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => {
const location = useLocation()
const navigate = useNavigate()
const { isElectron } = useContext(ElectronContext)
const { goBack, goForward } = useNavigationTabs()
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
const pathSnippets = location.pathname.split('/').filter((i) => i)
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
<Button
type='text'
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(-1)}
onClick={() => (isElectron ? goBack() : navigate(-1))}
style={{ padding: '0 2px', height: '22px' }}
/>
<Button
type='text'
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(1)}
onClick={() => (isElectron ? goForward() : navigate(1))}
style={{ padding: '0 2px', height: '22px' }}
/>
</Space.Compact>

View File

@ -0,0 +1,21 @@
import { Route } from 'react-router-dom'
import ProductionRoutes from '../../../routes/ProductionRoutes'
import InventoryRoutes from '../../../routes/InventoryRoutes'
import FinanceRoutes from '../../../routes/FinanceRoutes'
import SalesRoutes from '../../../routes/SalesRoutes'
import ManagementRoutes from '../../../routes/ManagementRoutes'
import DeveloperRoutes from '../../../routes/DeveloperRoutes'
import ModelRoutes from '../../../routes/ModelRoutes'
export const wrapDashboardChildRoutes = (layoutElement) => (
<Route element={layoutElement}>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{ModelRoutes}
{DeveloperRoutes}
</Route>
)

View File

@ -0,0 +1,70 @@
import { useContext, useEffect, useState } from 'react'
import { Space, Tag } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext'
import Tooltip from './Tooltip'
import CloudIcon from '../../Icons/CloudIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
const DashboardDevControls = () => {
const { connecting, connected } = useContext(ApiServerContext)
const navigate = useNavigate()
const [apiServerState, setApiServerState] = useState('disconnected')
useEffect(() => {
if (connecting == true) {
setApiServerState('connecting')
} else if (connected == true) {
setApiServerState('connected')
} else {
setApiServerState('disconnected')
}
}, [connecting, connected])
if (import.meta.env.MODE !== 'development') return null
return (
<Space>
{apiServerState === 'connected' ? (
<Tooltip content='Connected to api server'>
<Tag
color='success'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
{apiServerState === 'connecting' ? (
<Tooltip content='Connecting to api server...'>
<Tag
color='warning'
style={{ marginRight: 0 }}
icon={<LoadingOutlined />}
/>
</Tooltip>
) : null}
{apiServerState === 'disconnected' ? (
<Tooltip content='Disconnected from api server'>
<Tag
color='error'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
<Tooltip content='Developer'>
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip>
</Space>
)
}
export default DashboardDevControls

View File

@ -1,5 +1,13 @@
// DashboardNavigation.js
import { useContext, useEffect, useState, useMemo } from 'react'
import {
useContext,
useEffect,
useState,
useMemo,
useCallback,
memo
} from 'react'
import PropTypes from 'prop-types'
import {
Menu,
Flex,
@ -11,7 +19,7 @@ import {
Typography,
Popover
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import classNames from 'classnames'
import { AuthContext } from '../context/AuthContext'
import { SpotlightContext } from '../context/SpotlightContext'
import { ApiServerContext } from '../context/ApiServerContext'
@ -35,11 +43,12 @@ import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon'
import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons'
import DashboardDevControls from './DashboardDevControls'
import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs'
import {
filterSidebarItemsByListPermission,
getSidebarDefaultPath,
@ -56,6 +65,246 @@ import { getEffectiveAppearance } from '../../../database/Settings'
const { Text } = Typography
const NAV_CENTER_FADE_STYLE = {
width: '100%',
minWidth: 0,
display: 'flex',
alignItems: 'center'
}
const NAV_ELECTRON_CENTER_FADE_STYLE = {
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center'
}
const NAV_ELECTRON_TABS_STYLE = { flex: 1, minWidth: 0, display: 'flex' }
const NAV_TRAILING_FLEX_STYLE = { marginTop: '-2px', marginRight: '6px' }
const NAV_TRAILING_SPACE_STYLE = { paddingTop: '2px', marginRight: '8px' }
const NAV_SEARCH_BUTTON_STYLE = { marginTop: '4px' }
const NAV_BELL_BUTTON_STYLE = { marginTop: '2px' }
const NAV_BADGE_STYLE = { padding: 0, fontWeight: 600 }
const NAV_UPDATE_TAG_STYLE = { cursor: 'pointer', margin: '2px 0 0 0' }
const NAV_OVERFLOW_BUTTON_STYLE = { marginBottom: '4px' }
const NAV_FADE_MS = 200
const navigationFadeClass = (visible) =>
`dashboard-navigation-fade${visible ? '' : ' dashboard-navigation-fade-hidden'}`
const useDelayedInactive = (visible) => {
const [inactive, setInactive] = useState(!visible)
useEffect(() => {
if (visible) {
setInactive(false)
return undefined
}
const timeoutId = window.setTimeout(() => setInactive(true), NAV_FADE_MS)
return () => window.clearTimeout(timeoutId)
}, [visible])
return inactive
}
const NavigationFade = memo(function NavigationFade({
visible,
children,
style,
className
}) {
const inactive = useDelayedInactive(visible)
return (
<div
className={classNames(navigationFadeClass(visible), className)}
inert={inactive ? true : undefined}
aria-hidden={inactive ? true : undefined}
style={style}
>
{children}
</div>
)
})
NavigationFade.propTypes = {
visible: PropTypes.bool,
children: PropTypes.node,
style: PropTypes.object,
className: PropTypes.string
}
const DashboardNavigationTrailingItems = memo(
function DashboardNavigationTrailingItems({
controlsReady,
isMobile,
isElectron,
unreadCount,
userProfile,
availableUpdate,
userPopoverOpen,
onUserPopoverOpenChange,
onSearch,
onNotifications,
onCheckUpdates
}) {
return (
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
{controlsReady ? (
<Space style={NAV_TRAILING_SPACE_STYLE}>
<WebAppSwitcher />
<KeyboardShortcut
shortcut='alt+q'
hint='ALT Q'
onTrigger={onSearch}
>
<Button
icon={<SearchIcon />}
type='text'
style={NAV_SEARCH_BUTTON_STYLE}
onClick={onSearch}
/>
</KeyboardShortcut>
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={NAV_BADGE_STYLE}
>
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'
onTrigger={onNotifications}
>
<Button
icon={<BellIcon />}
type='text'
style={NAV_BELL_BUTTON_STYLE}
onClick={onNotifications}
/>
</KeyboardShortcut>
</Badge>
</Space>
) : null}
<DashboardDevControls />
{controlsReady && userProfile ? (
<Space>
<Popover
content={
<UserProfilePopover
onClose={() => onUserPopoverOpenChange(false)}
/>
}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={onUserPopoverOpenChange}
arrow={false}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Popover>
</Space>
) : null}
{controlsReady && isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={NAV_UPDATE_TAG_STYLE}
color='cyan'
onClick={onCheckUpdates}
>
Update Available
</Tag>
) : null}
</Flex>
)
}
)
DashboardNavigationTrailingItems.propTypes = {
controlsReady: PropTypes.bool,
isMobile: PropTypes.bool,
isElectron: PropTypes.bool,
unreadCount: PropTypes.number,
userProfile: PropTypes.shape({
name: PropTypes.string,
username: PropTypes.string
}),
availableUpdate: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
userPopoverOpen: PropTypes.bool,
onUserPopoverOpenChange: PropTypes.func,
onSearch: PropTypes.func,
onNotifications: PropTypes.func,
onCheckUpdates: PropTypes.func
}
const DashboardElectronCenterItems = memo(
function DashboardElectronCenterItems({ menu, showNavigationLabels }) {
return (
<>
<div
className={classNames(
'dashboard-menu-container',
showNavigationLabels && 'dashboard-menu-container-navigation-labels'
)}
>
{menu}
</div>
<div style={NAV_ELECTRON_TABS_STYLE}>
<DashboardTabs />
</div>
</>
)
}
)
DashboardElectronCenterItems.propTypes = {
menu: PropTypes.node,
showNavigationLabels: PropTypes.bool
}
const DashboardNavigationTrailing = memo(function DashboardNavigationTrailing({
showControls,
isOtherApp,
isElectron,
...itemProps
}) {
return (
<>
<div className='electrobun-webkit-app-region-no-drag'>
<NavigationFade visible={!isElectron || showControls}>
<DashboardNavigationTrailingItems
isElectron={isElectron}
{...itemProps}
/>
</NavigationFade>
</div>
{isOtherApp ? <DashboardWindowButtons /> : null}
</>
)
})
DashboardNavigationTrailing.propTypes = {
showControls: PropTypes.bool,
isOtherApp: PropTypes.bool,
controlsReady: PropTypes.bool,
isMobile: PropTypes.bool,
isElectron: PropTypes.bool,
unreadCount: PropTypes.number,
userProfile: PropTypes.shape({
name: PropTypes.string,
username: PropTypes.string
}),
availableUpdate: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
userPopoverOpen: PropTypes.bool,
onUserPopoverOpenChange: PropTypes.func,
onSearch: PropTypes.func,
onNotifications: PropTypes.func,
onCheckUpdates: PropTypes.func
}
const mapSidebarItemsToMenu = (items, navigate) =>
items.map((item) => {
if (item?.type === 'divider') {
@ -82,8 +331,7 @@ const mapSidebarItemsToMenu = (items, navigate) =>
const DashboardNavigation = () => {
const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext)
const { connecting, connected, userSettings, userSettingsLoaded } =
useContext(ApiServerContext)
const { userSettings, userSettingsLoaded } = useContext(ApiServerContext)
const { authenticated } = useContext(AuthContext)
const {
showNavigationLabels,
@ -93,7 +341,6 @@ const DashboardNavigation = () => {
} = useThemeContext()
const { toggleNotificationCenter, unreadCount } =
useContext(NotificationContext)
const [apiServerState, setApiServerState] = useState('disconnected')
const navigate = useNavigate()
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
@ -208,17 +455,18 @@ const DashboardNavigation = () => {
[includeDevItems, location.pathname, selectedKey, userProfile]
)
const headerMenuItems = isMobile
const headerMenuItems = useMemo(
() =>
isMobile
? sidebarMenuItems
: showNavigationLabels
? mainMenuItems
: mainMenuItems.map((item) => ({ ...item, label: undefined }))
: mainMenuItems.map((item) => ({ ...item, label: undefined })),
[isMobile, mainMenuItems, showNavigationLabels, sidebarMenuItems]
)
const [userPopoverOpen, setUserPopoverOpen] = useState(false)
const userPopoverContent = (
<UserProfilePopover onClose={() => setUserPopoverOpen(false)} />
)
const [anyModalVisible, setAnyModalVisible] = useState(false)
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@ -228,19 +476,12 @@ const DashboardNavigation = () => {
setSelectedKey(nextItem?.key || sectionKey)
}, [location.pathname, mainMenuItems])
useEffect(() => {
if (connecting == true) {
setApiServerState('connecting')
} else if (connected == true) {
setApiServerState('connected')
} else {
setApiServerState('disconnected')
}
}, [connecting, connected])
const handleMainMenuClick = ({ key }) => {
const handleMainMenuClick = useCallback(
({ key }) => {
navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
}
},
[includeDev, navigate, userProfile]
)
useEffect(() => {
if (!isElectron || !setSidebarViewMenu) return
@ -254,44 +495,141 @@ const DashboardNavigation = () => {
const showDesktopLogo = !isElectron && !isMobile
const showMobileLogo = !isElectron && isMobile
const showControls = !isElectron || authenticated
useEffect(() => {
if (!isElectron) {
setAnyModalVisible(false)
return
}
const menu = (
const isModalWrapVisible = (node) => {
if (!(node instanceof Element)) return false
if (node.getAttribute('aria-hidden') === 'true') return false
const style = window.getComputedStyle(node)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.pointerEvents !== 'none'
)
}
const updateModalVisibility = () => {
const wraps = document.querySelectorAll('.ant-modal-wrap')
setAnyModalVisible(Array.from(wraps).some(isModalWrapVisible))
}
let frameId = 0
const scheduleUpdate = () => {
if (frameId) return
frameId = window.requestAnimationFrame(() => {
frameId = 0
updateModalVisibility()
})
}
updateModalVisibility()
const observer = new MutationObserver(scheduleUpdate)
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'aria-hidden']
})
return () => {
observer.disconnect()
if (frameId) window.cancelAnimationFrame(frameId)
}
}, [isElectron])
const controlsReady = !isElectron || authenticated
const showControls = controlsReady && !anyModalVisible
const handleSearch = useCallback(() => {
showSpotlight()
}, [showSpotlight])
const handleNotifications = useCallback(() => {
toggleNotificationCenter()
}, [toggleNotificationCenter])
const handleCheckUpdates = useCallback(() => {
checkForUpdates()
}, [checkForUpdates])
const overflowedIndicator = useMemo(
() => (
<Button
type='text'
icon={<MenuIcon />}
style={NAV_OVERFLOW_BUTTON_STYLE}
/>
),
[]
)
const menu = useMemo(
() => (
<Menu
mode='horizontal'
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels && !isMobile ? 'no-navigation-labels' : ''}${isMobile ? 'mobile-menu' : ''}`}
items={headerMenuItems}
style={{
flexWrap: 'wrap',
flexGrow: 1,
flexGrow: isElectron && !isMobile ? 0 : 1,
border: 0,
minWidth: 0
}}
onClick={isMobile ? undefined : handleMainMenuClick}
selectedKeys={[isMobile ? selectedSidebarItemKey : selectedKey]}
overflowedIndicator={
<Button
type='text'
icon={<MenuIcon />}
style={{ marginBottom: '4px' }}
overflowedIndicator={overflowedIndicator}
/>
}
),
[
handleMainMenuClick,
headerMenuItems,
isElectron,
isMobile,
overflowedIndicator,
selectedKey,
selectedSidebarItemKey,
showNavigationLabels
]
)
const navigationTrailing = (
<DashboardNavigationTrailing
showControls={showControls}
isOtherApp={isOtherApp}
controlsReady={controlsReady}
isMobile={isMobile}
isElectron={isElectron}
unreadCount={unreadCount}
userProfile={userProfile}
availableUpdate={availableUpdate}
userPopoverOpen={userPopoverOpen}
onUserPopoverOpenChange={setUserPopoverOpen}
onSearch={handleSearch}
onNotifications={handleNotifications}
onCheckUpdates={handleCheckUpdates}
/>
)
const navigationContents = (
<Flex style={{ width: '100%' }} align='center'>
const navigationLeading = (
<>
{isMacOSApp ? <DashboardWindowButtons /> : null}
{isOtherApp ? (
<>
<WindowAppMenu />{' '}
<Divider
type='vertical'
style={{ height: '14px', margin: '3px 3px 0 1.5px' }}
style={{
height: '14px',
margin: showNavigationLabels
? '3px 3px 0 1.5px'
: '3px 0px 0 1.5px'
}}
/>
</>
) : null}
{showControls && isMobile && menu}
{isMobile ? (
<NavigationFade visible={showControls}>{menu}</NavigationFade>
) : null}
{showDesktopLogo == true ? (
<FarmControlLogo
style={{
@ -308,146 +646,77 @@ const DashboardNavigation = () => {
}}
/>
) : null}
<div style={{ flexGrow: 1 }}>
{showControls && !isMobile && menu}
{!showControls && (
<Text
type='secondary'
style={{
fontSize: '14px',
marginLeft: '8px',
userSelect: 'none',
'--webkit-user-select': 'none'
}}
>
Farm Control
</Text>
)}
</div>
</>
)
<div className='electrobun-webkit-app-region-no-drag'>
<Flex
gap={'small'}
align='center'
style={{ marginTop: '-2px', marginRight: '6px' }}
const showWindowTitle = isElectron && !showControls
const windowTitle = (
<NavigationFade
visible={showWindowTitle}
className='dashboard-navigation-window-title'
>
{showControls && (
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
<WebAppSwitcher />
<KeyboardShortcut
shortcut='alt+q'
hint='ALT Q'
onTrigger={() => showSpotlight()}
>
<Button
icon={<SearchIcon />}
type='text'
style={{ marginTop: '4px' }}
onClick={() => showSpotlight()}
<Text type='secondary'>Farm Control</Text>
</NavigationFade>
)
const navigationCenterBorder = (
<div
className={`dashboard-navigation-center-border${
showControls ? ' dashboard-navigation-center-border-hidden' : ''
}`}
aria-hidden='true'
/>
</KeyboardShortcut>
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={{ padding: 0, fontWeight: 600 }}
>
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'
onTrigger={() => toggleNotificationCenter()}
>
<Button
icon={<BellIcon />}
type='text'
style={{ marginTop: '2px' }}
onClick={() => toggleNotificationCenter()}
/>
</KeyboardShortcut>
</Badge>
</Space>
)}
{import.meta.env.MODE === 'development' && (
<Space>
{apiServerState === 'connected' ? (
<Tooltip content='Connected to api server'>
<Tag
color='success'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
)
const navigationContents = (
<Flex style={{ width: '100%' }} align='center'>
{navigationLeading}
<div className='dashboard-navigation-center'>
{!isMobile ? (
<NavigationFade visible style={NAV_CENTER_FADE_STYLE}>
{menu}
</NavigationFade>
) : null}
{apiServerState === 'connecting' ? (
<Tooltip content='Connecting to api erver...'>
<Tag
color='warning'
style={{ marginRight: 0 }}
icon={<LoadingOutlined />}
/>
</Tooltip>
) : null}
{apiServerState === 'disconnected' ? (
<Tooltip content='Disconnected from api server'>
<Tag
color='error'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
<Tooltip content='Developer'>
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip>
</Space>
)}
{showControls && userProfile ? (
<Space>
<Popover
content={userPopoverContent}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={setUserPopoverOpen}
arrow={false}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Popover>
</Space>
) : null}
{showControls && isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
color='cyan'
onClick={() => checkForUpdates()}
>
Update Available
</Tag>
) : null}
</Flex>
{windowTitle}
</div>
{isOtherApp ? <DashboardWindowButtons /> : null}
{navigationTrailing}
</Flex>
)
const electronDragClass =
isFullScreen || (isMaximized && isOtherApp)
? 'electrobun-webkit-app-region-no-drag'
: 'electrobun-webkit-app-region-drag'
return (
<>
{isElectron ? (
<Flex
className={`ant-menu-horizontal electron-navigation-wrapper ant-menu-light ${isFullScreen || (isMaximized && isOtherApp) ? 'electrobun-webkit-app-region-no-drag' : 'electrobun-webkit-app-region-drag'}`}
className={`electron-navigation-wrapper ${electronDragClass}`}
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
>
{navigationContents}
<Flex align='center' className='electron-navigation-leading'>
{navigationLeading}
</Flex>
<div className='dashboard-navigation-center'>
{!isMobile ? (
<NavigationFade
visible={showControls}
style={NAV_ELECTRON_CENTER_FADE_STYLE}
>
<DashboardElectronCenterItems
menu={menu}
showNavigationLabels={showNavigationLabels}
/>
</NavigationFade>
) : null}
{windowTitle}
{navigationCenterBorder}
</div>
<Flex align='center' className='electron-navigation-trailing'>
{navigationTrailing}
</Flex>
</Flex>
) : (
<Flex vertical>

View File

@ -25,6 +25,7 @@ import usePageLayout, {
normalizeColumnProportions
} from '../hooks/usePageLayout'
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const flattenOverviewSections = (sectionList = []) => {
const leaves = {}
@ -124,6 +125,16 @@ const DashboardOverviewPage = ({
onOpenActionsModal,
sections = []
}) => {
const overviewSection = pageName.endsWith('Overview')
? pageName.slice(0, -'Overview'.length)
: ''
const overviewTitle = overviewSection
? `Overview - ${overviewSection}`
: pageName
const overviewIconKey = overviewSection
? `${overviewSection.charAt(0).toLowerCase()}${overviewSection.slice(1)}`
: undefined
useNavigationTabPage({ title: overviewTitle, iconKey: overviewIconKey })
const [savedCollapseState, updateCollapseState] = useCollapseState(
pageName,
collapseDefaults

View File

@ -13,8 +13,8 @@ import { ElectronContext } from '../context/ElectronContext'
import AdaptiveSplitter from './AdaptiveSplitter'
const COLLAPSE_BELOW_PX = 200
const DEFAULT_SIDEBAR_PX = 300
const MAX_SIDEBAR_PX = 400
const DEFAULT_SIDEBAR_PX = 285
const MAX_SIDEBAR_PX = 305
const COLLAPSED_WIDTH_PX = 80
const ELECTRON_COLLAPSED_WIDTH_PX = 55
const EXPAND_DRAG_PX = 10

View File

@ -0,0 +1,244 @@
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-router-dom'
import { TableStateProvider } from '../context/TableStateContext'
import {
NavigationTabActiveContext,
NavigationTabIdContext,
createNavigationTabActiveStore,
useNavigationTabs
} from '../context/NavigationTabsContext'
import { wrapDashboardChildRoutes } from './DashboardChildRoutes'
const locationsEqual = (left, right) =>
Boolean(
left &&
right &&
left.pathname === right.pathname &&
(left.search || '') === (right.search || '') &&
(left.hash || '') === (right.hash || '')
)
const toTabLocation = (tabId, source) => ({
pathname: source?.pathname || '/',
search: source?.search ?? '',
hash: source?.hash ?? '',
state: source?.state ?? null,
key: `tab-${tabId}`
})
const reuseTabLocation = (tabId, previous, next) => {
if (
previous &&
locationsEqual(previous, next) &&
previous.key === `tab-${tabId}` &&
(previous.state ?? null) === (next.state ?? null)
) {
return previous
}
return toTabLocation(tabId, next)
}
const entryToLocation = (entry, fallbackLocation, tabId) =>
toTabLocation(tabId, {
pathname: entry?.pathname || fallbackLocation.pathname,
search: entry?.search ?? fallbackLocation.search ?? '',
hash: entry?.hash ?? fallbackLocation.hash ?? '',
state: entry?.state ?? null
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1]
const tabMatchesLocation = (tab, loc) => {
const entry = getTabCurrentEntry(tab)
if (!entry || !loc) return false
return locationsEqual(entry, loc)
}
const locationBelongsToLeavingTab = (tab, frozen, loc) => {
if (!loc) return false
if (frozen && locationsEqual(frozen, loc)) return true
return tabMatchesLocation(tab, loc)
}
const CachedTabRouteLayout = () => (
<TableStateProvider>
<Outlet />
</TableStateProvider>
)
const areLocationsEqual = (left, right) =>
left === right ||
(locationsEqual(left, right) &&
left?.key === right?.key &&
(left?.state ?? null) === (right?.state ?? null))
const arePanePropsEqual = (prev, next) =>
prev.tabId === next.tabId &&
prev.isActive === next.isActive &&
prev.registerTabPane === next.registerTabPane &&
areLocationsEqual(prev.loc, next.loc)
const DashboardTabPaneRoutes = memo(function DashboardTabPaneRoutes({ loc }) {
const tabRoutes = useMemo(
() => wrapDashboardChildRoutes(<CachedTabRouteLayout />),
[]
)
const locationContextValue = useMemo(
() => ({ location: loc, navigationType: 'POP' }),
[loc]
)
return (
<UNSAFE_LocationContext.Provider value={locationContextValue}>
<Routes location={loc}>{tabRoutes}</Routes>
</UNSAFE_LocationContext.Provider>
)
}, (prev, next) => areLocationsEqual(prev.loc, next.loc))
DashboardTabPaneRoutes.displayName = 'DashboardTabPaneRoutes'
DashboardTabPaneRoutes.propTypes = {
loc: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
hash: PropTypes.string,
key: PropTypes.string,
state: PropTypes.any
}).isRequired
}
const DashboardTabPane = memo(function DashboardTabPane({
tabId,
isActive,
loc,
registerTabPane
}) {
const storeRef = useRef(null)
if (storeRef.current == null) {
storeRef.current = createNavigationTabActiveStore(isActive)
}
useLayoutEffect(() => {
storeRef.current.setActive(isActive)
}, [isActive])
return (
<div
ref={(element) => registerTabPane(tabId, element)}
data-tab-id={tabId}
className={classNames('dashboard-tab-pane', {
'dashboard-tab-pane-active': isActive,
'dashboard-tab-pane-inactive': !isActive
})}
aria-hidden={!isActive}
inert={!isActive}
>
<NavigationTabIdContext.Provider value={tabId}>
<NavigationTabActiveContext.Provider value={storeRef.current}>
<DashboardTabPaneRoutes loc={loc} />
</NavigationTabActiveContext.Provider>
</NavigationTabIdContext.Provider>
</div>
)
}, arePanePropsEqual)
DashboardTabPane.displayName = 'DashboardTabPane'
DashboardTabPane.propTypes = {
tabId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
loc: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
hash: PropTypes.string,
key: PropTypes.string,
state: PropTypes.any
}).isRequired,
registerTabPane: PropTypes.func.isRequired
}
const DashboardTabPanes = () => {
const location = useLocation()
const { tabs, activeTabId, registerTabPane } = useNavigationTabs()
const frozenRef = useRef(new Map())
const prevActiveIdRef = useRef(activeTabId)
const lastLocationRef = useRef(location)
const pendingRestoreRef = useRef(false)
const prevActiveId = prevActiveIdRef.current
const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId)
if (switchedTabs) {
const previousTab = tabs.find((tab) => tab.id === prevActiveId)
const previousFrozen = frozenRef.current.get(prevActiveId)
const lastLoc = lastLocationRef.current
if (locationBelongsToLeavingTab(previousTab, previousFrozen, lastLoc)) {
frozenRef.current.set(
prevActiveId,
reuseTabLocation(prevActiveId, previousFrozen, lastLoc)
)
} else if (!previousFrozen && previousTab) {
frozenRef.current.set(
prevActiveId,
entryToLocation(getTabCurrentEntry(previousTab), lastLoc, prevActiveId)
)
}
pendingRestoreRef.current = true
}
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (activeTab && tabMatchesLocation(activeTab, location)) {
pendingRestoreRef.current = false
frozenRef.current.set(
activeTabId,
reuseTabLocation(
activeTabId,
frozenRef.current.get(activeTabId),
location
)
)
}
for (const tabId of [...frozenRef.current.keys()]) {
if (!tabs.some((tab) => tab.id === tabId)) {
frozenRef.current.delete(tabId)
}
}
prevActiveIdRef.current = activeTabId
lastLocationRef.current = location
if (tabs.length === 0) {
return <Outlet />
}
return (
<div className='dashboard-tab-panes'>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
const loc =
isActive && !pendingRestoreRef.current
? reuseTabLocation(tab.id, frozenRef.current.get(tab.id), location)
: frozenRef.current.get(tab.id) ||
entryToLocation(getTabCurrentEntry(tab), location, tab.id)
frozenRef.current.set(tab.id, loc)
return (
<DashboardTabPane
key={tab.id}
tabId={tab.id}
isActive={isActive}
loc={loc}
registerTabPane={registerTabPane}
/>
)
})}
</div>
)
}
export default DashboardTabPanes

View File

@ -0,0 +1,89 @@
import { useLayoutEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { LoadingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
const DashboardTabPreview = ({ src, srcTheme, loading = false }) => {
const { isDarkMode } = useThemeContext()
const currentTheme = isDarkMode ? 'dark' : 'light'
const usableSrc = src && srcTheme === currentTheme ? src : null
const lastSrcRef = useRef(usableSrc)
const [overlaySrc, setOverlaySrc] = useState(usableSrc)
const [revealing, setRevealing] = useState(false)
useLayoutEffect(() => {
if (srcTheme !== currentTheme) {
lastSrcRef.current = null
setOverlaySrc(null)
setRevealing(false)
return
}
if (loading) {
const locked = lastSrcRef.current || usableSrc || null
lastSrcRef.current = locked
setOverlaySrc(locked)
setRevealing(false)
return
}
if (usableSrc) {
lastSrcRef.current = usableSrc
}
setRevealing(true)
}, [currentTheme, loading, srcTheme, usableSrc])
const handleOverlayTransitionEnd = (event) => {
if (event.propertyName !== 'opacity' || !revealing) return
setOverlaySrc(null)
setRevealing(false)
}
return (
<div
className={classNames('dashboard-tab-preview', {
'dark-mode': isDarkMode
})}
>
<div className='dashboard-tab-preview-stack'>
{usableSrc ? (
<img
src={usableSrc}
alt=''
className='dashboard-tab-preview-svg dashboard-tab-preview-svg-current'
/>
) : null}
{overlaySrc ? (
<img
src={overlaySrc}
alt=''
className={classNames(
'dashboard-tab-preview-svg',
'dashboard-tab-preview-svg-previous',
{ 'is-revealing': revealing }
)}
onTransitionEnd={handleOverlayTransitionEnd}
/>
) : null}
{loading ? (
<div className='dashboard-tab-preview-loading'>
<LoadingOutlined
spin
className='dashboard-tab-preview-loading-icon'
/>
</div>
) : null}
</div>
</div>
)
}
DashboardTabPreview.propTypes = {
src: PropTypes.string,
srcTheme: PropTypes.oneOf(['dark', 'light']),
loading: PropTypes.bool
}
export default DashboardTabPreview

View File

@ -0,0 +1,803 @@
import PropTypes from 'prop-types'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
memo
} from 'react'
import { Button, Flex, Popover } from 'antd'
import DashboardTabPreview from './DashboardTabPreview'
import ElipsisText from './ElipsisText'
import classNames from 'classnames'
import ScrollBox from './ScrollBox'
import PlusIcon from '../../Icons/PlusIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import HomeIcon from '../../Icons/HomeIcon'
import { getModelByName } from '../../../database/ObjectModels'
import { getSidebarIconComponent } from '../../Icons/sidebarIconMap'
import {
useNavigationTabs,
useTabPreview
} from '../context/NavigationTabsContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
import { hasExternalTabDrag, writeTabDragData } from './tabDrag'
const DashboardTabChrome = () => (
<>
<span className='dashboard-tab-item-outline-container' aria-hidden='true'>
<span className='dashboard-tab-item-outline' aria-hidden='true' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-left'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-right'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
</>
)
const DashboardTabLabel = ({ tab, onClose }) => (
<Flex align='center' gap={6} className='dashboard-tabs-label'>
<ElipsisText>{tab.title || 'Farm Control'}</ElipsisText>
<span
className='dashboard-tabs-close'
role='button'
tabIndex={-1}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onClose?.(tab.id)
}}
onMouseDown={(event) => event.stopPropagation()}
>
<XMarkIcon style={{ fontSize: 10 }} />
</span>
</Flex>
)
DashboardTabLabel.propTypes = {
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string
}).isRequired,
onClose: PropTypes.func
}
const DashboardTabItem = memo(function DashboardTabItem({
htmlId = undefined,
tab,
tabIndex,
isSelected,
isDragging,
dropPosition,
onDragStart,
onDragEnd,
onClick,
onKeyDown,
onDragOver,
onDrop,
onClose,
captureTabPreview
}) {
const {
src: previewSrc,
theme: previewTheme,
capturing: previewCapturing
} = useTabPreview(tab.id)
const [previewOpen, setPreviewOpen] = useState(false)
const previewTargetRef = useRef(null)
const model = tab.modelName ? getModelByName(tab.modelName) : null
const SidebarIcon = getSidebarIconComponent(tab.iconKey)
const Icon = model?.icon || SidebarIcon || HomeIcon
const handlePreviewOpenChange = useCallback(
(nextOpen) => {
if (isSelected || isDragging) {
setPreviewOpen(false)
return
}
setPreviewOpen(nextOpen)
if (nextOpen) {
void captureTabPreview(tab.id)
}
},
[captureTabPreview, isDragging, isSelected, tab.id]
)
useEffect(() => {
if (isSelected || isDragging) {
setPreviewOpen(false)
}
}, [isDragging, isSelected])
const tabButton = (
<button
ref={previewTargetRef}
type='button'
role='tab'
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
className={classNames(
'dashboard-tab-item',
'electrobun-webkit-app-region-no-drag',
{
'dashboard-tab-item-active': isSelected,
'dashboard-tab-item-dragging': isDragging,
'dashboard-tab-item-drop-before': dropPosition === 'before',
'dashboard-tab-item-drop-after': dropPosition === 'after'
}
)}
draggable
onDragStart={(event) => onDragStart(event, tab.id)}
onDragEnd={onDragEnd}
id={htmlId}
onClick={() => onClick(tab.id)}
onKeyDown={(event) => onKeyDown(event, tabIndex)}
onDragOver={(event) => onDragOver(event, tab)}
onDrop={(event) => onDrop(event, tab)}
>
<DashboardTabChrome />
<span className='dashboard-tab-item-body'>
<span className='dashboard-tab-item-icon'>
<Icon style={{ fontSize: 14 }} color='secondary' />
</span>
<DashboardTabLabel tab={tab} onClose={onClose} />
</span>
</button>
)
if (isSelected) {
return tabButton
}
return (
<Popover
trigger='hover'
placement='bottom'
arrow={false}
mouseEnterDelay={1}
mouseLeaveDelay={0}
destroyOnHidden
open={isDragging ? false : previewOpen}
onOpenChange={handlePreviewOpenChange}
content={
<DashboardTabPreview
src={previewSrc}
srcTheme={previewTheme}
loading={previewCapturing}
/>
}
classNames={{ root: 'dashboard-tab-preview-popover' }}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
>
{tabButton}
</Popover>
)
})
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = {
htmlId: PropTypes.string,
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string,
modelName: PropTypes.string,
iconKey: PropTypes.string
}).isRequired,
tabIndex: PropTypes.number.isRequired,
isSelected: PropTypes.bool,
isDragging: PropTypes.bool,
dropPosition: PropTypes.oneOf(['before', 'after']),
onDragStart: PropTypes.func.isRequired,
onDragEnd: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
onKeyDown: PropTypes.func.isRequired,
onDragOver: PropTypes.func.isRequired,
onDrop: PropTypes.func.isRequired,
onClose: PropTypes.func,
captureTabPreview: PropTypes.func
}
const STICKY_EDGE_INSET = 20
const STICKY_LINE_CORNER_GAP = 16
const STICKY_MASK_INSET = 58
const measureTabMetrics = (container) => {
const button = container.querySelector('.dashboard-tab-item')
const icon = container.querySelector('.dashboard-tab-item-icon')
if (!button || !icon) {
return {
naturalWidth: container.offsetWidth,
minWidth: container.offsetWidth
}
}
const styles = window.getComputedStyle(button)
const minWidth = Math.ceil(
icon.getBoundingClientRect().width +
parseFloat(styles.paddingLeft) +
parseFloat(styles.paddingRight) +
parseFloat(styles.borderLeftWidth) +
parseFloat(styles.borderRightWidth)
)
return {
naturalWidth: container.offsetWidth,
minWidth: Math.max(minWidth, 1)
}
}
const hideStickyClone = (cloneEl) => {
if (!cloneEl) return
delete cloneEl.dataset.edge
delete cloneEl.dataset.faded
cloneEl.style.removeProperty('--tab-sticky-width')
cloneEl.style.removeProperty('--tab-label-opacity')
cloneEl.style.left = ''
cloneEl.style.right = ''
cloneEl.setAttribute('inert', '')
}
const setSimplebarMaskInset = (maskEl, edge, inset) => {
if (!maskEl) return
const wrapEl = maskEl.closest('.dashboard-tabs-wrap') || maskEl
if (!edge) {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.removeProperty('--tab-mask-right')
return
}
if (edge === 'left') {
wrapEl.style.setProperty('--tab-mask-left', `${inset}px`)
wrapEl.style.removeProperty('--tab-mask-right')
} else {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.setProperty('--tab-mask-right', `${inset}px`)
}
}
const setScrollEdgeFades = (root, scrollEl) => {
if (!root) return
if (!scrollEl) {
root.dataset.atStart = ''
root.dataset.atEnd = ''
return
}
const atStart = scrollEl.scrollLeft <= 1
const atEnd =
scrollEl.scrollLeft + scrollEl.clientWidth >= scrollEl.scrollWidth - 1
if (atStart) root.dataset.atStart = ''
else delete root.dataset.atStart
if (atEnd) root.dataset.atEnd = ''
else delete root.dataset.atEnd
}
const setStickyStripLine = (root, lineEl, edge, lineInset) => {
if (root) {
if (!edge) delete root.dataset.stickyEdge
else root.dataset.stickyEdge = edge
}
const padding = 4
if (!lineEl) return
if (!edge) {
delete lineEl.dataset.edge
lineEl.style.left = ''
lineEl.style.right = ''
return
}
lineEl.dataset.edge = edge
if (edge === 'left') {
lineEl.style.left = `${lineInset + padding}px`
lineEl.style.right = '0px'
} else {
lineEl.style.left = '0px'
lineEl.style.right = `${lineInset + padding}px`
}
}
const setOriginalStickyState = (container, sticky) => {
if (!container) return
if (sticky) {
container.dataset.stickyPlaceholder = 'true'
container.setAttribute('inert', '')
} else {
delete container.dataset.stickyPlaceholder
container.removeAttribute('inert')
}
}
const bindActiveTabStickyScroll = (scrollEl, list, cloneEl, root, lineEl) => {
const state = {
activeEl: null,
updating: false
}
const maskEl = scrollEl.closest('.simplebar-mask')
const wrapperEl = scrollEl.closest('.simplebar-wrapper') || scrollEl
const clearStickyVisuals = () => {
hideStickyClone(cloneEl)
setStickyStripLine(root, lineEl, null)
setSimplebarMaskInset(maskEl, null)
}
const update = () => {
if (state.updating) return
state.updating = true
try {
setScrollEdgeFades(root, scrollEl)
const container = list.querySelector(
'.dashboard-tab-item-container-active'
)
if (state.activeEl && state.activeEl !== container) {
setOriginalStickyState(state.activeEl, false)
}
state.activeEl = container
if (!container || !cloneEl) {
clearStickyVisuals()
return
}
const metrics = measureTabMetrics(container)
const naturalWidth = metrics.naturalWidth
const minWidth = Math.min(metrics.minWidth, naturalWidth)
const maxShrink = Math.max(0, naturalWidth - minWidth)
const scrollLeft = scrollEl.scrollLeft
const viewportWidth = wrapperEl.clientWidth
if (viewportWidth <= 0) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const wrapperRect = wrapperEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const naturalLeft = scrollLeft + (containerRect.left - wrapperRect.left)
const isFirstTab = container === list.firstElementChild
if (isFirstTab && scrollLeft < 1) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const overflowLeft = scrollLeft + STICKY_EDGE_INSET - naturalLeft
const overflowRight =
naturalLeft +
naturalWidth -
(scrollLeft + viewportWidth - STICKY_EDGE_INSET)
let edge = null
let overflow = 0
if (overflowLeft > 0.5 && overflowLeft >= overflowRight) {
edge = 'left'
overflow = overflowLeft
} else if (overflowRight > 0.5) {
edge = 'right'
overflow = overflowRight
}
if (!edge) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const shrink = Math.min(overflow, maxShrink)
const visualWidth = Math.max(minWidth, naturalWidth - shrink)
const shrinkProgress = maxShrink === 0 ? 1 : shrink / maxShrink
const fade = 1 - shrinkProgress
const maskInset = STICKY_MASK_INSET * shrinkProgress
setOriginalStickyState(container, true)
cloneEl.removeAttribute('inert')
cloneEl.dataset.edge = edge
if (fade < 0.25) cloneEl.dataset.faded = 'true'
else delete cloneEl.dataset.faded
const parentRect =
cloneEl.offsetParent?.getBoundingClientRect() || wrapperRect
const cloneOffset =
edge === 'left'
? wrapperRect.left - parentRect.left + STICKY_EDGE_INSET
: parentRect.right - wrapperRect.right + STICKY_EDGE_INSET
if (edge === 'left') {
cloneEl.style.left = `${cloneOffset}px`
cloneEl.style.right = 'auto'
} else {
cloneEl.style.left = 'auto'
cloneEl.style.right = `${cloneOffset}px`
}
cloneEl.style.setProperty('--tab-sticky-width', `${visualWidth}px`)
cloneEl.style.setProperty('--tab-label-opacity', String(fade))
setSimplebarMaskInset(maskEl, edge, maskInset)
setStickyStripLine(
root,
lineEl,
edge,
cloneOffset + visualWidth + STICKY_LINE_CORNER_GAP
)
} finally {
state.updating = false
}
}
let frame = 0
const scheduleUpdate = () => {
if (frame) return
frame = window.requestAnimationFrame(() => {
frame = 0
update()
})
}
update()
scrollEl.addEventListener('scroll', scheduleUpdate, { passive: true })
window.addEventListener('resize', scheduleUpdate)
const observer = new ResizeObserver(scheduleUpdate)
observer.observe(scrollEl)
if (wrapperEl !== scrollEl) observer.observe(wrapperEl)
observer.observe(list)
return () => {
scrollEl.removeEventListener('scroll', scheduleUpdate)
window.removeEventListener('resize', scheduleUpdate)
observer.disconnect()
if (frame) window.cancelAnimationFrame(frame)
setOriginalStickyState(state.activeEl, false)
clearStickyVisuals()
delete root.dataset.atStart
delete root.dataset.atEnd
}
}
const useActiveTabStickyScroll = (activeTabId, tabs) => {
const rootRef = useRef(null)
const listRef = useRef(null)
const cloneRef = useRef(null)
const lineRef = useRef(null)
const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}`
useLayoutEffect(() => {
const root = rootRef.current
const list = listRef.current
const cloneEl = cloneRef.current
const lineEl = lineRef.current
if (!root || !list) return undefined
let cancelled = false
let teardown = () => {}
let frame = 0
const tryBind = () => {
if (cancelled) return
const scrollEl = root.querySelector('.simplebar-content-wrapper')
if (!scrollEl) {
frame = window.requestAnimationFrame(tryBind)
return
}
teardown = bindActiveTabStickyScroll(
scrollEl,
list,
cloneEl,
root,
lineEl
)
}
tryBind()
return () => {
cancelled = true
if (frame) window.cancelAnimationFrame(frame)
teardown()
}
}, [tabLayoutKey])
return { rootRef, listRef, cloneRef, lineRef }
}
const DashboardTabs = () => {
const {
tabs,
activeTabId,
selectTab,
addTab,
registerTabStripRoot,
closeTab,
reorderTabs,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId,
tabs
)
const [draggedValue, setDraggedValue] = useState(null)
const [dropTarget, setDropTarget] = useState(null)
const clearDragState = useCallback(() => {
setDraggedValue(null)
setDropTarget(null)
}, [])
const handleItemDragStart = (event, tabId) => {
const tab = tabs.find((item) => item.id === tabId)
if (!tab) return
event.stopPropagation()
const item = event.currentTarget
if (item) {
const rect = item.getBoundingClientRect()
event.dataTransfer.setDragImage(
item,
event.clientX - rect.left,
event.clientY - rect.top
)
}
setDraggedValue(tabId)
event.dataTransfer.effectAllowed = 'move'
writeTabDragData(event, {
tab,
sourceWindowId: getDesktopWindowId()
})
handleTabDragStart(tabId)
}
const handleItemDragEnd = () => {
handleTabDragEnd()
clearDragState()
}
const handleItemDragOver = (event, tab) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (draggedValue != null && String(tab.id) === String(draggedValue)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'move'
const rect = event.currentTarget.getBoundingClientRect()
setDropTarget({
value: tab.id,
insertBefore: event.clientX < rect.left + rect.width / 2
})
}
const handleItemDrop = (event, tab) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
event.preventDefault()
event.stopPropagation()
const target = dropTarget || {
value: tab.id,
insertBefore: true
}
if (isExternal) {
void handleExternalTabDrop(target.value, target.insertBefore)
clearDragState()
return
}
if (draggedValue == null) return
if (String(draggedValue) !== String(target.value)) {
reorderTabs(draggedValue, target.value, target.insertBefore)
}
clearDragState()
}
const handleListDragOver = (event) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const lastTab = tabs[tabs.length - 1]
if (!lastTab) return
setDropTarget({
value: lastTab.id,
insertBefore: false
})
}
const handleListDrop = (event) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.stopPropagation()
const lastTab = tabs[tabs.length - 1]
if (!lastTab) {
clearDragState()
return
}
if (isExternal) {
void handleExternalTabDrop(lastTab.id, false)
clearDragState()
return
}
if (String(draggedValue) !== String(lastTab.id)) {
reorderTabs(draggedValue, lastTab.id, false)
}
clearDragState()
}
const handleStripDragOver = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const handleStripDrop = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
const lastTab = tabs[tabs.length - 1]
void handleExternalTabDrop(lastTab?.id, false)
}
const handleKeyDown = (event, tabIndex) => {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId)
const startIndex = currentIndex === -1 ? tabIndex : currentIndex
const nextIndex = (startIndex + direction + tabs.length) % tabs.length
selectTab(tabs[nextIndex].id)
}
const setRootRef = useCallback(
(element) => {
rootRef.current = element
registerTabStripRoot(element)
},
[registerTabStripRoot]
)
const isReordering = draggedValue != null || dropTarget != null
const activeTab = tabs.find((tab) => tab.id === activeTabId)
const activeTabIndex = activeTab
? tabs.findIndex((tab) => tab.id === activeTabId)
: 0
const tabItemProps = {
onDragStart: handleItemDragStart,
onDragEnd: handleItemDragEnd,
onClick: selectTab,
onKeyDown: handleKeyDown,
onDragOver: handleItemDragOver,
onDrop: handleItemDrop,
onClose: closeTab,
captureTabPreview
}
return (
<Flex
ref={setRootRef}
align='flex-start'
gap={4}
className={classNames(
'dashboard-tabs',
'electrobun-webkit-app-region-drag'
)}
style={{ flex: 1, minWidth: 0 }}
onDragOver={handleStripDragOver}
onDrop={handleStripDrop}
>
<ScrollBox className='dashboard-tabs-wrap' horizontal>
<Flex align='flex-start' gap={5} className='dashboard-tabs-inner'>
<div
ref={listRef}
className={classNames('dashboard-tabs-list', {
'dashboard-tabs-reordering': isReordering
})}
role='tablist'
onDragOver={handleListDragOver}
onDrop={handleListDrop}
>
{tabs.map((tab, index) => {
const isSelected = tab.id === activeTabId
const isDragging =
draggedValue != null && String(draggedValue) === String(tab.id)
const isDropTarget =
dropTarget != null &&
String(dropTarget.value) === String(tab.id) &&
String(draggedValue) !== String(tab.id)
return (
<div
key={tab.id}
className={classNames('dashboard-tab-item-container', {
'dashboard-tab-item-container-active': isSelected
})}
>
<DashboardTabItem
tab={tab}
tabIndex={index}
isSelected={isSelected}
isDragging={isDragging}
htmlId={`dashboard-tab-item-${tab.id}`}
dropPosition={
isDropTarget
? dropTarget.insertBefore
? 'before'
: 'after'
: undefined
}
{...tabItemProps}
/>
</div>
)
})}
</div>
<div className='dashboard-tabs-add-button-container'>
<Button
type='text'
className='dashboard-tabs-add-button electrobun-webkit-app-region-no-drag'
onClick={() => addTab()}
icon={<PlusIcon style={{ fontSize: 14, marginTop: 3 }} />}
/>
</div>
</Flex>
</ScrollBox>
<div
ref={lineRef}
className='dashboard-tabs-sticky-line'
aria-hidden='true'
/>
{activeTab ? (
<div
ref={cloneRef}
className='dashboard-tab-sticky-clone dashboard-tab-item-container dashboard-tab-item-container-active'
>
<DashboardTabItem
tab={activeTab}
tabIndex={activeTabIndex}
isSelected
isDragging={
draggedValue != null &&
String(draggedValue) === String(activeTab.id)
}
{...tabItemProps}
/>
</div>
) : null}
</Flex>
)
}
export default DashboardTabs

View File

@ -6,11 +6,13 @@ import MinusIcon from '../../Icons/MinusIcon'
import ContractIcon from '../../Icons/ContractIcon'
import ExpandIcon from '../../Icons/ExpandIcon'
import MacOSTrafficLights from './MacOSTrafficLights'
import { useThemeContext } from '../context/ThemeContext'
const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform, isFullScreen } =
useContext(ElectronContext)
const { showNavigationLabels } = useThemeContext()
const closeButton = (
<Button
icon={<XMarkIcon />}
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
<MacOSTrafficLights />
<Divider
type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }}
style={{
height: '14px',
margin: showNavigationLabels ? '3px 6px 0 0' : '3px 0px 0 0'
}}
/>
</>
) : (

View File

@ -7,6 +7,8 @@ import { getModelByName } from '../../../database/ObjectModels'
import { getObjectIdFromSearch } from '../../../utils/modelActions'
import { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext'
import { NavigationTabActiveContext } from '../context/NavigationTabsContext'
import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage'
const ModelPage = ({ modelName, pageName }) => {
const model = getModelByName(modelName)
@ -15,17 +17,35 @@ const ModelPage = ({ modelName, pageName }) => {
const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext)
const objectId = getObjectIdFromSearch(modelName, location.search)
const tabActiveStore = useContext(NavigationTabActiveContext)
useObjectNavigationTabPage({ modelName, pageName, objectId })
useEffect(() => {
const sync = () => {
if (!tabActiveStore.getSnapshot()) return
setCurrentObjectType(modelName)
if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile })
}
}
sync()
const unsubscribe = tabActiveStore.subscribe(sync)
return () => {
unsubscribe()
if (tabActiveStore.getSnapshot()) {
setCurrentObject(null)
setCurrentObjectType(null)
}
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile])
}
}, [
modelName,
objectId,
setCurrentObject,
setCurrentObjectType,
tabActiveStore,
userProfile
])
if (!page?.content) {
return null

View File

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

View File

@ -2,11 +2,11 @@ import PropTypes from 'prop-types'
import { Typography, Flex, Badge, Tag, Popover } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
import { findAction } from '../../../utils/modelActions'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { useActions } from '../context/ActionsContext'
import merge from 'lodash/merge'
import IdDisplay from './IdDisplay'
import ElipsisText from './ElipsisText'
@ -26,7 +26,7 @@ const ObjectDisplay = ({
const { subscribeToObjectUpdates, connected, fetchSpotlightData } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const navigate = useNavigate()
const { callAction } = useActions()
const idRef = useRef(null)
// Update event handler
@ -175,17 +175,9 @@ const ObjectDisplay = ({
const model = getModelByName(objectType)
const Icon = model.icon
const prefix = model.prefix
// Get hyperlink URL from model's default actions
var hyperlink = null
const defaultModelActions =
model.actions?.filter((action) => action.default == true) || []
const infoAction = findAction(model, 'info')
const objectId = getStringId(objectData)
if (defaultModelActions.length >= 1 && objectId) {
hyperlink = buildActionUrl(model, defaultModelActions[0], objectId, '', '')
}
// Render name with spotlight support
const renderNameDisplay = () => {
if (!objectData?.name) return null
@ -215,7 +207,12 @@ const ObjectDisplay = ({
return textElement
}
const canNavigate = showHyperlink && hyperlink != null
const canNavigate = showHyperlink && infoAction != null && objectId
const handleNavigate = () => {
if (!canNavigate) return
callAction(infoAction, { ...objectData, _id: objectId }, objectType)
}
return (
<Tag
@ -226,7 +223,7 @@ const ObjectDisplay = ({
cursor: canNavigate ? 'pointer' : undefined
}}
className='object-display-tag'
onClick={canNavigate ? () => navigate(hyperlink) : undefined}
onClick={canNavigate ? handleNavigate : undefined}
>
<Flex
gap={objectData?.color || objectData?.state ? 'small' : '5px'}

View File

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

View File

@ -1,5 +1,6 @@
import {
forwardRef,
memo,
useImperativeHandle,
useRef,
useEffect,
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {}
const EMPTY_VISIBLE_COLUMNS = {}
const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2
@ -291,7 +294,8 @@ EditableRow.propTypes = {
onRegister: PropTypes.func
}
const ObjectTable = forwardRef(
const ObjectTable = memo(
forwardRef(
(
{
type,
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
initialPage = 1,
viewMode: viewModeProp,
cards = false,
visibleColumns = {},
visibleColumns = EMPTY_VISIBLE_COLUMNS,
masterFilter,
size = 'middle',
onStateChange,
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions()
const isTabActive = useIsNavigationTabActive()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
return {}
}, [])
const rowActions =
model.actions?.filter((action) => action.row == true) || []
const rowActions = useMemo(
() => 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(
(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(
async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) {
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
// Subscribe to all object updates for this type (list/cards only)
useEffect(() => {
if (isKanban || connected !== true || !type) return
if (isKanban || connected !== true || !type || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates(
type,
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
}, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
useEffect(() => {
if (isKanban || connected !== true) return
if (isKanban || connected !== true || !isTabActive) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null
}
}
}, [type, connected, isKanban])
}, [type, connected, isKanban, isTabActive])
const updateData = useCallback(
(id, updatedData) => {
@ -1706,7 +1716,8 @@ const ObjectTable = forwardRef(
return () => registerPageSorter({})
}, [effectiveSorter, registerPageSorter, registerObjectListSorter])
const getFilterDropdown = ({
const getFilterDropdown = useCallback(
({
setSelectedKeys,
selectedKeys,
confirm,
@ -1727,6 +1738,8 @@ const ObjectTable = forwardRef(
filter={effectiveFilter}
masterFilter={resolvedMasterFilter}
/>
),
[effectiveFilter, resolvedMasterFilter, type]
)
const handleTableChange = (pagination, filters, sorter) => {
@ -1765,7 +1778,8 @@ const ObjectTable = forwardRef(
}
}
} else {
// All tab: persist personal filter/sort to URL/session
// All tab: persist personal filter/sort (session/URL, or this
// Electron tab's listState JSON when isElectron).
persistTableState(next, nextSorter)
}
const effective = buildEffectiveFilter(next)
@ -1858,9 +1872,47 @@ const ObjectTable = forwardRef(
]
)
const modelProperties = getModelProperties(type)
// Table columns from model properties
const columnsWithSkeleton = [
const modelProperties = useMemo(() => getModelProperties(type), [type])
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:
loading || lazyLoading ? (
@ -1919,51 +1971,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) => {
const prop = modelProperties.find((p) => p.name === colName)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
return
}
var fixed = prop.columnFixed || undefined
@ -1988,10 +2003,8 @@ const ObjectTable = forwardRef(
default:
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 columnConfig = {
@ -2056,16 +2069,12 @@ const ObjectTable = forwardRef(
)
}
columnsWithSkeleton.push(columnConfig)
columns.push(columnConfig)
}
})
if (
showActions &&
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
if (showActions && rowActions.length > 0 && hasRealTableRows) {
columns.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
@ -2073,13 +2082,39 @@ const ObjectTable = forwardRef(
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
width: 20 + rowActions.length * 30,
render: (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
const [cardColSpan, setCardColSpan] = useState(24)
const cardsContainerNodeRef = useRef(null)
@ -2465,6 +2500,7 @@ const ObjectTable = forwardRef(
</ObjectTableFilterContext.Provider>
)
}
)
)
ObjectTable.displayName = 'ObjectTable'

View File

@ -9,6 +9,7 @@ import {
} from 'react'
import { HolderOutlined } from '@ant-design/icons'
import classNames from 'classnames'
import { hasExternalTabDrag } from './tabDrag'
const SegmentedNavReorderContext = createContext(null)
@ -59,6 +60,9 @@ const SegmentedNav = ({
options = [],
onChange,
onReorder,
onExternalDrop,
onItemDragStart,
onItemDragEnd,
className,
disabled = false,
animated = true
@ -75,7 +79,10 @@ const SegmentedNav = ({
const [dropTarget, setDropTarget] = useState(null)
const enabledOptions = getEnabledOptions(options, disabled)
const isReordering = Boolean(onReorder && draggedValue != null)
const acceptsExternal = Boolean(onExternalDrop)
const isReordering = Boolean(
(onReorder || acceptsExternal) && (draggedValue != null || dropTarget)
)
const setItemRef = useCallback((optionValue, element) => {
if (element) {
@ -172,21 +179,32 @@ const SegmentedNav = ({
setDraggedValue(optionValue)
event.dataTransfer.effectAllowed = 'move'
if (!onItemDragStart) {
event.dataTransfer.setData('text/plain', String(optionValue))
}
onItemDragStart?.(event, optionValue)
},
[onReorder]
[onItemDragStart, onReorder]
)
const handleDragEnd = useCallback(() => {
onItemDragEnd?.()
clearDragState()
}, [clearDragState])
}, [clearDragState, onItemDragEnd])
const handleItemDragOver = useCallback(
(event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) {
const isExternal = acceptsExternal && hasExternalTabDrag(event)
if ((!onReorder && !isExternal) || !option.reorderable) {
return
}
if (String(option.value) === String(draggedValue)) {
if (draggedValue == null && !isExternal) {
return
}
if (
draggedValue != null &&
String(option.value) === String(draggedValue)
) {
return
}
@ -200,12 +218,13 @@ const SegmentedNav = ({
insertBefore: event.clientX < rect.left + rect.width / 2
})
},
[draggedValue, onReorder]
[acceptsExternal, draggedValue, onReorder]
)
const handleItemDrop = useCallback(
(event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) {
const isExternal = acceptsExternal && draggedValue == null
if ((!onReorder && !isExternal) || !option.reorderable) {
return
}
@ -217,18 +236,39 @@ const SegmentedNav = ({
insertBefore: true
}
if (isExternal) {
onExternalDrop?.(target.value, target.insertBefore, event)
clearDragState()
return
}
if (draggedValue == null) {
return
}
if (String(draggedValue) !== String(target.value)) {
onReorder(draggedValue, target.value, target.insertBefore)
}
clearDragState()
},
[clearDragState, draggedValue, dropTarget, onReorder]
[
acceptsExternal,
clearDragState,
draggedValue,
dropTarget,
onExternalDrop,
onReorder
]
)
const handleGroupDragOver = useCallback(
(event) => {
if (!onReorder || draggedValue == null) {
const isExternal = acceptsExternal && hasExternalTabDrag(event)
if (
(!onReorder && !isExternal) ||
(draggedValue == null && !isExternal)
) {
return
}
@ -253,12 +293,16 @@ const SegmentedNav = ({
insertBefore: false
})
},
[draggedValue, onReorder, options]
[acceptsExternal, draggedValue, onReorder, options]
)
const handleGroupDrop = useCallback(
(event) => {
if (!onReorder || draggedValue == null) {
const isExternal = acceptsExternal && draggedValue == null
if (
(!onReorder && !isExternal) ||
(draggedValue == null && !isExternal)
) {
return
}
@ -274,13 +318,31 @@ const SegmentedNav = ({
const reorderableOptions = options.filter((option) => option.reorderable)
const lastOption = reorderableOptions[reorderableOptions.length - 1]
if (lastOption && String(draggedValue) !== String(lastOption.value)) {
if (!lastOption) {
clearDragState()
return
}
if (isExternal) {
onExternalDrop?.(lastOption.value, false, event)
clearDragState()
return
}
if (String(draggedValue) !== String(lastOption.value)) {
onReorder(draggedValue, lastOption.value, false)
}
clearDragState()
},
[clearDragState, draggedValue, onReorder, options]
[
acceptsExternal,
clearDragState,
draggedValue,
onExternalDrop,
onReorder,
options
]
)
const getDragHandleProps = useCallback(
@ -381,6 +443,13 @@ const SegmentedNav = ({
isDropTarget && !dropTarget.insertBefore,
'segmented-nav-item-icon': option.icon
})}
draggable={Boolean(onReorder && option.reorderable)}
onDragStart={
option.reorderable
? (event) => handleDragStart(event, option.value)
: undefined
}
onDragEnd={option.reorderable ? handleDragEnd : undefined}
onClick={() => {
if (!isDisabled) {
onChange?.(option.value)
@ -431,6 +500,9 @@ SegmentedNav.propTypes = {
),
onChange: PropTypes.func,
onReorder: PropTypes.func,
onExternalDrop: PropTypes.func,
onItemDragStart: PropTypes.func,
onItemDragEnd: PropTypes.func,
className: PropTypes.string,
disabled: PropTypes.bool,
animated: PropTypes.bool

View File

@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
import { useAppUpdateContext } from '../context/AppUpdateContext'
import { getSidebarMenuSections } from '../../../database/Sidebars'
import KeyboardShortcut from './KeyboardShortcut'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
const runEditCommand = (command) => {
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
const navigate = useNavigate()
const { userProfile } = useContext(AuthContext)
const { handleWindowControl } = useContext(ElectronContext)
const { addTab, createNewWindow } = useNavigationTabs()
const { checkForUpdates } = useAppUpdateContext()
const includeDev = import.meta.env.DEV
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
const fileMenuItems = useMemo(
() => [
{
key: 'new-window',
label: 'New Window',
onClick: () => createNewWindow()
},
{
key: 'new-tab',
label: 'New Tab',
onClick: () => addTab()
},
{ type: 'divider' },
{
key: 'close',
label: 'Close Window',
onClick: () => handleWindowControl('close')
}
],
[handleWindowControl]
[addTab, createNewWindow, handleWindowControl]
)
const editMenuItems = useMemo(
@ -250,6 +264,19 @@ const WindowAppMenu = () => {
/>
)
}
if (menu.key === 'file') {
return (
<KeyboardShortcut
key={menu.key}
shortcut='ctrl+t'
onTrigger={addTab}
>
<KeyboardShortcut shortcut='ctrl+n' onTrigger={createNewWindow}>
<MenuButton label={menu.label} items={menu.items} />
</KeyboardShortcut>
</KeyboardShortcut>
)
}
return (
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
)

View File

@ -0,0 +1,59 @@
export const TAB_DRAG_MIME = 'application/x-farmcontrol-tab'
export const TAB_DRAG_TEXT_PREFIX = 'farmcontrol-tab:'
export const hasExternalTabDrag = (event) => {
const types = event?.dataTransfer?.types
if (!types) return false
const list = Array.from(types)
return (
list.includes(TAB_DRAG_MIME) ||
list.includes('text/plain') ||
list.includes('text/uri-list')
)
}
export const writeTabDragData = (event, payload) => {
const serialized = JSON.stringify(payload)
try {
event.dataTransfer.setData(TAB_DRAG_MIME, serialized)
} catch {
// Some engines only allow text/plain during dragstart.
}
event.dataTransfer.setData('text/plain', `${TAB_DRAG_TEXT_PREFIX}${serialized}`)
}
export const readTabDragData = (event) => {
const transfer = event?.dataTransfer
if (!transfer) return null
const custom = (() => {
try {
return transfer.getData(TAB_DRAG_MIME)
} catch {
return ''
}
})()
const plain = (() => {
try {
return transfer.getData('text/plain')
} catch {
return ''
}
})()
const raw = custom
? custom
: plain.startsWith(TAB_DRAG_TEXT_PREFIX)
? plain.slice(TAB_DRAG_TEXT_PREFIX.length)
: ''
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (!parsed?.tab?.id) return null
return parsed
} catch {
return null
}
}

View File

@ -4,6 +4,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
@ -20,52 +21,102 @@ import {
stripModalActionParams
} from '../../../utils/modelActions'
import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
import { useNavigationTabs } from './NavigationTabsContext'
const ActionsContext = createContext()
const ActionsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const locationRef = useRef(location)
locationRef.current = location
const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null)
const [modalAction, setModalAction] = useState(null)
const [onModalOk, setOnModalOk] = useState(null)
const lastHandledAction = useRef(null)
const { isElectron } = useContext(ElectronContext)
const { addTab } = useNavigationTabs()
const searchParams = new URLSearchParams(location.search)
const actionName = searchParams.get('action')
const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search)
if (nextUrl !== location.pathname + location.search) {
const loc = locationRef.current
const nextUrl = stripModalActionParams(loc.pathname, loc.search)
if (nextUrl !== loc.pathname + loc.search) {
navigate(nextUrl, { replace: true })
}
setModalAction(null)
lastHandledAction.current = null
}, [location.pathname, location.search, navigate])
}, [navigate])
const handleModalOk = useCallback(() => {
onModalOk?.()
clearAction()
}, [clearAction, onModalOk])
const [ctrlDown, setCtrlDown] = useState(false)
useEffect(() => {
const isHoldKey = (key) => key === 'Meta' || key === 'Control'
const handleKeyDown = (event) => {
if (isHoldKey(event.key)) {
setCtrlDown(true)
}
}
const handleKeyUp = (event) => {
if (isHoldKey(event.key)) {
setCtrlDown(false)
}
}
const clearHold = () => setCtrlDown(false)
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', clearHold)
document.addEventListener('visibilitychange', clearHold)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', clearHold)
document.removeEventListener('visibilitychange', clearHold)
}
}, [])
const handleNavigate = useCallback(
(url) => {
if (isElectron && ctrlDown) {
addTab(url)
return
}
if (!isElectron && ctrlDown) {
window.open(url, '_blank')
return
}
navigate(url, { replace: true })
},
[addTab, navigate, ctrlDown, isElectron]
)
const callAction = useCallback(
(action, objectData, objectType) => {
setCurrentObject(objectData)
setCurrentObjectType(objectType)
const model = getModelByName(objectType)
const loc = locationRef.current
const url = buildActionUrl(
model,
action,
objectData._id,
location.pathname,
location.search
loc.pathname,
loc.search
)
navigate(url)
handleNavigate(url)
},
[location.pathname, location.search, navigate]
[handleNavigate]
)
useEffect(() => {
@ -123,7 +174,7 @@ const ActionsProvider = ({ children }) => {
if (action.name === 'list') {
lastHandledAction.current = actionKey
if (location.pathname !== model.url) {
navigate(model.url, { replace: true })
handleNavigate(model.url)
}
return
}
@ -147,7 +198,7 @@ const ActionsProvider = ({ children }) => {
location.pathname,
location.search
)
navigate(url, { replace: true })
handleNavigate(url)
}
}, [
actionName,
@ -155,7 +206,7 @@ const ActionsProvider = ({ children }) => {
currentObject,
location.pathname,
location.search,
navigate
handleNavigate
])
const modalObjectData = currentObject
@ -187,18 +238,22 @@ const ActionsProvider = ({ children }) => {
? {}
: modalObjectData
return (
<ActionsContext.Provider
value={{
const value = useMemo(
() => ({
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk
}}
>
setOnModalOk,
ctrlDown
}),
[callAction, clearAction, currentObject, currentObjectType, ctrlDown]
)
return (
<ActionsContext.Provider value={value}>
<Modal
open={resolvedModalAction != null}
destroyOnHidden={true}

View File

@ -5,7 +5,8 @@ import {
useState,
useContext,
useRef,
useCallback
useCallback,
useMemo
} from 'react'
import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography, Flex } from 'antd'
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
const getObjectEndpoint = (type) =>
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 ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate()
const {
token,
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityCallbacksRef = useRef(new Map())
const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0)
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(
RECONNECT_DELAYS_MS[0]
)
const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
const [reconnectCycle, setReconnectCycle] = useState(0)
const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false)
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, [])
const resetReconnectState = useCallback(() => {
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
reconnectScheduledRef.current = false
isReconnectingRef.current = false
setIsReconnecting(false)
setReconnectProgress(0)
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers])
const startReconnectCountdown = useCallback(
(delayMs) => {
clearReconnectTimers()
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
setReconnectDelayMs(delayMs)
setReconnectCycle((current) => current + 1)
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(() => {
attemptReconnectRef.current?.()
}, delayMs)
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
isReconnectingRef.current = true
reconnectAttemptRef.current += 1
setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true)
setConnectionIssue(true)
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
[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 response = await axios.get(
`${config.backendUrl}/applaunch/${launchSession}`,
@ -2924,36 +3104,8 @@ const ApiServerProvider = ({ children }) => {
}
}
const reconnectSecondsRemaining = Math.max(
1,
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,
const apiMethodsRef = useRef({})
apiMethodsRef.current = {
getUserSettings,
updateUserSettings,
setObjectActivity,
@ -2981,7 +3133,6 @@ const ApiServerProvider = ({ children }) => {
getModelStats,
getModelPropertyValues,
getModelHistory,
fetchLoading,
showError,
fetchFileContent,
fetchFileThumbnail,
@ -3025,66 +3176,48 @@ const ApiServerProvider = ({ children }) => {
fetchAppUpdateCurrent,
fetchWsServerVersion,
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 }}
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={() => attemptReconnect()}
>
Reconnect
</Button>
const stableApiRef = useRef(null)
if (stableApiRef.current == null) {
stableApiRef.current = createStableApiMethods(apiMethodsRef)
}
const value = useMemo(
() => ({
...stableApiRef.current,
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
fetchLoading
}),
[
connecting,
connected,
error,
fetchLoading,
userSettings,
userSettingsLoaded
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
)
return (
<ApiServerContext.Provider value={value}>
{contextHolder}
<LaunchSessionCompleter
completeAppLaunchSession={completeAppLaunchSession}
/>
{children}
<ConnectionIssueModal
open={Boolean(token) && authenticated == true && connectionIssue}
delayMs={reconnectDelayMs}
cycle={reconnectCycle}
isReconnecting={isReconnecting}
onReconnect={attemptReconnect}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal
title={
<Space size={'middle'}>

View File

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

View File

@ -3,10 +3,12 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { NavigationTabActiveContext } from './NavigationTabsContext'
const DashboardObjectToolsContext = createContext()
@ -27,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
setCurrentObjectToolsState(null)
}, [])
return (
<DashboardObjectToolsContext.Provider
value={{
const value = useMemo(
() => ({
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}}
>
}),
[clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
)
return (
<DashboardObjectToolsContext.Provider value={value}>
{children}
</DashboardObjectToolsContext.Provider>
)
@ -59,6 +64,7 @@ export const useDashboardObjectToolsContext = () => {
export const useDashboardObjectTools = (tools) => {
const { setCurrentObjectTools, clearCurrentObjectTools } =
useDashboardObjectToolsContext()
const store = useContext(NavigationTabActiveContext)
const ownerIdRef = useRef(null)
if (ownerIdRef.current == null) {
@ -66,8 +72,14 @@ export const useDashboardObjectTools = (tools) => {
}
useEffect(() => {
const sync = () => {
if (!store.getSnapshot()) return
setCurrentObjectTools(tools, ownerIdRef.current)
}, [tools, setCurrentObjectTools])
}
sync()
return store.subscribe(sync)
}, [setCurrentObjectTools, store, tools])
useEffect(() => {
const ownerId = ownerIdRef.current

View File

@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
const [electronAvailable] = useState(isElectron())
const navigate = useNavigate()
const lastNavigationAtRef = useRef(0)
const tabHistoryHandlerRef = useRef(null)
const registerTabHistoryHandler = useCallback((handler) => {
tabHistoryHandlerRef.current =
typeof handler === 'function' ? handler : null
}, [])
const navigateHistory = useCallback(
(direction) => {
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
if (now - lastNavigationAtRef.current < 300) return
lastNavigationAtRef.current = now
if (tabHistoryHandlerRef.current) {
tabHistoryHandlerRef.current(direction)
return
}
if (direction === 'back') {
navigate(-1)
} else if (direction === 'forward') {
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
void desktopBridge.windowControl(action)
}
const getWindowSession = useCallback(async () => {
if (!electronAvailable) return null
return await desktopBridge.getWindowSession()
}, [electronAvailable])
const syncWindowTabs = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.syncWindowTabs(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const createAppWindow = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.createAppWindow(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const onNewTabRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newTab', handler)
},
[electronAvailable]
)
const onNewWindowRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newWindow', handler)
},
[electronAvailable]
)
const onTabMovedAway = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('tabMovedAway', handler)
},
[electronAvailable]
)
const beginTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.beginTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const completeTabDrop = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.completeTabDrop(payload)) || { ok: false }
},
[electronAvailable]
)
const cancelTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.cancelTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const getAuthSession = async () => {
if (!electronAvailable) return null
return await desktopBridge.getAuthSession()
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
resizeSpotlightWindow,
setSidebarViewMenu,
getElectronVersion,
getAppEngine
getAppEngine,
registerTabHistoryHandler,
getWindowSession,
syncWindowTabs,
createAppWindow,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
}}
>
{children}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -14,6 +14,11 @@ import { AuthContext } from './AuthContext'
import { useTableState } from './TableStateContext'
import useViewMode from '../hooks/useViewMode'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
import { getModelByName } from '../../../database/ObjectModels'
import {
useNavigationTabPage,
useNavigationTabs
} from './NavigationTabsContext'
const ObjectListViewContext = createContext()
@ -126,7 +131,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
subscribeToObjectUpdates
} = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView, setObjectListView } = useTableState()
const { getViewFromUrl, getPersistedView, persistView, setObjectListView } =
useTableState()
const { isElectron } = useNavigationTabs()
const listModel = getModelByName(objectType)
const [views, setViews] = useState(() => readCachedViews(objectType) || [])
const [draftViews, setDraftViews] = useState(() => {
@ -194,6 +202,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
return view
}, [activeTabKey, displayedViews])
const listLabel = listModel?.labelPlural || 'List'
const viewName = activeView?.name
useNavigationTabPage({
title: viewName ? `${listLabel} - ${viewName}` : listLabel,
modelName: objectType
})
viewsStateRef.current = { views, draftViews, isEditing }
activeViewRef.current = activeView
@ -288,11 +303,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
const name =
key && view && typeof view === 'object' ? view.name || null : null
persistView(key)
persistView(key, { scope: objectType, name })
if (!isElectron) {
writeLastViewId(objectType, key)
}
setObjectListView(objectType, key ? { id: key, name } : null)
},
[objectType, persistView, setObjectListView]
[isElectron, objectType, persistView, setObjectListView]
)
useEffect(() => {
@ -344,9 +361,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// Allow early restore from cache; only block when we have nothing to match against yet
if (initialLoading && views.length === 0) return
const urlViewId = getViewFromUrl()
const rememberedViewId = readLastViewId(objectType)
const candidateViewId = urlViewId || rememberedViewId
const persistedViewId = getPersistedView(objectType)?.id || null
const urlViewId = isElectron ? null : getViewFromUrl()
const rememberedViewId = isElectron ? null : readLastViewId(objectType)
const candidateViewId = persistedViewId || urlViewId || rememberedViewId
if (!candidateViewId) {
urlSyncedRef.current = true
@ -361,14 +379,18 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
if (matched) {
const matchedId = String(matched._id)
setActiveTabKey(matchedId)
if (!isElectron) {
writeLastViewId(objectType, matchedId)
}
setObjectListView(objectType, {
id: matchedId,
name: matched.name || null
})
if (!matched._isDraft) {
// Keep viewId in the URL and strip any leftover filter/sort params
persistView(matchedId)
persistView(matchedId, {
scope: objectType,
name: matched.name || null
})
}
urlSyncedRef.current = true
setViewSyncReady(true)
@ -391,6 +413,8 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
draftViews,
isEditing,
objectType,
isElectron,
getPersistedView,
getViewFromUrl,
persistView,
rememberActiveView,
@ -894,8 +918,7 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// route filter/sort changes into the draft instead of URL/session persistence.
const activeObjectView = isEditing ? activeView : null
const handleViewModeChange = useCallback(
async (viewMode) => {
const handleViewModeChange = useCallback(async (viewMode) => {
const view = activeViewRef.current
if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
@ -921,9 +944,7 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
...(userOverridesRef.current || {}),
viewMode
}
},
[]
)
}, [])
const isCustomView = activeTabKey !== ALL_TAB_KEY

View File

@ -9,6 +9,10 @@ import {
} from 'react'
import { useSearchParams } from 'react-router-dom'
import PropTypes from 'prop-types'
import {
NavigationTabIdContext,
useNavigationTabs
} from './NavigationTabsContext'
const TableStateContext = createContext(null)
@ -166,6 +170,9 @@ const normalizeSorter = (sorter) => {
}
export const TableStateProvider = ({ children }) => {
const tabId = useContext(NavigationTabIdContext)
const { isElectron, getTabListState, setTabListState, activeTab } =
useNavigationTabs()
const [searchParams, setSearchParams] = useSearchParams()
const [pageFilters, setPageFilters] = useState({})
const [pageSorters, setPageSorters] = useState({})
@ -214,8 +221,17 @@ export const TableStateProvider = ({ children }) => {
[setSearchParams]
)
const resolveScopedTabId = useCallback(
() => tabId || (isElectron ? activeTab?.id : null),
[activeTab?.id, isElectron, tabId]
)
const getPersistedFilter = useCallback(
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).filter || {}
}
if (useFilterInUrl) {
const fromUrl = readFilterFromUrl(searchParams)
if (fromUrl) return fromUrl
@ -226,11 +242,15 @@ export const TableStateProvider = ({ children }) => {
}
return {}
},
[searchParams]
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const getPersistedSorter = useCallback(
(scope, { useSortInUrl = false, useSortInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).sort || {}
}
if (useSortInUrl) {
const fromUrl = readSortFromUrl(searchParams)
if (fromUrl) return fromUrl
@ -241,7 +261,7 @@ export const TableStateProvider = ({ children }) => {
}
return {}
},
[searchParams]
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const getViewFromUrl = useCallback(
@ -249,8 +269,36 @@ export const TableStateProvider = ({ children }) => {
[searchParams]
)
const getPersistedView = useCallback(
(scope) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
const fromUrl = readViewFromUrl(searchParams)
if (fromUrl) return { id: String(fromUrl), name: null }
return readListViewFromSession(scope)
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const persistView = useCallback(
(viewId, { clearFilterSort = Boolean(viewId) } = {}) => {
(
viewId,
{ clearFilterSort = Boolean(viewId), scope, name } = {}
) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId && scope) {
setTabListState(scopedTabId, scope, {
viewId: viewId ? String(viewId) : null,
viewName: viewId ? name || null : null
})
return
}
updateSearchParams((next) => {
// Drop legacy `view` param that previously stored `_reference`
next.delete('view')
@ -266,7 +314,7 @@ export const TableStateProvider = ({ children }) => {
}
})
},
[updateSearchParams]
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistFilter = useCallback(
@ -276,6 +324,13 @@ export const TableStateProvider = ({ children }) => {
{ saveFilterInSession = false, saveFilterInUrl = false } = {}
) => {
const active = getActiveFilterValues(filterState)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveFilterInSession || saveFilterInUrl) {
setTabListState(scopedTabId, scope, { filter: active })
}
return
}
if (saveFilterInSession) {
writeFilterToSession(scope, active)
}
@ -289,7 +344,7 @@ export const TableStateProvider = ({ children }) => {
})
}
},
[updateSearchParams]
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistSort = useCallback(
@ -299,6 +354,13 @@ export const TableStateProvider = ({ children }) => {
{ saveSortInSession = false, saveSortInUrl = false } = {}
) => {
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveSortInSession || saveSortInUrl) {
setTabListState(scopedTabId, scope, { sort: nextSorter })
}
return
}
if (saveSortInSession) {
writeSortToSession(scope, nextSorter)
}
@ -312,7 +374,7 @@ export const TableStateProvider = ({ children }) => {
})
}
},
[updateSearchParams]
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistTableState = useCallback(
@ -329,6 +391,21 @@ export const TableStateProvider = ({ children }) => {
) => {
const active = getActiveFilterValues(filterState)
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const patch = {}
if (saveFilterInSession || saveFilterInUrl) {
patch.filter = active
}
if (saveSortInSession || saveSortInUrl) {
patch.sort = nextSorter
}
if (Object.keys(patch).length > 0) {
setTabListState(scopedTabId, scope, patch)
}
return
}
if (saveFilterInSession) {
writeFilterToSession(scope, active)
@ -356,7 +433,7 @@ export const TableStateProvider = ({ children }) => {
})
}
},
[updateSearchParams]
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const setPageFilter = useCallback((path, filter) => {
@ -389,7 +466,12 @@ export const TableStateProvider = ({ children }) => {
const setObjectListFilter = useCallback((objectType, filter) => {
if (!objectType) return
const active = getActiveFilterValues(filter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listFilter: active })
} else {
writeListFilterToSession(objectType, active)
}
setObjectListFilters((prev) => {
const hasActive = Object.keys(active).length > 0
if (!hasActive) {
@ -400,12 +482,17 @@ export const TableStateProvider = ({ children }) => {
}
return { ...prev, [objectType]: active }
})
}, [])
}, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListSorter = useCallback((objectType, sorter) => {
if (!objectType) return
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listSort: nextSorter })
} else {
writeListSortToSession(objectType, nextSorter)
}
setObjectListSorters((prev) => {
const hasSort = nextSorter?.field && nextSorter?.order
if (!hasSort) {
@ -416,7 +503,7 @@ export const TableStateProvider = ({ children }) => {
}
return { ...prev, [objectType]: nextSorter }
})
}, [])
}, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListView = useCallback((objectType, view) => {
if (!objectType) return
@ -424,7 +511,15 @@ export const TableStateProvider = ({ children }) => {
view?.id != null
? { id: String(view.id), name: view.name || null }
: null
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, {
viewId: nextView?.id || null,
viewName: nextView?.name || null
})
} else {
writeListViewToSession(objectType, nextView)
}
setObjectListViews((prev) => {
if (!nextView) {
if (!(objectType in prev)) return prev
@ -442,7 +537,7 @@ export const TableStateProvider = ({ children }) => {
}
return { ...prev, [objectType]: nextView }
})
}, [])
}, [isElectron, resolveScopedTabId, setTabListState])
const getObjectListFilter = useCallback(
(objectType) => {
@ -450,9 +545,15 @@ export const TableStateProvider = ({ children }) => {
if (objectListFilters[objectType]) {
return objectListFilters[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getActiveFilterValues(
getTabListState(scopedTabId, objectType).listFilter || {}
)
}
return getActiveFilterValues(readListFilterFromSession(objectType) || {})
},
[objectListFilters]
[getTabListState, isElectron, objectListFilters, resolveScopedTabId]
)
const getObjectListSorter = useCallback(
@ -461,9 +562,13 @@ export const TableStateProvider = ({ children }) => {
if (objectListSorters[objectType]) {
return objectListSorters[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, objectType).listSort || {}
}
return readListSortFromSession(objectType) || {}
},
[objectListSorters]
[getTabListState, isElectron, objectListSorters, resolveScopedTabId]
)
const getObjectListView = useCallback(
@ -472,9 +577,16 @@ export const TableStateProvider = ({ children }) => {
if (objectListViews[objectType]) {
return objectListViews[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, objectType)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
return readListViewFromSession(objectType)
},
[objectListViews]
[getTabListState, isElectron, objectListViews, resolveScopedTabId]
)
const hasPageFilter = useCallback(
@ -484,6 +596,17 @@ export const TableStateProvider = ({ children }) => {
const hasStoredFilter = useCallback((scope) => {
if (!scope) return false
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
if (entry.viewId) {
return Object.keys(entry.listFilter || {}).length > 0
}
return (
Object.keys(entry.filter || {}).length > 0 ||
Object.keys(entry.listFilter || {}).length > 0
)
}
// When a view is active, only report the effective list filter (which
// already accounts for the view). Don't fall back to the All-tab session
// filter — that belongs to a different context.
@ -493,12 +616,19 @@ export const TableStateProvider = ({ children }) => {
if (hasStoredSessionFilter(scope)) return true
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
}, [objectListFilters, objectListViews])
}, [
getTabListState,
isElectron,
objectListFilters,
objectListViews,
resolveScopedTabId
])
const value = useMemo(
() => ({
getPersistedFilter,
getPersistedSorter,
getPersistedView,
getViewFromUrl,
persistView,
persistFilter,
@ -523,6 +653,7 @@ export const TableStateProvider = ({ children }) => {
[
getPersistedFilter,
getPersistedSorter,
getPersistedView,
getViewFromUrl,
persistView,
persistFilter,

View File

@ -2,6 +2,7 @@ import {
createContext,
useCallback,
useContext,
useMemo,
useState,
useEffect
} from 'react'
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
)
}, [showNavigationLabels])
const toggleTheme = () => {
if (isSystem) {
setIsSystem(false)
}
setIsDarkMode(!isDarkMode)
}
const toggleTheme = useCallback(() => {
setIsSystem((current) => {
if (current) return false
return current
})
setIsDarkMode((current) => !current)
}, [])
const toggleSystem = () => {
setIsSystem(!isSystem)
if (!isSystem) {
// When enabling system theme, update to match system preference
const toggleSystem = useCallback(() => {
setIsSystem((current) => {
const next = !current
if (next) {
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
}
}
return next
})
}, [])
const toggleCompact = () => {
setIsCompact(!isCompact)
}
const toggleCompact = useCallback(() => {
setIsCompact((current) => !current)
}, [])
const setThemeMode = useCallback((value) => {
if (value === 'system') {
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
setShowNavigationLabelsState(Boolean(value))
}, [])
const getThemeAlgorithm = () => {
var baseAlgorithm
if (isDarkMode == true) {
baseAlgorithm = theme.darkAlgorithm
} else {
baseAlgorithm = theme.defaultAlgorithm
}
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
}
const getColors = useCallback(() => COLORS, [])
const getColors = () => {
return COLORS
const themeConfig = useMemo(() => {
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
useEffect(() => {
@ -175,6 +223,10 @@ export const ThemeProvider = ({ children }) => {
'--color-background',
isDarkMode ? '#000000' : '#ffffff'
)
root.style.setProperty(
'--tab-preview-render-background',
isDarkMode ? '#000000' : '#E9E9E9'
)
root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000')
root.style.setProperty(
'--color-descriptions-border',
@ -216,43 +268,14 @@ export const ThemeProvider = ({ children }) => {
'--color-mobile-footer-item-border',
isDarkMode ? '#303030' : '#f0f0f0'
)
root.style.setProperty(
'--color-header-border',
isDarkMode ? 'rgba(253,253,253,0.12)' : 'rgba(10,10,10,0.12)'
)
}, [isDarkMode, primaryColorOverride])
const themeConfig = {
algorithm: getThemeAlgorithm(),
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
return (
<ThemeContext.Provider
value={{
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}}
>
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)

View File

@ -0,0 +1,194 @@
import { snapdom } from '@zumer/snapdom'
const FONT_TIMEOUT_MS = 180
const CAPTURE_CLASS = 'dashboard-tab-pane-capturing'
const CAPTURE_FRAME_CLASS = 'dashboard-tab-preview-capture-frame'
const CAPTURE_PADDING_PX = 24
const PREVIEW_EXCLUDE = [
'.ant-popover',
'.dashboard-tab-preview-popover',
'.dashboard-tab-preview'
]
const waitForPaint = () =>
new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve)
})
})
const waitForTimeout = (ms) =>
new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
const waitWithTimeout = (promise, ms) =>
Promise.race([promise, waitForTimeout(ms)])
const getCssVariableColor = (name) =>
window.getComputedStyle(document.documentElement).getPropertyValue(name).trim()
const cssColorToRgb = (color, fallback) => {
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
return fallback
}
if (
color.startsWith('#') ||
color.startsWith('rgb(') ||
color.startsWith('rgba(')
) {
return color
}
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 1
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return fallback
ctx.fillStyle = fallback
ctx.fillRect(0, 0, 1, 1)
ctx.fillStyle = color
ctx.fillRect(0, 0, 1, 1)
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data
if (!a) return fallback
return `rgb(${r}, ${g}, ${b})`
}
const getPreviewBackgroundColor = (pane) => {
const themeColor = getCssVariableColor('--tab-preview-render-background')
if (themeColor) {
return cssColorToRgb(themeColor, themeColor)
}
return cssColorToRgb(
(pane && window.getComputedStyle(pane).backgroundColor) ||
window.getComputedStyle(document.body).backgroundColor,
getCssVariableColor('--color-background') || '#ffffff'
)
}
const waitForFonts = async () => {
if (!document.fonts?.ready) return
await waitWithTimeout(document.fonts.ready, FONT_TIMEOUT_MS)
}
export const waitForTabPreviewLayout = async (pane) => {
if (!pane) return false
void pane.offsetHeight
await waitForFonts()
await waitForPaint()
void pane.offsetHeight
await waitForPaint()
return pane.clientWidth >= 8 && pane.clientHeight >= 8
}
const resetCloneLayout = (element) => {
const styles = element.style
styles.position = 'relative'
styles.inset = 'auto'
styles.left = '0'
styles.top = '0'
styles.right = 'auto'
styles.bottom = 'auto'
styles.zIndex = '2'
styles.display = 'flex'
styles.flexDirection = 'column'
styles.opacity = '1'
styles.visibility = 'visible'
styles.translate = 'none'
styles.transform = 'none'
styles.overflow = 'hidden'
}
const preparePaneForCapture = (pane) => {
const previousInert = pane.inert
const parent = pane.parentNode
const nextSibling = pane.nextSibling
const frame = document.createElement('div')
frame.className = CAPTURE_FRAME_CLASS
frame.style.padding = `${CAPTURE_PADDING_PX}px`
if (parent) {
parent.insertBefore(frame, pane)
frame.appendChild(pane)
}
pane.classList.add(CAPTURE_CLASS)
pane.inert = false
void frame.offsetHeight
void pane.offsetHeight
return {
captureTarget: pane.parentNode === frame ? frame : pane,
restore: () => {
if (pane.parentNode === frame) {
if (nextSibling && nextSibling.parentNode === parent) {
parent.insertBefore(pane, nextSibling)
} else if (parent) {
parent.appendChild(pane)
}
}
frame.remove()
pane.classList.remove(CAPTURE_CLASS)
pane.inert = previousInert
}
}
}
const tabPreviewPlugin = {
name: 'tab-preview-layout',
afterClone(ctx) {
const element = ctx.clone
if (!(element instanceof HTMLElement)) return
const pane = element.classList.contains('dashboard-tab-pane')
? element
: element.querySelector('.dashboard-tab-pane')
resetCloneLayout(element)
if (!(pane instanceof HTMLElement)) return
pane.classList.remove('dashboard-tab-pane-inactive', CAPTURE_CLASS)
pane.classList.add('dashboard-tab-pane-active')
resetCloneLayout(pane)
pane.style.flex = '1 1 auto'
pane.style.width = '100%'
pane.style.height = 'auto'
pane.style.minHeight = '0'
}
}
export const captureTabPaneImage = async (pane) => {
if (!pane) return null
const { captureTarget, restore } = preparePaneForCapture(pane)
try {
const ready = await waitForTabPreviewLayout(pane)
if (!ready) return null
const backgroundColor = getPreviewBackgroundColor(pane)
const jpg = await snapdom.toJpg(captureTarget, {
backgroundColor,
scale: 1,
embedFonts: 'auto',
exclude: PREVIEW_EXCLUDE,
excludeMode: 'remove',
outerTransforms: false,
placeholders: false,
invalidate: true,
plugins: [tabPreviewPlugin]
})
return jpg?.src || null
} catch (error) {
console.error('Failed to capture tab preview', error)
return null
} finally {
restore()
}
}

View File

@ -0,0 +1,71 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const formatPageLabel = (pageName) =>
pageName ? `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)}` : 'Page'
const getObjectDisplayName = (objectData, model) => {
if (objectData?.name) return objectData.name
if (objectData?._reference && model?.prefix) {
return `${model.prefix}:${objectData._reference}`
}
return null
}
export const formatObjectPageTabTitle = ({ pageName, objectName } = {}) => {
const pageLabel = formatPageLabel(pageName)
if (objectName) return `${objectName} - ${pageLabel}`
return pageLabel
}
export const useObjectNavigationTabPage = ({
modelName,
pageName,
objectId
} = {}) => {
const model = getModelByName(modelName)
const { fetchSpotlightData, subscribeToObjectUpdates, connected } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const [objectName, setObjectName] = useState(null)
const fetchSpotlightDataRef = useRef(fetchSpotlightData)
fetchSpotlightDataRef.current = fetchSpotlightData
useEffect(() => {
if (!objectId || !model?.prefix || token == null) {
setObjectName(null)
return undefined
}
let cancelled = false
const query = `${model.prefix}:${objectId}`
void fetchSpotlightDataRef.current(query).then((result) => {
if (cancelled) return
if (result && typeof result === 'object' && !Array.isArray(result)) {
setObjectName(getObjectDisplayName(result, model))
}
})
return () => {
cancelled = true
}
}, [model, objectId, token])
useEffect(() => {
if (!objectId || !modelName || !connected) return undefined
return subscribeToObjectUpdates(objectId, modelName, (value) => {
setObjectName((current) => getObjectDisplayName(value, model) || current)
})
}, [connected, model, modelName, objectId, subscribeToObjectUpdates])
useNavigationTabPage({
title: formatObjectPageTabTitle({ pageName, objectName }),
modelName
})
}
export default useObjectNavigationTabPage

Some files were not shown because too many files have changed in this diff Show More