diff --git a/react-ui/package.json b/react-ui/package.json index 1f742d78da361ba160c390cd6296d007c4539b42..8bdbbfa50034042e846a40bf3bec7c00b6cd4161 100755 --- a/react-ui/package.json +++ b/react-ui/package.json @@ -27,6 +27,7 @@ "react-error-boundary": "^4.1.2", "react-grid-layout": "^1.5.0", "react-i18next": "^15.0.0", + "react-loading-skeleton": "^3.5.0", "react-redux": "^9.1.2", "react-router-dom": "^6.23.1", "react-toastify": "^10.0.5", diff --git a/react-ui/src/components/devices/view/device.view.list.tsx b/react-ui/src/components/devices/view/device.view.list.tsx new file mode 100755 index 0000000000000000000000000000000000000000..53f1580d13d36784df38242e4be998b2e0ae6590 --- /dev/null +++ b/react-ui/src/components/devices/view/device.view.list.tsx @@ -0,0 +1,94 @@ +import { insertMarkTags } from "@helper/text"; +import { useAppSelector } from "@hooks"; +import DOMPurify from 'dompurify'; +import { RefObject, useCallback, useRef } from 'react'; +import { Col, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'; +import { useTranslation } from "react-i18next"; +import { Device } from "../reducer/device.reducer"; +import { useDeviceTableViewModel } from "../view_model/device.list.viewmodel"; + +const cropUUID = (uuid: string): string => { + return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); +}; + +export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElement> }) => { + const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device); + const { t } = useTranslation('common'); + const listRef = useRef<HTMLDivElement>(null); + const { dispatchDevice } = useDeviceTableViewModel(searchRef, listRef); + + const handleItemClick = useCallback((device: Device) => { + dispatchDevice(device) + }, []); + + const getDeviceList = useCallback(() => { + const search = searchRef?.current?.value; + let filtered = devices; + + if (search) { + filtered = devices.filter((device) => { + const user = pnds.find(pnd => pnd.id === device.pid); + return device.id?.includes(search) || + device.name?.includes(search) || + user?.name?.includes(search); + }); + } + + return filtered.map((device) => { + const user = pnds.find(pnd => pnd.id === device.pid); + const username = user?.name || ''; + const deviceId = device.id!; + const croppedId = cropUUID(deviceId); + const devicename = device.name || ''; + const isSelected = selectedDevice?.device.id === deviceId; + + return ( + <div + key={deviceId} + className={`border-bottom border-primary p-2 transitions ${isSelected && 'bg-gradient-fade py-2'} ${!isSelected && 'text-disabled disabled-hover'}`} + onClick={() => handleItemClick(device)} + > + <Row + className="align-items-center clickable" + onClick={() => handleItemClick(device)} + > + <Col xs={12} sm={5} > + <span dangerouslySetInnerHTML={{ + __html: search ? insertMarkTags(devicename, search) : DOMPurify.sanitize(devicename) + }} /> + </Col> + <Col xs={12} sm={3} > + <OverlayTrigger overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>}> + <span className="text-gray-500" dangerouslySetInnerHTML={{ + __html: search ? insertMarkTags(croppedId, search) : DOMPurify.sanitize(croppedId) + }} /> + </OverlayTrigger> + </Col> + <Col xs={12} sm={4} > + <span className="text-gray-500" dangerouslySetInnerHTML={{ + __html: search ? insertMarkTags(username, search) : DOMPurify.sanitize(username) + }} /> + </Col> + </Row> + </div> + ); + }); + }, [devices, searchRef, pnds, selectedDevice, handleItemClick]); + + return ( + <div className="rounded border border-primary mt-2"> + <Row className="border-bottom border-primary px-2 py-2 mx-0"> + <Col xs={12} sm={5} > + <span className="font-medium">{t('device.table.header.name')}</span> + </Col> + <Col xs={12} sm={3} > + <span className="font-medium">{t('device.table.header.uuid')}</span> + </Col> + <Col xs={12} sm={4} > + <span className="font-medium">{t('device.table.header.user')}</span> + </Col> + </Row> + <div ref={listRef}>{getDeviceList()}</div> + </div> + ); +}; \ No newline at end of file diff --git a/react-ui/src/components/devices/view/device.view.table.tsx b/react-ui/src/components/devices/view/device.view.table.tsx deleted file mode 100755 index ff46862e7c3983d741c66716f9df43ef40375de2..0000000000000000000000000000000000000000 --- a/react-ui/src/components/devices/view/device.view.table.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { insertMarkTags } from "@helper/text"; -import { useAppSelector } from "@hooks"; -import DOMPurify from 'dompurify'; -import { RefObject, useCallback, useRef, useState } from 'react'; -import { Collapse, OverlayTrigger, Tooltip } from 'react-bootstrap'; -import { useTranslation } from "react-i18next"; -import { Device } from "../reducer/device.reducer"; -import { useDeviceTableViewModel } from "../view_model/device.table.viewmodel"; -import { DeviceListCollapsable } from "./subcomponent/device.view.list-detail"; - -const cropUUID = (uuid: string): string => { - return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); -}; - -export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElement> }) => { - const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device); - const { t } = useTranslation('common'); - const [expandedId, setExpandedId] = useState<string | null | undefined>(null); - const listRef = useRef<HTMLDivElement>(null); - const { dispatchDevice } = useDeviceTableViewModel(searchRef, listRef); - - const handleItemClick = useCallback((device: Device) => { - dispatchDevice(device) - - const { id } = device - setExpandedId(expandedId === id ? null : id); - }, [expandedId]); - 1 - const getDeviceList = useCallback(() => { - const search = searchRef?.current?.value; - let filtered = devices; - - if (search) { - filtered = devices.filter((device) => { - const user = pnds.find(pnd => pnd.id === device.pid); - return device.id?.includes(search) || - device.name?.includes(search) || - user?.name?.includes(search); - }); - } - - return filtered.map((device) => { - const user = pnds.find(pnd => pnd.id === device.pid); - const username = user?.name || ''; - const deviceId = device.id!; - const croppedId = cropUUID(deviceId); - const devicename = device.name || ''; - const isExpanded = expandedId === deviceId; - const isSelected = selectedDevice?.device.id === deviceId; - - return ( - <div - key={deviceId} - className={`border-bottom border-primary p-2 transitions ${isSelected && 'bg-gradient-fade py-2'} ${!isSelected && 'text-disabled disabled-hover'}`} - onClick={() => !isExpanded && handleItemClick(device)} - > - <div - aria-expanded={isExpanded} - className="d-flex justify-content-between py-2 clickable" - onClick={() => isExpanded && handleItemClick(device)} - > - <FontAwesomeIcon icon={faChevronDown} rotation={isExpanded ? undefined : 270} /> - <span dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(devicename, search) : DOMPurify.sanitize(devicename) - }} /> - <OverlayTrigger overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>}> - <span className="text-gray-500" dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(croppedId, search) : DOMPurify.sanitize(croppedId) - }} /> - </OverlayTrigger> - <span className="text-gray-500" dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(username, search) : DOMPurify.sanitize(username) - }} /> - </div> - - <Collapse in={isExpanded}> - <div> - <DeviceListCollapsable deviceId={deviceId} username={username} search={search} /> - </div> - </Collapse> - </div> - ); - }); - }, [devices, searchRef, pnds, selectedDevice, expandedId, handleItemClick]); - - return ( - <div className="rounded border border-primary mt-2"> - <div className="border-bottom border-primary d-flex justify-content-between px-4 py-2 clickable"> - <FontAwesomeIcon icon={faChevronDown} className="opacity-0" /> - <span className="font-medium">{t('device.table.header.name')}</span> - <span className="font-medium">{t('device.table.header.uuid')}</span> - <span className="font-medium">{t('device.table.header.user')}</span> - </div> - <div ref={listRef}>{getDeviceList()}</div> - </div> - ); -}; \ No newline at end of file diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx index 350abadd0cd4e742a33295889aebd83376b265ef..636991ff1e37192837f6b68b73bf0a6763ad894e 100755 --- a/react-ui/src/components/devices/view/device.view.tsx +++ b/react-ui/src/components/devices/view/device.view.tsx @@ -1,80 +1,68 @@ -import { faGripVertical, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { GridLayout } from '@layout/grid.layout/grid.layout'; -import UpdateIndicator from '@layout/grid.layout/update-inidicator.layout/update-indicator.layout'; +import { GridBox } from '@shared/components/box/gridBox.view'; import { JsonViewer } from '@shared/components/json_viewer/view/json_viewer.view'; -import { Category, CategoryType } from '@shared/types/category.type'; import { useRef } from 'react'; -import { Button, Col, Container, Form, Row } from 'react-bootstrap'; +import { Button, Col, Form, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { useDeviceViewModel } from '../view_model/device.viewmodel'; import './device.scss'; -import { DeviceList } from './device.view.table'; +import { DeviceList } from './device.view.list'; +import { DeviceListCollapsable } from './subcomponent/device.view.list-detail'; const DeviceView = () => { const { t } = useTranslation('common'); const searchRef = useRef<HTMLInputElement>(null); - const { jsonYang, selectedDevice } = useDeviceViewModel(); + const { jsonYang } = useDeviceViewModel(); return ( - <div className='m-4 pt-4'> - <GridLayout> - <> - <div key="device-list"> - <Container className='c-box hoverable h-100'> - <UpdateIndicator - category={Category.DEVICE as CategoryType} - updateInterval={15000} - /> - <FontAwesomeIcon icon={faGripVertical} className="drag-handle" /> - <Row> - <Col sm={12}> - <h3 className='c-box-title'>{t('device.title')}</h3> - </Col> - </Row> - <Row> - <Col xs={12} sm={6}> - <Form.Group controlId='device.search' className='p-0 mx-1 pt-2'> - <Form.Control type="text" placeholder={t('device.search.placeholder')} ref={searchRef} /> - </Form.Group> - </Col> - <Col xs={12} sm={{ span: 4, offset: 2 }} className='pt-2'> - <Button variant='primary' className='float-end'><FontAwesomeIcon icon={faPlus} className='me-1' />{t('device.add_device_button')}</Button> - </Col> - </Row> + <GridLayout> + <> + <div key="device-list"> + <GridBox title={t("device.title")}> + <Row className="mb-3 align-items-center"> + <Col xs={12} md={6} lg={8}> + <Form.Group controlId='device.search'> + <Form.Control type="text" placeholder={t('device.search.placeholder')} ref={searchRef} /> + </Form.Group> + </Col> + <Col xs={12} md={6} lg={4} className='mt-3 mt-md-0 text-md-end'> + <Button variant='primary'> + <FontAwesomeIcon icon={faPlus} className='me-2' /> + {t('device.add_device_button')} + </Button> + </Col> + </Row> + <Row> + <Col xs={12} className='h-auto'> + <DeviceList searchRef={searchRef} /> + </Col> + </Row> + </GridBox> + </div> - <Row className='align-items-start'> - <Col sm={12} className='pt-2'> - {DeviceList({ searchRef })} - </Col> - </Row> - </Container> - </div> + <div key="device-metadata"> + <GridBox title={t("device.title")}> + <Row> + <Col xs={12} > + <DeviceListCollapsable search={searchRef.current?.value || ''} /> + </Col> + </Row> + </GridBox> + </div> - <div key="device-details"> - <Container className='c-box hoverable h-100'> - <UpdateIndicator - category={Category.TAB as CategoryType} - updateInterval={5000} - /> - <FontAwesomeIcon icon={faGripVertical} className="drag-handle" /> - <Row> - <Col xs={12}> - <h3 className='c-box-title'>{t('device.tabs.yang_model.title')} <small>{selectedDevice?.device.name}</small></h3> - </Col> - </Row> - <Row className='align-items-start'> - <Col xs={12}> - {jsonYang && - <JsonViewer json={jsonYang} /> - } - </Col> - </Row> - </Container> - </div> - </> - </GridLayout> - </div> + <div key="device-details"> + <GridBox title={t('device.tabs.yang_model.title')}> + <Row> + <Col xs={12}> + {jsonYang && <JsonViewer json={jsonYang} />} + </Col> + </Row> + </GridBox> + </div> + </> + </GridLayout> ); }; diff --git a/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx b/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx index 7473eba6c488f28653b118b4f298b03a9599a83e..ba146d782e4ce583c3d7bbc48173e02d44cad2e2 100644 --- a/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx +++ b/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx @@ -8,19 +8,33 @@ import { useState } from "react"; import { Collapse } from "react-bootstrap"; interface DeviceListCollapsableProps { - deviceId: string - username: string search?: string, } -export const DeviceListCollapsable = ({ deviceId, username, search }: DeviceListCollapsableProps) => { +enum Collapsables { + Metadata = 1, + Config = 2 +} + +export const DeviceListCollapsable = ({ search }: DeviceListCollapsableProps) => { const { selected } = useAppSelector(state => state.device); - const [metadata, setMetadata] = useState<boolean>(true) + const [collapseable, setCollapsable] = useState<Collapsables | undefined>(undefined) + const username = selected?.device.name || ""; + const deviceId = selected?.device.id || ""; const json = selected?.json || {} - const key = Object.keys(json).at(2) as keyof typeof json - const metadataObject = json[key] as JSON || {}; + const metadataKey = Object.keys(json).at(2) as keyof typeof json + const metadataObject = json[metadataKey] as JSON || {}; + + const configKey = Object.keys(json).at(0) as keyof typeof json + const configObject = json[configKey] as JSON || {}; + + + const setCollapsed = (prev: Collapsables) => { + const next = collapseable === prev ? undefined : prev; + setCollapsable(next); + } return ( <div id={`collapse-${deviceId}`}> @@ -42,18 +56,31 @@ export const DeviceListCollapsable = ({ deviceId, username, search }: DeviceList <span>{username}</span> </div> - <div className="d-flex justify-content-between clickable border-top border-dark mt-3 pt-1" aria-expanded={metadata} onClick={() => setMetadata(!metadata)}> + <div className="d-flex justify-content-between clickable border-top border-dark mt-3 pt-3" aria-expanded={collapseable === Collapsables.Metadata} onClick={() => setCollapsed(Collapsables.Metadata)}> <div> - <FontAwesomeIcon icon={faChevronDown} rotation={metadata ? undefined : 270} /> + <FontAwesomeIcon icon={faChevronDown} rotation={collapseable === Collapsables.Metadata ? undefined : 270} /> Metadata </div> </div> - <Collapse in={metadata}> + <Collapse in={collapseable === Collapsables.Metadata}> <div id={`collapse-${deviceId}`}> {JsonViewer({ json: metadataObject, options: { editable: false, searchEnabled: false } })} </div> </Collapse> + + <div className="d-flex justify-content-between clickable mt-3" aria-expanded={collapseable === Collapsables.Config} onClick={() => setCollapsed(Collapsables.Config)}> + <div> + <FontAwesomeIcon icon={faChevronDown} rotation={collapseable === Collapsables.Config ? undefined : 270} /> + Config + </div> + </div> + + <Collapse in={collapseable === Collapsables.Config}> + <div id={`collapse-${deviceId}`}> + {JsonViewer({ json: configObject, options: { editable: false, searchEnabled: false } })} + </div> + </Collapse> </div> </div > ) diff --git a/react-ui/src/components/devices/view_model/device.table.viewmodel.ts b/react-ui/src/components/devices/view_model/device.list.viewmodel.ts similarity index 100% rename from react-ui/src/components/devices/view_model/device.table.viewmodel.ts rename to react-ui/src/components/devices/view_model/device.list.viewmodel.ts diff --git a/react-ui/src/index.scss b/react-ui/src/index.scss index 5c9f184b7fc394991e9895411be67b1f31f36722..610261473b771dcbdb508afe81966f54237cbaff 100755 --- a/react-ui/src/index.scss +++ b/react-ui/src/index.scss @@ -1,9 +1,16 @@ @import "./shared/style/index.scss"; +@import "./shared/style/colors.scss"; body { margin: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: map-get($theme-colors, bg-primary) !important; + // background-color: map-get($theme-colors, bg-primary) !important; + + background: linear-gradient( + to top, + rgba(lighten(map-get($map: $theme-colors, $key: primary), 20%), 0.1) 0%, + rgba(map-get($map: $theme-colors, $key: bg-primary), 0.9) 90% + ); } diff --git a/react-ui/src/index.tsx b/react-ui/src/index.tsx index 0a8fbbf254f9e64b5f05aef93bce102ff06b65bb..7d6cfe0ac8c3e70a5bb2a15e7701f5e4f62be12c 100755 --- a/react-ui/src/index.tsx +++ b/react-ui/src/index.tsx @@ -1,9 +1,11 @@ import { UtilsProvider } from '@provider/utils.provider' +import AnimatedBackground from '@shared/components/background/background.view' import i18next from 'i18next' import React from 'react' import ReactDOM from 'react-dom/client' import { ErrorBoundary } from "react-error-boundary" import { I18nextProvider } from 'react-i18next' +import { SkeletonTheme } from 'react-loading-skeleton' import { Provider } from 'react-redux' import { RouterProvider @@ -19,18 +21,22 @@ window.env = window.location.hostname === 'localhost' ? 'development' : 'product ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> - <ErrorBoundary fallback={<div>Something went wrong</div>}> - <Provider store={store}> - <PersistGate loading={null} persistor={persistor}> - <I18nextProvider i18n={i18next}> - <UtilsProvider> - <ToastContainer /> - <RouterProvider router={router} /> - </UtilsProvider> - </I18nextProvider> - </PersistGate> - </Provider> - </ErrorBoundary> + <AnimatedBackground> + <ErrorBoundary fallback={<div>Something went wrong</div>}> + <SkeletonTheme height="2em" > + <Provider store={store}> + <PersistGate loading={null} persistor={persistor}> + <I18nextProvider i18n={i18next}> + <UtilsProvider> + <ToastContainer /> + <RouterProvider router={router} /> + </UtilsProvider> + </I18nextProvider> + </PersistGate> + </Provider> + </SkeletonTheme> + </ErrorBoundary> + </AnimatedBackground> </React.StrictMode> ); diff --git a/react-ui/src/shared/components/background/background.view.tsx b/react-ui/src/shared/components/background/background.view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27cf56cebe2fe345a6302762e14975aaf79a57b6 --- /dev/null +++ b/react-ui/src/shared/components/background/background.view.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; + +const AnimatedBackground = ({ children }) => { + const containerRef = useRef(null); + const [linePaths, setLinePaths] = useState([]); + + useEffect(() => { + const updatePaths = () => { + if (containerRef.current) { + const box = containerRef.current.getBoundingClientRect(); + const width = box.width; + const height = box.height; + + // Calculate starting points and angles for parallel lines + const startX = width * 0; // Start from right side + const startY = height * 0.9; // Start from bottom + const endX = width * 1.2; // End at 30% of width + const endY = height * -0.3; // End at 30% of height + const spacing = 80; // 2em spacing between lines + + // Calculate angle and offsets + const angle = Math.atan2(startY - endY, startX - endX); + const xOffset = spacing * Math.sin(angle); + const yOffset = spacing * Math.cos(angle); + + // Create 5 parallel lines + const paths = Array.from({ length: 7 }).map((_, index) => { + const startXOffset = index * xOffset; + const startYOffset = index * yOffset; + + return `M ${startX - startXOffset} ${startY - startYOffset} + L ${endX - startXOffset} ${endY - startYOffset}`; + }); + + setLinePaths(paths); + } + }; + + updatePaths(); + window.addEventListener('resize', updatePaths); + return () => window.removeEventListener('resize', updatePaths); + }, []); + + return ( + <div ref={containerRef} className="position-relative min-vh-100"> + + {/* Animated lines */} + <div className="position-absolute w-100 h-100"> + <svg className="w-100 h-100"> + {linePaths.map((path, index) => ( + <g key={index}> + {/* Background line */} + {/* Animated border */} + <path + d={path} + fill="none" + stroke={`${index % 2 === 0 ? 'rgba(179, 80, 224, 0.4)' : 'rgba(150, 50, 190, .2)'}`} + strokeWidth="2" + strokeLinecap="round" + strokeDasharray="40 240" + style={{ + animation: `dash ${0.4 * index + 20}s linear infinite`, + }} + /> + </g> + ))} + </svg> + </div> + + {/* Content container */} + <div className="position-relative" style={{ zIndex: 10 }}> + {children} + </div> + + <style> + {` + @keyframes dash { + from { + stroke-dashoffset: 280; + } + to { + stroke-dashoffset: -280; + } + } + + `} + </style> + </div> + ); +}; + +export default AnimatedBackground; \ No newline at end of file diff --git a/react-ui/src/shared/components/box/gridBox.view.scss b/react-ui/src/shared/components/box/gridBox.view.scss new file mode 100644 index 0000000000000000000000000000000000000000..f247f654c322faf306bffd7da1dcccd8ad4552d1 --- /dev/null +++ b/react-ui/src/shared/components/box/gridBox.view.scss @@ -0,0 +1,67 @@ +@import "/src/shared/style/colors.scss"; + +.grid-box:hover .grid-box-dependency { + opacity: 0.7; + transition: opacity 0.3s ease-in-out; +} + +.grid-box-dependency { + opacity: 0.4; + font-size: 0.9em; +} + +.grid-box .content { + overflow-x: clip; + overflow-y: auto; +} + +$box-padding: 1.5em; +$border-radius: 0.25em; +$border-width: 2px; +$transition-duration: 0.3s; + +.c-box { + padding: $box-padding / 2 $box-padding !important; + background-color: white; + position: relative; + transition: box-shadow $transition-duration ease-in-out; + @extend .border-gradient; + @extend .rounded; + box-shadow: $box-shadow; + + &:hover { + box-shadow: 0 0.5rem 1rem rgba(map-get($theme-colors, "primary"), 0.2); + + &::before { + opacity: 1; + } + } +} + +.c-box-title { + $text-color: black; + color: $text-color; + padding: 0.5em 0; + margin-top: 0.2em; + + small { + font-size: 0.75em; + color: rgba($text-color, 0.65); + &::before { + content: "("; + } + &::after { + content: ")"; + } + } +} + +.rounded { + border-radius: $border-radius; +} + +.abstract-box { + padding: $box-padding; + font-size: 0.9em; + border-radius: calc($border-radius / 2); +} diff --git a/react-ui/src/shared/components/box/gridBox.view.tsx b/react-ui/src/shared/components/box/gridBox.view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77bed39130f36d9b5815f7256d642b448003e929 --- /dev/null +++ b/react-ui/src/shared/components/box/gridBox.view.tsx @@ -0,0 +1,37 @@ +import { faGripVertical } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import UpdateIndicator from "@layout/grid.layout/update-inidicator.layout/update-indicator.layout" +import { Category, CategoryType } from "@shared/types/category.type" +import { Col, Container, Row } from "react-bootstrap" +import './gridBox.view.scss' + +interface GridBoxProps { + title: string, + children: React.ReactNode, + className?: string, +} + + +export const GridBox: React.FC<GridBoxProps> = ({ children, title, className = "" }) => { + return ( + <div className="grid-box h-100"> + <Container fluid className={`c-box d-flex flex-column h-100 ${className}`}> + <div> + <UpdateIndicator + category={Category.DEVICE as CategoryType} + updateInterval={15000} + /> + <FontAwesomeIcon icon={faGripVertical} className="drag-handle" /> + <Row className="mb-0"> + <Col xs={12}> + <h4 className='c-box-title'>{title}</h4> + </Col> + </Row> + </div> + <div className="flex-grow-1 content"> + {children} + </div> + </Container> + </div> + ) +} \ No newline at end of file diff --git a/react-ui/src/shared/components/json_viewer/reducer/json_viewer.reducer.ts b/react-ui/src/shared/components/json_viewer/reducer/json_viewer.reducer.ts index dd432535cba4f5cb7a37140ddfc00c05f8e28a94..093dde85084257853534ef3d75c9dd295088de81 100755 --- a/react-ui/src/shared/components/json_viewer/reducer/json_viewer.reducer.ts +++ b/react-ui/src/shared/components/json_viewer/reducer/json_viewer.reducer.ts @@ -25,8 +25,6 @@ interface CollapsedItem { } export interface ReducerState { - breadcrumbs: Array<string>, - /** * Meta container containg identifier of * all non collapsed json objects @@ -35,7 +33,6 @@ export interface ReducerState { } const initialState: ReducerState = { - breadcrumbs: [], collapseContainer: [], } @@ -76,14 +73,11 @@ const JsonViewerSlice = createSlice({ collapsed: CollapseActions[collapse](item.collapsed) } }) - }, - setBreadcrumbs: (state, { payload }: PayloadAction<Array<string>>) => { - state.breadcrumbs = payload - }, - }, + } + } }) -export const { toggleCollapse, setBreadcrumbs } = JsonViewerSlice.actions +export const { toggleCollapse, } = JsonViewerSlice.actions export default JsonViewerSlice.reducer diff --git a/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx b/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx index ba53d64d7446ae07daffffcd5467734b852c72cd..68f0d609879c285ab654a87d751c471159adbcba 100755 --- a/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx +++ b/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx @@ -5,9 +5,11 @@ import DOMPurify from 'dompurify' import React, { Suspense, useMemo, useRef } from "react" import { Form, Table } from "react-bootstrap" import { useTranslation } from "react-i18next" +import Skeleton from 'react-loading-skeleton' import { useJsonViewer } from "../viewmodel/json_viewer.viewmodel" import './json_viewer.scss' + type JsonViewerProbs = { json: JSON, options?: { @@ -21,11 +23,15 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr const htmlContainer = useRef(null); const search = useRef<HTMLInputElement>(null); - const { getSubset, breadcrumbs, isCollapsed, collapseable, collapse, parameterizedJson, searchTerm } = useJsonViewer({ json, search, container: htmlContainer }); + const { getSubset, isCollapsed, collapseable, collapse, parameterizedJson, searchTerm } = useJsonViewer({ json, search, container: htmlContainer }); const renderInner = (innerJson: JSON, nested: number = 0, parentKey: string = "", path: string = "/network-instance/0/"): JSX.Element => { path += parentKey + (parentKey === "" ? "" : "/") + if (Object.entries(innerJson).length === 0) { + return <Skeleton count={3}></Skeleton> + } + return Object.entries(innerJson).map(([key, child]): JSX.Element => { let collapsed = isCollapsed(key, nested); @@ -104,18 +110,18 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr } - const hierarchyHTML = useMemo(() => { + const Hierarchy = useMemo(() => { const subset = getSubset(json); return ( - <> - <Suspense fallback={<div>loading...</div>}> - {renderJson(subset)} - </Suspense> - </> + <Suspense> + {renderJson(subset)} + </Suspense> ) }, [json, collapseable, searchTerm]) - const searchHTML = (): React.ReactElement => { + + + const Search = (): React.ReactElement => { return ( <Form.Group controlId='json_viewer.search' className='p-0 '> <Form.Control type="text" placeholder={t('device.search.placeholder')} ref={search} /> @@ -125,8 +131,8 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr return ( <div ref={htmlContainer}> - {options?.searchEnabled && searchHTML()} - {hierarchyHTML} + {options?.searchEnabled && Search()} + {Hierarchy} </div> ) } \ No newline at end of file diff --git a/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx b/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx index 6a0c80c7e0ec7d09354415bec67fb1b33f333399..ccbeac7135e6e26ed310ef7ccb4360282516dd95 100644 --- a/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx +++ b/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx @@ -19,7 +19,7 @@ interface JsonViewerViewModelType { } export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelType) => { - const { breadcrumbs, collapseContainer } = useAppSelector(state => state.json_viwer) + const { collapseContainer } = useAppSelector(state => state.json_viwer) const dispatch = useAppDispatch(); const [searchTerm, setSearchTerm] = useState(''); const { toClipboard } = useUtils(); @@ -31,10 +31,8 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy const parameterizedJsonMap = useRef<Array<string>>([]); - const getSubset = (json: JSON) => { - const subset = breadcrumbs.reduce((nested, key) => nested?.[key], json); - - let inner = subset; + const getSubset = (json: JSON): JSON => { + let inner = json; const keys: Array<string> = []; while (Object.keys(inner).length === 1) { const key: string = Object.keys(inner)[0]; @@ -42,7 +40,6 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy keys.push(key); } - //dispatch(setBreadcrumbs([...breadcrumbs, ...keys])) return inner } @@ -160,7 +157,6 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy return { getSubset, - breadcrumbs, collapseable: collapseContainer, isCollapsed, collapse, diff --git a/react-ui/src/shared/layouts/grid.layout/grid.layout.scss b/react-ui/src/shared/layouts/grid.layout/grid.layout.scss index c90375d371f5e78827676f9520f03b1b388a5cc4..e1b04be8095bb9cf5cee747ab9892fb9071eb247 100644 --- a/react-ui/src/shared/layouts/grid.layout/grid.layout.scss +++ b/react-ui/src/shared/layouts/grid.layout/grid.layout.scss @@ -24,8 +24,6 @@ } .react-grid-item { - min-height: 600px !important; - &.react-draggable-dragging { z-index: 100; @@ -35,12 +33,8 @@ } } -.react-grid-layout { - width: 100% !important; -} - .react-grid-item.react-grid-placeholder { - background: lighten(map-get($theme-colors, primary), 30%) !important; + background: lighten(map-get($theme-colors, primary), 10%) !important; opacity: 0.2; transition-duration: 100ms; z-index: 2; @@ -53,6 +47,7 @@ } .react-grid-item { + height: 100px; /* Hide resize handle by default */ .react-resizable-handle-se { opacity: 0; diff --git a/react-ui/src/shared/layouts/grid.layout/grid.layout.tsx b/react-ui/src/shared/layouts/grid.layout/grid.layout.tsx index 710e178890b3aebfb22dde5b72074f21108e8ac1..694c74478f45fb7bc977a7b31e3ad81f1f3f859e 100644 --- a/react-ui/src/shared/layouts/grid.layout/grid.layout.tsx +++ b/react-ui/src/shared/layouts/grid.layout/grid.layout.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useEffect, useState } from 'react'; +import React, { ReactElement, useEffect, useRef, useState } from 'react'; import { Responsive, WidthProvider } from 'react-grid-layout'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; @@ -10,20 +10,52 @@ interface GridLayoutProps { children: ReactElement; } +getComputedStyle +const RowCount = 2; +const padding = 80; // in px + + export const GridLayout: React.FC<GridLayoutProps> = ({ children }) => { - const rowHeight = 50; - const [mounted, setMounted] = useState(false); + const [rowHeight, setRowHeight] = useState<number>(0); + const [containerHeight, setContainerHeight] = useState<number>(0); + const [mounted, setMounted] = useState<boolean>(false); + const containerRef = useRef<HTMLDivElement>(null); + const layouts = { lg: [ - { i: 'device-list', x: 0, y: 0, w: 2, h: 1, minW: 2, minH: 1 }, - { i: 'device-details', x: 2, y: 0, w: 2, h: 1, minW: 2, minH: 1 } + { i: 'device-list', x: 0, y: 0, w: 2, h: 1, minW: 1, minH: 1 }, + { i: 'device-metadata', x: 0, y: 1, w: 2, h: 1, minW: 1, minH: 1 }, + { i: 'device-details', x: 2, y: 0, w: 2, h: 2, minW: 1, minH: 1 } ] }; + const calcHeights = () => { + const container = containerRef.current; + if (!container) { + // Fallback to body height if container is not available + const height = document.body.clientHeight * 0.7; + setRowHeight(Math.floor(height / RowCount)); + return; + } + + const { top } = container.getBoundingClientRect(); + const height = document.body.clientHeight - (top + padding); + setContainerHeight(height); + setRowHeight(Math.floor(height / RowCount)); + console.log(Math.floor(height / RowCount)); + + }; + + useEffect(() => { + calcHeights(); + }, [containerRef.current]) + useEffect(() => { setMounted(true); - // Force layout recalculation after mount - window.dispatchEvent(new Event('resize')); + + + window.addEventListener('resize', calcHeights); + return () => window.removeEventListener('resize', calcHeights); }, []); const gridItems = React.Children.map(children.props.children, (child, index) => { @@ -36,12 +68,19 @@ export const GridLayout: React.FC<GridLayoutProps> = ({ children }) => { }); return ( - <div style={{ display: mounted ? 'block' : 'none' }}> + <div + ref={containerRef} + style={{ + display: mounted ? 'block' : 'none', + height: `${containerHeight}px` + }} + > <ResponsiveGridLayout className="layout" layouts={layouts} breakpoints={{ lg: 996, sm: 480 }} cols={{ lg: 4, sm: 3 }} + maxRows={RowCount} rowHeight={rowHeight} margin={[20, 20]} draggableHandle=".drag-handle" @@ -50,7 +89,8 @@ export const GridLayout: React.FC<GridLayoutProps> = ({ children }) => { preventCollision={true} compactType={null} useCSSTransforms={mounted} - resizeHandles={['se']} // Only show resize handle in bottom right corner + resizeHandles={['se']} + > {gridItems} </ResponsiveGridLayout> diff --git a/react-ui/src/shared/layouts/protected.layout/protected.layout.tsx b/react-ui/src/shared/layouts/protected.layout/protected.layout.tsx index dbcb49192988956ac0aa2eafd4c92575f0364c5c..4e54f58dee5f7a1ae7c6cf0c90aa81bb5d24fd82 100755 --- a/react-ui/src/shared/layouts/protected.layout/protected.layout.tsx +++ b/react-ui/src/shared/layouts/protected.layout/protected.layout.tsx @@ -71,10 +71,10 @@ export const ProtectedLayout = () => { const HorizontalNavbar = () => { return ( - <Container fluid> + <Container fluid className="mb-4"> <Row> <Col> - <nav id="navbar" className="bg-white mx-4 mt-4 d-flex align-items-center c-box"> + <nav id="navbar" className="bg-white mx-4 mt-4 d-flex align-items-center c-box overflow-visible"> <Link to="/"><img src={logo} className="mx-4" width={45} alt="logo" /></Link> <Link className={"head-links" + handleActiveLink(DEVICE_URL)} to="/">{t('protected.link.device_list')}</Link> <Link className={"head-links" + handleActiveLink('/map')} to="/">{t('protected.link.map')}</Link> @@ -106,7 +106,9 @@ export const ProtectedLayout = () => { return ( <MenuProvider> {HorizontalNavbar()} - <Outlet /> + <div className="px-3"> + <Outlet /> + </div> </MenuProvider> ) }; \ No newline at end of file diff --git a/react-ui/src/shared/style/box.scss b/react-ui/src/shared/style/box.scss deleted file mode 100755 index 62dd6530d63e2d4680b6ae9082e80afa50b7b95e..0000000000000000000000000000000000000000 --- a/react-ui/src/shared/style/box.scss +++ /dev/null @@ -1,84 +0,0 @@ -@import "./colors.scss"; - -$box-padding: 1.5em; -$border-radius: 0.25em; -$border-width: 2px; -$transition-duration: 0.3s; - -.c-box { - padding: $box-padding / 2 $box-padding !important; - background-color: white; - position: relative; - transition: box-shadow $transition-duration ease-in-out; - @extend .border-gradient; - @extend .rounded; - box-shadow: $box-shadow; - - &:hover { - box-shadow: 0 0.5rem 1rem rgba(map-get($theme-colors, "primary"), 0.2); - - &::before { - opacity: 1; - } - } -} - -.c-box-title { - $text-color: black; - color: $text-color; - padding: 0.5em 0; - margin-top: 0.2em; - - small { - font-size: 0.75em; - color: rgba($text-color, 0.65); - &::before { - content: "("; - } - &::after { - content: ")"; - } - } -} - -.rounded { - border-radius: $border-radius; -} - -.abstract-box { - padding: $box-padding; - font-size: 0.9em; - border-radius: calc($border-radius / 2); -} - -.border-gradient { - background: - linear-gradient(white, white) padding-box, - linear-gradient( - 180deg, - rgba(map-get($theme-colors, "primary"), 0.4) 0%, - rgba(map-get($theme-colors, "primary"), 0.2) 40%, - rgba(map-get($theme-colors, "primary"), 0.1) 100% - ) - border-box; - border: $border-width solid transparent; - - &::before { - content: ""; - position: absolute; - top: -$border-width; - left: -$border-width; - right: -$border-width; - bottom: -$border-width; - background: linear-gradient( - 180deg, - rgba(map-get($theme-colors, "primary"), 0.4) 0%, - rgba(map-get($theme-colors, "primary"), 0.2) 60%, - rgba(map-get($theme-colors, "primary"), 0.1) 100% - ); - border-radius: inherit; - z-index: -1; - opacity: 0; - transition: opacity $transition-duration ease-in-out; - } -} diff --git a/react-ui/src/shared/style/colors.scss b/react-ui/src/shared/style/colors.scss index bbeff3733af39224fa9d938214e2c31d2a5f4ee5..ce3c3cf90bf53a447a9f84b3fa9549beede92ab2 100755 --- a/react-ui/src/shared/style/colors.scss +++ b/react-ui/src/shared/style/colors.scss @@ -10,5 +10,40 @@ $theme-colors: ( ); $box-shadow: 0px 4px 8px rgba(map-get($theme-colors, "primary"), 0.2); +$transition-duration: 0.3s; @import "/node_modules/bootstrap/scss/bootstrap"; + +// Gradients + +.border-gradient { + background: + linear-gradient(white, white) padding-box, + linear-gradient( + 180deg, + rgba(map-get($theme-colors, "primary"), 0.4) 0%, + rgba(map-get($theme-colors, "primary"), 0.2) 40%, + rgba(map-get($theme-colors, "primary"), 0.1) 100% + ) + border-box; + border: $border-width solid transparent; + + &::before { + content: ""; + position: absolute; + top: -$border-width; + left: -$border-width; + right: -$border-width; + bottom: -$border-width; + background: linear-gradient( + 180deg, + rgba(map-get($theme-colors, "primary"), 0.4) 0%, + rgba(map-get($theme-colors, "primary"), 0.2) 60%, + rgba(map-get($theme-colors, "primary"), 0.1) 100% + ); + border-radius: inherit; + z-index: -1; + opacity: 0; + transition: opacity $transition-duration ease-in-out; + } +} diff --git a/react-ui/src/shared/style/index.scss b/react-ui/src/shared/style/index.scss index e210c84154bd1413e3b23ca906e9a555862599a4..b70f6c35e87e35090674a0ac0041e9118a329d3a 100755 --- a/react-ui/src/shared/style/index.scss +++ b/react-ui/src/shared/style/index.scss @@ -1,5 +1,7 @@ @import "./fonts.scss"; -@import './colors.scss'; -@import './utils.scss'; -@import './box.scss'; -@import './toast.scss'; +@import "./colors.scss"; +@import "./utils.scss"; +@import "./toast.scss"; +@import "./skeleton.scss"; + +@import "/node_modules/react-loading-skeleton/dist/skeleton.css"; diff --git a/react-ui/src/shared/style/skeleton.scss b/react-ui/src/shared/style/skeleton.scss new file mode 100644 index 0000000000000000000000000000000000000000..680cadd74c6d2c293772e36eb44f1feb896d2307 --- /dev/null +++ b/react-ui/src/shared/style/skeleton.scss @@ -0,0 +1,3 @@ +.react-loading-skeleton { + margin: 0.25em 0; +} diff --git a/react-ui/yarn.lock b/react-ui/yarn.lock index db8fb7d3c16b6ee32f476f99416a6a89236a6891..224cc0ddd12db6f8a8c6ff139a8f4015d76e03dc 100755 --- a/react-ui/yarn.lock +++ b/react-ui/yarn.lock @@ -8915,6 +8915,11 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-loading-skeleton@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz#da2090355b4dedcad5c53cb3f0ed364e3a76d6ca" + integrity sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ== + react-redux@^9.1.2: version "9.2.0" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"